getServer(); $sabreRequest = new SabreRequest( $request->getMethod(), $this->normalizeRequestUri($request->getRequestUri()), $this->normalizeHeaders($request->headers->all()), ); $body = $request->getContent(asResource: false); if (!empty($body)) { $sabreRequest->setBody($body); } $sabreResponse = new SabreResponse(); $sabreRequest->setBaseUrl($server->getBaseUri()); $server->httpRequest = $sabreRequest; $server->httpResponse = $sabreResponse; try { $server->invokeMethod($sabreRequest, $sabreResponse, false); } catch (\Sabre\DAV\Exception $e) { try { $server->emit('exception', [$e]); } catch (\Throwable $ignore) {} $sabreResponse->setStatus($e->getHTTPCode()); foreach ($e->getHTTPHeaders($server) as $name => $value) { $sabreResponse->setHeader($name, $value); } $sabreResponse->setBody($this->serializeException($e, $server)); } catch (\Throwable $e) { try { $server->emit('exception', [$e]); } catch (\Throwable $ignore) {} $sabreResponse->setStatus(500); $sabreResponse->setBody($this->serializeException($e, $server)); } return $this->buildKtxcResponse($sabreResponse); } private function getServer(): Server { return $this->buildServer(); } private function buildServer(): Server { $principalBackend = new PrincipalBackend($this->identity, $this->tenant); $calDavBackend = new CalDavBackend($this->chronoManager, $this->identity, $this->tenant, $this->uriMap, new IcalEncoder(), new IcalDecoder()); $cardDavBackend = new CardDavBackend($this->peopleManager, $this->identity, $this->tenant, $this->uriMap); $server = new Server([ new PrincipalCollection($principalBackend, PrincipalBackend::PRINCIPAL_PREFIX), new CalendarRoot($principalBackend, $calDavBackend), new AddressBookRoot($principalBackend, $cardDavBackend), new FilesRootNode($this->documentsManager, $this->identity, $this->tenant), ]); $server->setBaseUri(self::BASE_URI); $server->debugExceptions = $this->debug; // Authentication plugin — short-circuits when JWT/Bearer already populated $authBackend = new DavAuthBackend($this->identity, $this->securityService); $server->addPlugin(new AuthPlugin($authBackend)); // DAVACL — required for CalDAV / CardDAV principal resolution $aclPlugin = new DavACLPlugin(); $userId = (string) ($this->identity->identifier() ?? 'anonymous'); $aclPlugin->adminPrincipals = [PrincipalBackend::PRINCIPAL_PREFIX . '/' . $userId]; $server->addPlugin($aclPlugin); $server->addPlugin(new CalDAVPlugin()); $server->addPlugin(new CardDAVPlugin()); $server->addPlugin(new VCFExportPlugin()); // File-based lock backend (single-server; swap for DB/Redis in multi-node setups) $davDir = $this->rootDir . '/var/dav'; if (!is_dir($davDir)) { mkdir($davDir, 0750, true); } $server->addPlugin(new LocksPlugin(new LocksBackendFile($davDir . '/locks.db'))); $server->addPlugin(new BrowserPlugin()); return $server; } private function serializeException(\Throwable $e, Server $server): string { $h = static fn (string $s): string => htmlspecialchars($s, ENT_NOQUOTES, 'UTF-8'); $dom = new \DOMDocument('1.0', 'utf-8'); $dom->formatOutput = true; $error = $dom->createElementNS('http://sabredav.org/ns', 's:error'); $error->setAttribute('xmlns:s', 'http://sabredav.org/ns'); $dom->appendChild($error); $error->appendChild($dom->createElement('s:exception', $h(get_class($e)))); $error->appendChild($dom->createElement('s:message', $h($e->getMessage()))); if ($this->debug) { $error->appendChild($dom->createElement('s:file', $h($e->getFile()))); $error->appendChild($dom->createElement('s:line', $h((string) $e->getLine()))); $error->appendChild($dom->createElement('s:code', $h((string) $e->getCode()))); $error->appendChild($dom->createElement('s:stacktrace', $h($e->getTraceAsString()))); } if ($e instanceof \Sabre\DAV\Exception) { $e->serialize($server, $error); } return $dom->saveXML(); } // ------------------------------------------------------------------------- // HTTP bridge helpers // ------------------------------------------------------------------------- private function normalizeRequestUri(string $uri): string { // Ensure requests for the bare base path (e.g. /dav) include the trailing // slash so sabre's getPath() keeps them within the base URI /dav/. $path = strtok($uri, '?'); if ($path === rtrim(self::BASE_URI, '/')) { return self::BASE_URI . (str_contains($uri, '?') ? '?' . explode('?', $uri, 2)[1] : ''); } return $uri; } private function normalizeHeaders(array $symfonyHeaders): array { $normalized = []; foreach ($symfonyHeaders as $name => $values) { $key = str_replace(' ', '-', ucwords(str_replace('-', ' ', strtolower($name)))); $normalized[$key] = implode(', ', (array) $values); } return $normalized; } private function buildKtxcResponse(SabreResponse $sabreResponse): Response { $status = $sabreResponse->getStatus(); $body = $sabreResponse->getBody(); // Collect headers; skip security headers that KTXC re-applies by default $headers = []; foreach ($sabreResponse->getHeaders() as $name => $value) { $headers[$name] = $value; } if (is_resource($body)) { // Stream the body to avoid buffering potentially large files $stream = $body; return new StreamedResponse( static function () use ($stream): void { while (!feof($stream)) { echo fread($stream, 8192); } }, $status, $headers, ); } return new Response((string) ($body ?? ''), $status, $headers); } }