generated from Nodarx/template
219 lines
8.2 KiB
PHP
219 lines
8.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace KTXM\ServiceDav;
|
|
|
|
use DI\Attribute\Inject;
|
|
use KTXC\Http\Request\Request;
|
|
use KTXC\Http\Response\Response;
|
|
use KTXC\Http\Response\StreamedResponse;
|
|
use KTXC\Service\SecurityService;
|
|
use KTXC\SessionIdentity;
|
|
use KTXC\SessionTenant;
|
|
use KTXM\ChronoManager\Manager as ChronoManager;
|
|
use KTXM\DocumentsManager\Manager as DocumentsManager;
|
|
use KTXM\PeopleManager\Manager as PeopleManager;
|
|
use KTXM\ServiceDav\Auth\DavAuthBackend;
|
|
use KTXM\ServiceDav\Backends\CalDavBackend;
|
|
use KTXM\ServiceDav\Backends\CardDavBackend;
|
|
use KTXM\ServiceDav\Backends\FilesRootNode;
|
|
use KTXM\ServiceDav\Backends\PrincipalBackend;
|
|
use Sabre\CalDAV\CalendarRoot;
|
|
use Sabre\CalDAV\Plugin as CalDAVPlugin;
|
|
use Sabre\CardDAV\AddressBookRoot;
|
|
use Sabre\CardDAV\Plugin as CardDAVPlugin;
|
|
use Sabre\CardDAV\VCFExportPlugin;
|
|
use Sabre\DAV\Auth\Plugin as AuthPlugin;
|
|
use Sabre\DAV\Browser\Plugin as BrowserPlugin;
|
|
use Sabre\DAV\Locks\Backend\File as LocksBackendFile;
|
|
use Sabre\DAV\Locks\Plugin as LocksPlugin;
|
|
use Sabre\DAV\Server;
|
|
use Sabre\DAVACL\Plugin as DavACLPlugin;
|
|
use Sabre\DAVACL\PrincipalCollection;
|
|
use Sabre\HTTP\Request as SabreRequest;
|
|
use Sabre\HTTP\Response as SabreResponse;
|
|
|
|
class DavServer
|
|
{
|
|
private const BASE_URI = '/dav/';
|
|
|
|
public function __construct(
|
|
private readonly SessionTenant $tenant,
|
|
private readonly SessionIdentity $identity,
|
|
private readonly SecurityService $securityService,
|
|
private readonly DocumentsManager $documentsManager,
|
|
private readonly ChronoManager $chronoManager,
|
|
private readonly PeopleManager $peopleManager,
|
|
private readonly DavUriMap $uriMap,
|
|
#[Inject('rootDir')] private readonly string $rootDir,
|
|
#[Inject('kernel.debug')] private readonly bool $debug = false,
|
|
) {}
|
|
|
|
public function process(Request $request): Response
|
|
{
|
|
$server = $this->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);
|
|
$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);
|
|
}
|
|
}
|