diff --git a/ci/testctl b/ci/testctl index 42bc12eb..ed6fe428 100755 --- a/ci/testctl +++ b/ci/testctl @@ -1,514 +1,516 @@ #!/bin/bash base_dir="$(dirname $(realpath "$0"))" pushd "${base_dir}" pushd .. set -e PASSPORT_PRIVATE_KEY="-----BEGIN PRIVATE KEY----- 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 bakhie7hJBP7BoOup+dD5NQPlXBQ434= -----END PRIVATE KEY-----" PASSPORT_PUBLIC_KEY="-----BEGIN PUBLIC KEY----- MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEApmHkae115z3vMF9IjiUa XrJH1Ljifxqs+XbDCFhQenz2iOf/bZDBMqfrwhBe3hVywa13kIuhf1TZlcJ/nRhE IZIbg6BF6/GeHN998Cx/IjhBeFkG7f1hdxEXY5ibd7yq8vAfzizZpnCu4+LM/NvI ei9mU+hj3GxrDbpMQTHCDDqX5aNEix8uFU4XaOm9UOJw9ivz19cqcnxWqJ49znxw XUlS5k4Hewbwf2s2BWFhz+CDWPHMpWViLQ17qj5QRklNn39sUymP1kXKP3Y76zvW zNVniPvFt+hHixxEa7tUEaTdtk4FgkNnWy0bFa7DIPC8ukjHvHHg10LeQiygDduI xbdVJwYK/qitU68ZAbbufldlj2FoVLwSyP9qpxUIdRoljCop1kDu6xQiAq0VHEAV LtgMpZhtD/6D4z644QJ8atx4Ljp/HEtTngaTh+/vLc9AnhHclX0/JmlbP1VkWWxG 9FIxS0TVPolRBs8948kLioTif9lrltJicxd6T0OuA5fny8l6hdSlzaD3pnr5pkYw mVn5FgBAFnd/skX6DsRs+eb2lNahkvNZZ15z9UeU2Rl6J9uBx0vbkcB7eiSCPhRM IKq/CtkyEgz8ce4kG18fJHDFDASRvGjaBu91lV5h7NLmhFLFzMvb7XoHrv/yD6TV E5W47J/DrfrevJkzS1GV++MCAwEAAQ== -----END PUBLIC KEY-----" export HOST=kolab.local export APP_WEBSITE_DOMAIN="$HOST" export APP_DOMAIN=$HOST export DES_KEY=kBxUM/53N9p9abusAoT0ZEAxwI2pxFz/ export DB_HOST=127.0.0.1 export KOLAB_SSL_CERTIFICATE=/etc/certs/kolab.local.cert export KOLAB_SSL_CERTIFICATE_KEY=/etc/certs/kolab.local.key export IMAP_HOST=localhost export IMAP_PORT=11143 export IMAP_ADMIN_LOGIN=cyrus-admin export IMAP_ADMIN_PASSWORD=simple123 export MAIL_HOST=localhost export MAIL_PORT=10587 export IMAP_DEBUG=true +export DAV_URI=http://localhost:11080/dav/ export FILEAPI_WOPI_OFFICE=https://$HOST export CALENDAR_CALDAV_SERVER=http://localhost:11080/dav export KOLAB_ADDRESSBOOK_CARDDAV_SERVER=http://localhost:11080/dav export DB_ROOT_PASSWORD=simple123 export DB_HKCCP_PASSWORD=simple123 export DB_KOLAB_PASSWORD=simple123 export DB_RC_PASSWORD=simple123 export DB_PASSWORD=simple123 export DB_USERNAME=kolabdev export DB_DATABASE=kolabdev export MINIO_ROOT_USER=minio export MINIO_ROOT_PASSWORD=simple123 export MINIO_USER=minio export MINIO_PASSWORD=simple123 export MEET_SERVER_TOKEN=simple123 export MEET_WEBHOOK_TOKEN=simple123 export PUBLIC_IP=127.0.0.1 export REDIS_PASSWORD=simple123 export CERTS_PATH=./ci/certs export IMAP_SPOOL_STORAGE=--mount=type=tmpfs,tmpfs-size=128M,tmpfs-mode=777,destination=/var/spool/imap,U=true,notmpcopyup export IMAP_LIB_STORAGE=--mount=type=tmpfs,tmpfs-size=128M,tmpfs-mode=777,destination=/var/lib/imap,U=true,notmpcopyup export SYNAPSE_STORAGE=--mount=type=tmpfs,tmpfs-size=128M,tmpfs-mode=777,destination=/data,U=true,notmpcopyup export MARIADB_STORAGE=--mount=type=tmpfs,tmpfs-size=512M,destination=/var/lib/mysql,U=true export REDIS_STORAGE=--mount=type=tmpfs,tmpfs-size=128M,destination=/var/lib/redis,U=true export MINIO_STORAGE=--mount=type=tmpfs,tmpfs-size=128M,destination=/data,U=true export LDAP_STORAGE=--mount=type=tmpfs,tmpfs-size=128M,destination=/ldapdata,U=true,notmpcopyup export PASSPORT_SYNAPSE_OAUTH_CLIENT_ID=2909ca4f-df7e-45fe-b355-e7c195aef112 export PASSPORT_SYNAPSE_OAUTH_CLIENT_SECRET=2URb+3JGJM9wPuDnlUSTPOw2mqmHsoOV8NXanx9xwQM= export ENV_FILE=ci/env export PODMAN_IGNORE_CGROUPSV1_WARNING=true PODMAN="podman" source bin/podman_shared # Teardown the currently running environments (both the tests and dev pod) kolab__teardown() { $PODMAN pod rm --force tests $PODMAN pod rm --force dev } podman__build_tests() { podman__build docker/tests kolab-tests --ulimit nofile=65535:65535 } # Build all containers required for testing kolab__build() { pin_git_refs if [[ $1 != "" ]]; then if declare -f "podman__build_$1" >/dev/null 2>&1; then podman__build_$1 else podman__build docker/$1 $1 fi else podman__build_base podman__build_webapp podman__build_meet podman__build_imap podman__build docker/mariadb mariadb podman__build docker/redis redis podman__build_proxy podman__build docker/synapse synapse podman__build docker/element element podman__build_roundcube podman__build_tests env CERT_DIR=ci/certs APP_DOMAIN=$HOST bin/regen-certs fi } # Setup the test environment in the "tests" pod. kolab__setup() { echo "Build" kolab__build echo "Setup" export POD=tests # Create the pod first $PODMAN pod create --replace --name $POD podman__run_mariadb podman__run_redis podman__healthcheck $POD-mariadb $POD-redis podman__run_imap podman__healthcheck $POD-imap export CONFIG=config.demo podman__run_webapp podman__healthcheck $POD-webapp # Ensure all commands are processed echo "Flushing work queue" $PODMAN exec -ti $POD-webapp ./artisan queue:work --stop-when-empty podman__run_minio podman__healthcheck $POD-minio # Validate the test environment kolab__validate $POD } # Execute a testsuite (testsuite|quicktest|tests/Feature/Jobs/WalletCheckTest.php). Requires setup to have been executed previously to prepare the "tests" pod. kolab__test() { export POD=tests $PODMAN run -ti --pod tests --name $POD-kolab-tests --replace \ --env-file=ci/env \ -v ./src:/src/kolabsrc.orig:ro \ -e APP_SERVICES_DOMAINS="localhost" \ -e PASSPORT_PRIVATE_KEY="$PASSPORT_PRIVATE_KEY" \ -e PASSPORT_PUBLIC_KEY="$PASSPORT_PUBLIC_KEY" \ -e APP_URL="http://kolab.local" \ -e APP_PUBLIC_URL="http://kolab.local" \ -e APP_HEADER_CSP="" \ -e APP_HEADER_XFO="" \ -e ASSET_URL="http://kolab.local" \ -e MEET_SERVER_URLS="http://kolab.local/meetmedia/api/" \ + -e DAV_URI \ kolab-tests:latest /init.sh $@ } # Validate that the proxy works kolab__proxytest() { # Without element $PODMAN run -ti --rm \ -v ./ci/certs/:/etc/certs/:ro \ -e APP_WEBSITE_DOMAIN \ -e SSL_CERTIFICATE=${KOLAB_SSL_CERTIFICATE} \ -e SSL_CERTIFICATE_KEY=${KOLAB_SSL_CERTIFICATE_KEY} \ -e WEBAPP_BACKEND="http://localhost:8000" \ -e MEET_BACKEND="http://localhost:12080" \ -e ROUNDCUBE_BACKEND="http://localhost:8080" \ -e DAV_BACKEND="http://localhost:11080" \ -e COLLABORA_BACKEND="http://localhost:9980" \ -e SIEVE_BACKEND="localhost:4190" \ kolab-proxy:latest /init.sh validate # With element $PODMAN run -ti --rm \ -v ./ci/certs/:/etc/certs/:ro \ -e APP_WEBSITE_DOMAIN \ -e SSL_CERTIFICATE=${KOLAB_SSL_CERTIFICATE} \ -e SSL_CERTIFICATE_KEY=${KOLAB_SSL_CERTIFICATE_KEY} \ -e WEBAPP_BACKEND="http://localhost:8000" \ -e MEET_BACKEND="http://localhost:12080" \ -e ROUNDCUBE_BACKEND="http://localhost:8080" \ -e DAV_BACKEND="http://localhost:11080" \ -e COLLABORA_BACKEND="http://localhost:9980" \ -e SIEVE_BACKEND="localhost:4190" \ -e ELEMENT_BACKEND=http://element:8880 \ -e MATRIX_BACKEND=http://synapse:8008 \ kolab-proxy:latest /init.sh validate } # Validate that imap works kolab__imaptest() { # With tls $PODMAN run -ti --rm \ -v ./ci/certs/:/etc/certs/:ro \ $IMAP_SPOOL_STORAGE \ $IMAP_LIB_STORAGE \ -e SSL_CERTIFICATE=${KOLAB_SSL_CERTIFICATE} \ -e SSL_CERTIFICATE_KEY=${KOLAB_SSL_CERTIFICATE_KEY} \ -e TLS_SERVER_CA_FILE=${KOLAB_SSL_CERTIFICATE_KEY} \ -e APP_SERVICES_DOMAIN="localhost" \ -e SERVICES_PORT=8000 \ -e IMAP_ADMIN_LOGIN \ -e IMAP_ADMIN_PASSWORD \ -e WITH_TLS="true" \ kolab-imap:latest /init.sh validate # Without tls $PODMAN run -ti --rm \ $IMAP_SPOOL_STORAGE \ $IMAP_LIB_STORAGE \ -e APP_SERVICES_DOMAIN="localhost" \ -e SERVICES_PORT=8000 \ -e IMAP_ADMIN_LOGIN \ -e IMAP_ADMIN_PASSWORD \ kolab-imap:latest /init.sh validate # Frontend with tls $PODMAN run -ti --rm \ -v ./ci/certs/:/etc/certs/:ro \ $IMAP_SPOOL_STORAGE \ $IMAP_LIB_STORAGE \ -e SSL_CERTIFICATE=${KOLAB_SSL_CERTIFICATE} \ -e SSL_CERTIFICATE_KEY=${KOLAB_SSL_CERTIFICATE_KEY} \ -e TLS_SERVER_CA_FILE=${KOLAB_SSL_CERTIFICATE_KEY} \ -e APP_SERVICES_DOMAIN="localhost" \ -e SERVICES_PORT=8000 \ -e IMAP_ADMIN_LOGIN \ -e IMAP_ADMIN_PASSWORD \ -e ROLE="frontend" \ -e WITH_TLS="true" \ kolab-imap:latest /init.sh validate } # Lint the kolab4 codebase kolab__lint() { $PODMAN run --rm -ti \ -v ./src:/src/kolabsrc.orig:ro \ kolab-tests:latest /init.sh lint } # Setup the test environment and run a complete kolab4 testsuite kolab__testrun() { echo "Setup" kolab__setup echo "Test" kolab__test testsuite } # Setup the test environment and run all available testsuites (including roundcube etc.) kolab__testrun_complete() { echo "Setup" kolab__setup echo "Test" kolab__test lint kolab__test testsuite kolab__rctest syncroton lint kolab__rctest syncroton testsuite kolab__rctest irony lint # kolab__rctest irony testsuite kolab__rctest roundcubemail-plugins-kolab lint # kolab__rctest roundcubemail-plugins-kolab testsuite } # Get a shell inside the container. Without arguments his gives you a shell in the test container, with argument inside one of the containers in the dev pod. kolab__shell() { if [[ $1 != "" ]]; then POD="dev" container=$1 shift command podman exec -ti $POD-$container /bin/bash else kolab__test shell fi } # Run the roundcube testsuite kolab__rctest() { export POD=tests DEBUG_ARGS="-ti --rm --pod tests --name debug-$1 -e KOLABOBJECTS_COMPAT_MODE=true -e DEBUG_USERS=john@kolab.org" podman__run_roundcube ./init.sh $@ } # Get a shell inside the roundcube test container to run/debug tests kolab__rcshell() { export POD=tests DEBUG_ARGS="-ti --rm --pod tests --name debug-$1 -e KOLABOBJECTS_COMPAT_MODE=true -e DEBUG_USERS=john@kolab.org" podman__run_roundcube ./init.sh $@ } # Validate a deployment, currently only used for test pod kolab__validate() { POD=$1 $PODMAN exec $POD-imap testsaslauthd -u cyrus-admin -p simple123 $PODMAN exec $POD-imap testsaslauthd -u "john@kolab.org" -p simple123 # Ensure the inbox is created FOUND=false for i in {1..60}; do if $PODMAN exec $POD-imap bash -c 'echo "lm" | cyradm --auth PLAIN -u cyrus-admin -w simple123 --port 11143 localhost | grep "user/john@kolab.org"'; then echo "Found mailbox"; FOUND=true break else echo "Waiting for mailbox"; sleep 1; fi done if ! $FOUND; then echo "Failed to find the inbox for john@kolab.org" exit 1 fi } # Deploy a test deployment in the "dev" pod kolab__deploy() { export POD=dev if [ `getenforce` == "Enforcing" ]; then # Patches on how to correctly configure selinux are welcome echo "selinux breaks networking, please disable" exit 1 fi # Create the pod first $PODMAN pod create \ --replace \ --add-host=kolab.local:127.0.0.1 \ --publish "443:6443" \ --publish "465:6465" \ --publish "587:6587" \ --publish "143:6143" \ --publish "993:6993" \ --publish "6379:6379" \ --publish "3306:3306" \ --publish "11080:11080" \ --publish "11143:11143" \ --publish "11993:11993" \ --publish "44444:44444/udp" \ --publish "44444:44444/tcp" \ --name $POD podman__run_mariadb podman__run_redis podman__healthcheck $POD-mariadb $POD-redis # IMAP must be avialable for the seeder podman__run_imap podman__healthcheck $POD-imap export CONFIG=config.prod podman__run_webapp podman__healthcheck $POD-webapp # Ensure all commands are processed echo "Flushing work queue" $PODMAN exec -ti $POD-webapp ./artisan queue:work --stop-when-empty $PODMAN exec $POD-webapp ./artisan user:password "admin@kolab.local" "simple123" podman__run_synapse podman__run_element podman__run_minio podman__healthcheck $POD-minio podman__run_meet podman__run_roundcube podman__run_proxy podman__run_postfix podman__run_amavis podman__run_collabora echo "Deployment complete" } # Re-run a container in the dev pod kolab__run() { POD=dev podman__run_$1 } kolab__debug() { DEBUG_ARGS="-ti --rm --name debug-$1" podman__run_$1 /bin/bash } # Monitor vue files for changes, and automatically reload the dev webapp container if anything changes. Requires "entr" on the host. kolab__watch() { trap 'kill $(jobs -p) 2>/dev/null' EXIT find src/resources/ src/app -regex '.*\.\(vue\|php\|js\)$' | entr podman exec -ti dev-webapp bash -c "/update-source.sh; ./artisan octane:reload" & podman exec -ti dev-webapp npm run watch } # Get the host to trust the generated ca kolab__add_ca_trust() { sudo trust anchor --store ci/certs/ca.cert sudo update-ca-trust } # Generate mail in the admin inbox kolab__generate_mail() { $PODMAN run --pod=dev -t --rm kolab-utils:latest ./generatemail.py --maxAttachmentSize=3 --type=mail --count 100 --username admin@kolab.local --password simple123 --host localhost --port 11143 INBOX } # Trigger an activesync sync on the admin inbox kolab__syncroton_sync() { $PODMAN run -t --network=host --add-host=kolab.local:127.0.0.1 --rm kolab-utils:latest ./activesynccli.py --host kolab.local --user admin@kolab.local --password simple123 sync 38b950ebd62cd9a66929c89615d0fc04 } # Access logs of container kolab__logs() { POD=dev command podman logs --tail=1000 -f $POD-$1 } # Mysql shell kolab__db() { POD=${POD:-dev} $PODMAN exec -ti $POD-mariadb /bin/bash -c "mysql -h 127.0.0.1 -u kolabdev --password=simple123 --auto-rehash kolabdev" } kolab__rcdb() { POD=${POD:-dev} $PODMAN exec -ti $POD-mariadb /bin/bash -c "mysql -h 127.0.0.1 -u roundcube --password=simple123 --auto-rehash roundcube" } kolab__help() { cat </dev/null 2>&1; then "kolab__$cmdname" "${@:1}" else echo "Function $cmdname not recognized" >&2 kolab__help exit 1 fi diff --git a/src/app/Backends/DAV.php b/src/app/Backends/DAV.php index cef28caf..079152b7 100644 --- a/src/app/Backends/DAV.php +++ b/src/app/Backends/DAV.php @@ -1,648 +1,648 @@ 'urn:ietf:params:xml:ns:caldav', self::TYPE_VTODO => 'urn:ietf:params:xml:ns:caldav', self::TYPE_VCARD => 'urn:ietf:params:xml:ns:carddav', ]; protected $url; protected $user; protected $password; protected $responseHeaders = []; protected $homes; /** * Object constructor */ public function __construct($user, $password, $url = null) { $this->url = $url ?: \config('services.dav.uri'); $this->user = $user; $this->password = $password; } /** * Discover DAV home (root) collection of a specified type. * * @return array|false Home locations or False on error */ public function discover() { if (is_array($this->homes)) { return $this->homes; } $path = parse_url($this->url, PHP_URL_PATH); $body = '' . '' . '' . '' . '' . ''; // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it) - $response = $this->request('/', 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); + $response = $this->request('', 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); if (empty($response)) { \Log::error("Failed to get current-user-principal from the DAV server."); return false; } $elements = $response->getElementsByTagName('response'); $principal_href = ''; foreach ($elements as $element) { foreach ($element->getElementsByTagName('current-user-principal') as $prop) { $principal_href = $prop->nodeValue; break; } } if ($path && str_starts_with($principal_href, $path)) { $principal_href = substr($principal_href, strlen($path)); } $ns = [ 'xmlns:d="DAV:"', 'xmlns:cal="urn:ietf:params:xml:ns:caldav"', 'xmlns:card="urn:ietf:params:xml:ns:carddav"', ]; $body = '' . '' . '' . '' . '' . '' . '' . ''; $response = $this->request($principal_href, 'PROPFIND', $body); if (empty($response)) { \Log::error("Failed to get home collections from the DAV server."); return false; } $elements = $response->getElementsByTagName('response'); $homes = []; if ($element = $response->getElementsByTagName('response')->item(0)) { if ($prop = $element->getElementsByTagName('prop')->item(0)) { foreach ($prop->childNodes as $home) { if ($home->firstChild && $home->firstChild->localName == 'href') { $href = $home->firstChild->nodeValue; if ($path && str_starts_with($href, $path)) { $href = substr($href, strlen($path)); } $homes[$home->localName] = $href; } } } } return $this->homes = $homes; } /** * Get user home folder of specified type * * @param string $type Home type or component name * * @return string|null Folder location href */ public function getHome($type) { $options = [ self::TYPE_VEVENT => 'calendar-home-set', self::TYPE_VTODO => 'calendar-home-set', self::TYPE_VCARD => 'addressbook-home-set', self::TYPE_NOTIFICATION => 'notification-URL', ]; $homes = $this->discover(); if (is_array($homes) && isset($options[$type])) { return $homes[$options[$type]] ?? null; } return null; } /** * Check if we can connect to the DAV server * * @return bool True on success, False otherwise */ public static function healthcheck(): bool { // TODO return true; } /** * Get list of folders of specified type. * * @param string $component Component to filter by (VEVENT, VTODO, VCARD) * * @return false|array List of folders' metadata or False on error */ public function listFolders(string $component) { $root_href = $this->getHome($component); if ($root_href === null) { return false; } $ns = 'xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"'; $props = ''; if ($component != self::TYPE_VCARD) { $ns .= ' xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/" xmlns:k="Kolab:"'; $props = '' . '' . ''; } $body = '' . '' . '' . '' . '' . '' . $props . '' . ''; // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it) $headers = ['Depth' => 1, 'Prefer' => 'return-minimal']; $response = $this->request($root_href, 'PROPFIND', $body, $headers); if (empty($response)) { \Log::error("Failed to get folders list from the DAV server."); return false; } $folders = []; foreach ($response->getElementsByTagName('response') as $element) { $folder = DAV\Folder::fromDomElement($element); // Note: Addressbooks don't have 'type' specified if ( ($component == self::TYPE_VCARD && in_array('addressbook', $folder->types)) || in_array($component, $folder->components) ) { $folders[] = $folder; } } return $folders; } /** * Create a DAV object in a folder * * @param DAV\CommonObject $object Object * * @return false|DAV\CommonObject Object on success, False on error */ public function create(DAV\CommonObject $object) { $headers = ['Content-Type' => $object->contentType]; $content = (string) $object; if (!strlen($content)) { throw new \Exception("Cannot PUT an empty DAV object"); } $response = $this->request($object->href, 'PUT', $content, $headers); if ($response !== false) { if (!empty($this->responseHeaders['ETag'])) { $etag = $this->responseHeaders['ETag'][0]; if (preg_match('|^".*"$|', $etag)) { $etag = substr($etag, 1, -1); } $object->etag = $etag; } return $object; } return false; } /** * Update a DAV object in a folder * * @param DAV\CommonObject $object Object * * @return false|DAV\CommonObject Object on success, False on error */ public function update(DAV\CommonObject $object) { return $this->create($object); } /** * Delete a DAV object from a folder * * @param string $location Object location * * @return bool True on success, False on error */ public function delete(string $location) { $response = $this->request($location, 'DELETE', '', ['Depth' => 1, 'Prefer' => 'return-minimal']); return $response !== false; } /** * Create a DAV folder (collection) * * @param DAV\Folder $folder Folder object * * @return bool True on success, False on error */ public function folderCreate(DAV\Folder $folder) { $response = $this->request($folder->href, 'MKCOL', $folder->toXML('mkcol')); return $response !== false; } /** * Delete a DAV folder (collection) * * @param string $location Folder location * * @return bool True on success, False on error */ public function folderDelete($location) { $response = $this->request($location, 'DELETE'); return $response !== false; } /** * Get all properties of a folder. * * @param string $location Object location * * @return false|DAV\Folder Folder metadata or False on error */ public function folderInfo(string $location) { $body = DAV\Folder::propfindXML(); // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it) $response = $this->request($location, 'PROPFIND', $body, ['Depth' => 0, 'Prefer' => 'return-minimal']); if (!empty($response) && ($element = $response->getElementsByTagName('response')->item(0))) { return DAV\Folder::fromDomElement($element); } return false; } /** * Update a DAV folder (collection) * * @param DAV\Folder $folder Folder object * * @return bool True on success, False on error */ public function folderUpdate(DAV\Folder $folder) { // Note: Changing resourcetype property is forbidden (at least by Cyrus) $response = $this->request($folder->href, 'PROPPATCH', $folder->toXML('propertyupdate')); return $response !== false; } /** * Initialize default DAV folders (collections) * * @param \App\User $user User object * * @throws \Exception */ public static function initDefaultFolders(\App\User $user): void { if (!\config('services.dav.uri')) { return; } $folders = \config('services.dav.default_folders'); if (!count($folders)) { return; } // Cyrus DAV does not support proxy authorization via DAV. Even though it has // the Authorize-As header, it is used only for cummunication with Murder backends. // We use a one-time token instead. It's valid for 10 seconds, assume it's enough time. $password = \App\Auth\Utils::tokenCreate((string) $user->id); if ($password === null) { throw new \Exception("Failed to create an authentication token for DAV"); } $dav = new self($user->email, $password); foreach ($folders as $props) { $folder = new DAV\Folder(); $folder->href = $props['type'] . 's' . '/user/' . $user->email . '/' . $props['path']; $folder->types = ['collection', $props['type']]; $folder->name = $props['displayname'] ?? ''; $folder->components = $props['components'] ?? []; $existing = null; try { $existing = $dav->folderInfo($folder->href); } catch (RequestException $e) { // Cyrus DAV returns 503 Service Unavailable on a non-existing location (?) if ($e->getCode() != 503 && $e->getCode() != 404) { throw $e; } } // folder already exists? check the properties and update if needed if ($existing) { if ($existing->name != $folder->name || $existing->components != $folder->components) { if (!$dav->folderUpdate($folder)) { throw new \Exception("Failed to update DAV folder {$folder->href}"); } } } elseif (!$dav->folderCreate($folder)) { throw new \Exception("Failed to create DAV folder {$folder->href}"); } } } /** * Check server options (and authentication) * * @return false|array DAV capabilities on success, False on error */ public function options() { $response = $this->request('', 'OPTIONS'); if ($response !== false) { return preg_split('/,\s+/', implode(',', $this->responseHeaders['DAV'] ?? [])); } return false; } /** * Search DAV objects in a folder. * * @param string $location Folder location * @param DAV\Search $search Search request parameters * @param callable $callback A callback to execute on every item * * @return false|array List of objects on success, False on error */ public function search(string $location, DAV\Search $search, $callback = null) { $headers = ['Depth' => $search->depth, 'Prefer' => 'return-minimal']; $response = $this->request($location, 'REPORT', $search, $headers); if (empty($response)) { \Log::error("Failed to get objects from the DAV server."); return false; } $objects = []; foreach ($response->getElementsByTagName('response') as $element) { $object = $this->objectFromElement($element, $search->component); if ($callback) { $object = $callback($object); } if ($object) { if (is_array($object)) { $objects[$object[0]] = $object[1]; } else { $objects[] = $object; } } } return $objects; } /** * Fetch DAV objects data from a folder * * @param string $location Folder location * @param string $component Object type (VEVENT, VTODO, VCARD) * @param array $hrefs List of objects' locations to fetch * * @return false|array Objects metadata on success, False on error */ public function getObjects(string $location, string $component, array $hrefs = []) { if (empty($hrefs)) { return []; } $body = ''; foreach ($hrefs as $href) { $body .= '' . $href . ''; } $queries = [ self::TYPE_VEVENT => 'calendar-multiget', self::TYPE_VTODO => 'calendar-multiget', self::TYPE_VCARD => 'addressbook-multiget', ]; $types = [ self::TYPE_VEVENT => 'calendar-data', self::TYPE_VTODO => 'calendar-data', self::TYPE_VCARD => 'address-data', ]; $body = '' . ' ' . '' . '' . '' . '' . $body . ''; $response = $this->request($location, 'REPORT', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); if (empty($response)) { \Log::error("Failed to get objects from the DAV server."); return false; } $objects = []; foreach ($response->getElementsByTagName('response') as $element) { $objects[] = $this->objectFromElement($element, $component); } return $objects; } /** * Parse XML content */ protected function parseXML($xml) { $doc = new \DOMDocument('1.0', 'UTF-8'); if (str_starts_with($xml, 'loadXML($xml)) { throw new \Exception("Failed to parse XML"); } $doc->formatOutput = true; } return $doc; } /** * Parse request/response body for debug purposes */ protected function debugBody($body, $headers) { $head = ''; foreach ($headers as $header_name => $header_value) { if (is_array($header_value)) { $header_value = implode("\n\t", $header_value); } $head .= "{$header_name}: {$header_value}\n"; } if ($body instanceof DAV\CommonObject) { $body = (string) $body; } if (str_starts_with($body, 'formatOutput = true; $doc->preserveWhiteSpace = false; if (!$doc->loadXML($body)) { throw new \Exception("Failed to parse XML"); } $body = $doc->saveXML(); } return $head . (is_string($body) && strlen($body) > 0 ? "\n{$body}" : ''); } /** * Create DAV\CommonObject from a DOMElement */ protected function objectFromElement($element, $component) { switch ($component) { case self::TYPE_VEVENT: $object = DAV\Vevent::fromDomElement($element); break; case self::TYPE_VTODO: $object = DAV\Vtodo::fromDomElement($element); break; case self::TYPE_VCARD: $object = DAV\Vcard::fromDomElement($element); break; default: throw new \Exception("Unknown component: {$component}"); } return $object; } /** * Execute HTTP request to a DAV server */ protected function request($path, $method, $body = '', $headers = []) { $debug = \config('app.debug'); $url = $this->url; $this->responseHeaders = []; if ($path && ($rootPath = parse_url($url, PHP_URL_PATH)) && str_starts_with($path, $rootPath)) { $path = substr($path, strlen($rootPath)); } $url .= $path; $client = Http::withBasicAuth($this->user, $this->password) // ->withToken($token) // Bearer token ->withOptions(['verify' => \config('services.dav.verify')]); if ($body) { if (!isset($headers['Content-Type'])) { $headers['Content-Type'] = 'application/xml; charset=utf-8'; } $client->withBody($body, $headers['Content-Type']); } if (!empty($headers)) { $client->withHeaders($headers); } if ($debug) { $body = $this->debugBody($body, $headers); \Log::debug("C: {$method}: {$url}" . (strlen($body) > 0 ? "\n$body" : '')); } $response = $client->send($method, $url); $body = $response->body(); $code = $response->status(); if ($debug) { \Log::debug("S: [{$code}]\n" . $this->debugBody($body, $response->headers())); } // Throw an exception if a client or server error occurred... $response->throw(); $this->responseHeaders = $response->headers(); return $this->parseXML($body); } }