├── .github ├── actions │ └── setup │ │ └── action.yml ├── configs │ ├── baikal │ │ └── initdb.sql │ └── radicale │ │ └── config │ │ ├── config │ │ ├── rights │ │ └── users ├── scripts │ ├── createabook.sh │ └── deployBaikal.sh └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── COPYING ├── Makefile ├── NOTES.md ├── README.md ├── composer.json ├── doc ├── CardDAV_Discovery.drawio ├── Classes.drawio ├── Classes.svg ├── QUIRKS.md ├── README.md ├── SPNEGO.md └── quickstart.php ├── psalm.xml ├── src ├── Account.php ├── AddressbookCollection.php ├── CardDavClient.php ├── Config.php ├── Exception │ ├── ClientException.php │ ├── NetworkException.php │ └── XmlParseException.php ├── HttpClientAdapter.php ├── HttpClientAdapterGuzzle.php ├── Services │ ├── Discovery.php │ ├── Sync.php │ ├── SyncHandler.php │ └── SyncResult.php ├── WebDavCollection.php ├── WebDavResource.php └── XmlElements │ ├── Deserializers.php │ ├── ElementNames.php │ ├── Filter.php │ ├── Multistatus.php │ ├── ParamFilter.php │ ├── Prop.php │ ├── PropFilter.php │ ├── Propstat.php │ ├── Response.php │ ├── ResponsePropstat.php │ ├── ResponseStatus.php │ └── TextMatch.php └── tests ├── Interop ├── AccountData.php.dist ├── AddressbookCollectionTest.php ├── AddressbookQueryTest.php ├── DiscoveryTest.php ├── SyncTest.php ├── SyncTestHandler.php ├── TestInfrastructureSrv.php └── phpunit.xml ├── TestInfrastructure.php ├── TestLogger.php └── Unit ├── AccountTest.php ├── ConfigTest.php ├── FilterTest.php ├── HttpClientAdapterGuzzleTest.php └── phpunit.xml /.github/actions/setup/action.yml: -------------------------------------------------------------------------------- 1 | name: 'carddavclient-ci-setup' 2 | description: 'Setup CardDavClient test environment' 3 | inputs: 4 | php-version: 5 | description: 'PHP version to use' 6 | required: true 7 | default: '8.0' 8 | runs: 9 | using: "composite" 10 | steps: 11 | - name: Set up PHP 12 | uses: shivammathur/setup-php@v2 13 | with: 14 | php-version: ${{ inputs.php-version }} 15 | tools: composer:v2 16 | extensions: gd, xdebug, curl 17 | coverage: xdebug 18 | - name: Install dependencies 19 | run: composer update --no-interaction --no-progress 20 | shell: bash 21 | -------------------------------------------------------------------------------- /.github/configs/baikal/initdb.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO addressbooks (id, principaluri, displayname, uri, description, synctoken) VALUES(1,'principals/citest','Default Address Book','default','Default Address Book for citest',1); 2 | INSERT INTO calendars (id,synctoken,components) VALUES(1,1,'VEVENT,VTODO'); 3 | INSERT INTO calendarinstances (id,calendarid,principaluri,displayname,uri,description,calendarorder,calendarcolor,timezone) VALUES(1,1,'principals/citest','Default calendar','default','Default calendar',0,'','Europe/Berlin'); 4 | INSERT INTO principals (id,uri,email,displayname) VALUES(1,'principals/citest','citest@example.com','citest'); 5 | INSERT INTO users (id,username,digesta1) VALUES(1,'citest','583d4a8e7edca58122952a093ed573d7'); 6 | -------------------------------------------------------------------------------- /.github/configs/radicale/config/config: -------------------------------------------------------------------------------- 1 | [server] 2 | hosts = 0.0.0.0:5232 3 | 4 | [auth] 5 | type = htpasswd 6 | htpasswd_filename = /config/users 7 | htpasswd_encryption = plain 8 | 9 | [rights] 10 | type = from_file 11 | file = /config/rights 12 | 13 | [storage] 14 | filesystem_folder = /data/collections 15 | -------------------------------------------------------------------------------- /.github/configs/radicale/config/rights: -------------------------------------------------------------------------------- 1 | # Allow reading root collection for authenticated users 2 | [root] 3 | user: .+ 4 | collection: 5 | permissions: R 6 | 7 | # Allow reading and writing principal collection (same as user name) 8 | [principal] 9 | user: .+ 10 | collection: {user} 11 | permissions: RW 12 | 13 | # Allow reading and writing calendars and address books that are direct 14 | # children of the principal collection 15 | [calendars] 16 | user: .+ 17 | collection: {user}/[^/]+ 18 | permissions: rw 19 | 20 | # Allow reading collection "public" for authenticated users 21 | [public-principal] 22 | user: .+ 23 | collection: public 24 | permissions: R 25 | 26 | # Allow reading all calendars and address books that are direct children of 27 | # the collection "public" for authenticated users 28 | [public-calendars] 29 | user: .+ 30 | collection: public/[^/]+ 31 | permissions: r 32 | -------------------------------------------------------------------------------- /.github/configs/radicale/config/users: -------------------------------------------------------------------------------- 1 | citest:citest 2 | -------------------------------------------------------------------------------- /.github/scripts/createabook.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ABOOKURL="$1" 4 | ABOOKDISP="$2" 5 | ABOOKDESC="$3" 6 | 7 | if [ -z "$ABOOKURL" ]; then 8 | echo "Usage: $0 abookUrl abookDisplayname [abookDesc] [user] [password]" 9 | exit 1; 10 | fi 11 | 12 | AUTH="" 13 | if [ -n "$4" ]; then 14 | AUTH="-u $4" 15 | if [ -n "$5" ]; then 16 | AUTH="$AUTH:$5" 17 | fi 18 | fi 19 | 20 | if [ -n "$ABOOKDISP" ]; then 21 | ABOOKDISP="${ABOOKDISP}" 22 | fi 23 | if [ -n "$ABOOKDESC" ]; then 24 | ABOOKDESC="${ABOOKDESC}" 25 | fi 26 | 27 | curl --no-progress-meter $AUTH -X MKCOL "$ABOOKURL" -v \ 28 | -H 'Content-Type: application/xml' \ 29 | --data \ 30 | " 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | $ABOOKDISP 39 | $ABOOKDESC 40 | 41 | 42 | " 43 | 44 | # vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 45 | -------------------------------------------------------------------------------- /.github/scripts/deployBaikal.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | BAIKALURL=http://localhost:8080 6 | 7 | # Web Setup 8 | curl -v -X POST \ 9 | -d 'Baikal_Model_Config_Standard::submitted=1' \ 10 | -d 'refreshed=0' \ 11 | -d 'data[timezone]=Europe/Berlin' \ 12 | -d 'witness[timezone]=1' \ 13 | -d 'data[card_enabled]=1' \ 14 | -d 'witness[card_enabled]=1' \ 15 | -d 'witness[cal_enabled]=1' \ 16 | -d 'data[invite_from]=noreply@localhost' \ 17 | -d 'witness[invite_from]=1' \ 18 | -d 'data[dav_auth_type]=Digest' \ 19 | -d 'witness[dav_auth_type]=1' \ 20 | -d 'data[admin_passwordhash]=admin' \ 21 | -d 'witness[admin_passwordhash]=1' \ 22 | -d 'data[admin_passwordhash_confirm]=admin' \ 23 | -d 'witness[admin_passwordhash_confirm]=1' \ 24 | $BAIKALURL/admin/install/ 25 | 26 | 27 | curl -v -X POST \ 28 | -d 'Baikal_Model_Config_Database::submitted=1' \ 29 | -d 'refreshed=0' \ 30 | -d 'data[sqlite_file]=/var/www/baikal/Specific/db/db.sqlite' \ 31 | -d 'witness[sqlite_file]=1' \ 32 | -d 'witness[mysql]=1' \ 33 | $BAIKALURL/admin/install/ 34 | 35 | # Add test user and addressbook to database 36 | docker exec baikal sh -c 'apt-get update && apt-get install -y sqlite3' 37 | cat .github/configs/baikal/initdb.sql | docker exec -i baikal sqlite3 /var/www/baikal/Specific/db/db.sqlite 38 | 39 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI Build 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - master 8 | - issue* 9 | 10 | jobs: 11 | staticanalyses: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout carddavclient 16 | uses: actions/checkout@v4 17 | - name: Set up CI environment 18 | uses: ./.github/actions/setup 19 | with: 20 | php-version: '8.0' 21 | - name: Check code style compliance with PSR12 22 | run: make stylecheck 23 | - name: Check code compatibility with minimum supported PHP version 24 | run: make phpcompatcheck 25 | - name: Run psalm static analysis 26 | run: make psalmanalysis 27 | 28 | unittests: 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | php-version: ['7.3', '7.4', '8.0', '8.1', '8.2'] 33 | 34 | runs-on: ubuntu-latest 35 | 36 | env: 37 | XDEBUG_MODE: coverage 38 | 39 | steps: 40 | - name: Checkout carddavclient 41 | uses: actions/checkout@v4 42 | - name: Set up CI environment 43 | uses: ./.github/actions/setup 44 | with: 45 | php-version: '8.0' 46 | - name: Run unit tests 47 | run: make unittests 48 | - name: Upload unit test coverage reports to codecov.io 49 | uses: codecov/codecov-action@v4 50 | with: 51 | files: testreports/unit/clover.xml 52 | flags: unittests 53 | token: ${{ secrets.CODECOV_TOKEN }} 54 | name: Carddavclient unit test coverage 55 | 56 | interop-nextcloud: 57 | strategy: 58 | fail-fast: false 59 | matrix: 60 | nextcloud-version: ['27', '28'] 61 | 62 | runs-on: ubuntu-latest 63 | 64 | env: 65 | XDEBUG_MODE: coverage 66 | CARDDAVCLIENT_INTEROP_SRV: Nextcloud 67 | 68 | services: 69 | nextcloud: 70 | image: nextcloud:${{ matrix.nextcloud-version }} 71 | options: >- 72 | --name nextcloud 73 | ports: 74 | - 8080:80 75 | 76 | steps: 77 | - name: Checkout carddavclient 78 | uses: actions/checkout@v4 79 | - name: Set up CI environment 80 | uses: ./.github/actions/setup 81 | with: 82 | php-version: '8.0' 83 | - name: Setup Nextcloud 84 | run: | 85 | sudo docker exec --user www-data nextcloud php occ maintenance:install --admin-user=ncadm --admin-pass=ncadmPassw0rd 86 | sudo docker exec --user www-data nextcloud php occ app:install contacts 87 | sudo docker exec --user www-data nextcloud php occ app:disable contactsinteraction 88 | - name: Create Nextcloud Addressbooks 89 | run: | 90 | .github/scripts/createabook.sh "http://localhost:8080/remote.php/dav/addressbooks/users/ncadm/contacts/" Contacts '' ncadm ncadmPassw0rd 91 | - name: Run interop tests 92 | run: make tests-interop 93 | - name: Upload interop-nextcloud test coverage reports to codecov.io 94 | uses: codecov/codecov-action@v4 95 | with: 96 | files: testreports/interop/clover.xml 97 | flags: interop 98 | token: ${{ secrets.CODECOV_TOKEN }} 99 | name: Carddavclient nextcloud interoperability test coverage 100 | 101 | interop-owncloud: 102 | strategy: 103 | fail-fast: false 104 | matrix: 105 | owncloud-version: ['10'] 106 | 107 | runs-on: ubuntu-latest 108 | 109 | env: 110 | XDEBUG_MODE: coverage 111 | CARDDAVCLIENT_INTEROP_SRV: Owncloud 112 | 113 | services: 114 | owncloud: 115 | image: owncloud/server:${{ matrix.owncloud-version }} 116 | env: 117 | ADMIN_USERNAME: admin 118 | ADMIN_PASSWORD: admin 119 | OWNCLOUD_DOMAIN: localhost:8080 120 | HTTP_PORT: 8080 121 | options: >- 122 | --name owncloud 123 | ports: 124 | - 8080:8080 125 | 126 | steps: 127 | - name: Checkout carddavclient 128 | uses: actions/checkout@v4 129 | - name: Set up CI environment 130 | uses: ./.github/actions/setup 131 | with: 132 | php-version: '8.0' 133 | - name: Run interop tests 134 | run: make tests-interop 135 | - name: Upload interop-owncloud test coverage reports to codecov.io 136 | uses: codecov/codecov-action@v4 137 | with: 138 | files: testreports/interop/clover.xml 139 | flags: interop 140 | token: ${{ secrets.CODECOV_TOKEN }} 141 | name: Carddavclient owncloud interoperability test coverage 142 | 143 | interop-radicale: 144 | runs-on: ubuntu-latest 145 | 146 | env: 147 | XDEBUG_MODE: coverage 148 | CARDDAVCLIENT_INTEROP_SRV: Radicale 149 | 150 | steps: 151 | - name: Checkout carddavclient 152 | uses: actions/checkout@v4 153 | - name: Start Radicale 154 | run: | 155 | mkdir -p ${{ github.workspace }}/.github/configs/radicale/data 156 | sudo docker run --name radicale -d -p 5232:5232 -v ${{ github.workspace }}/.github/configs/radicale/data:/data -v ${{ github.workspace }}/.github/configs/radicale/config:/config:ro tomsquest/docker-radicale 157 | - name: Set up CI environment 158 | uses: ./.github/actions/setup 159 | with: 160 | php-version: '8.0' 161 | - name: Create Radicale Addressbooks 162 | run: | 163 | .github/scripts/createabook.sh "http://127.0.0.1:5232/citest/book1/" 'Book 1' 'First addressbook' citest citest 164 | .github/scripts/createabook.sh "http://127.0.0.1:5232/citest/book2/" 'Book 2' 'Second addressbook' citest citest 165 | - name: Run interop tests 166 | run: make tests-interop 167 | - name: Upload interop-radicale test coverage reports to codecov.io 168 | uses: codecov/codecov-action@v4 169 | with: 170 | files: testreports/interop/clover.xml 171 | flags: interop 172 | token: ${{ secrets.CODECOV_TOKEN }} 173 | name: Carddavclient radicale interoperability test coverage 174 | 175 | interop-davical: 176 | runs-on: ubuntu-latest 177 | 178 | env: 179 | XDEBUG_MODE: coverage 180 | CARDDAVCLIENT_INTEROP_SRV: Davical 181 | PGHOST: localhost 182 | PGUSER: postgres 183 | 184 | services: 185 | postgres: 186 | image: postgres:15-alpine 187 | env: 188 | POSTGRES_USER: 'postgres' 189 | POSTGRES_PASSWORD: postgres 190 | options: >- 191 | --health-cmd pg_isready 192 | --health-interval 10s 193 | --health-timeout 5s 194 | --health-retries 5 195 | --name postgres 196 | ports: 197 | - 5432:5432 198 | 199 | davical: 200 | image: fintechstudios/davical 201 | env: 202 | PGHOST: postgres 203 | PGPASSWORD: davical 204 | RUN_MIGRATIONS_AT_STARTUP: true 205 | ROOT_PGUSER: postgres 206 | ROOT_PGPASSWORD: postgres 207 | DAVICAL_ADMIN_PASS: admin 208 | options: >- 209 | --name davical 210 | ports: 211 | - 8088:80 212 | 213 | steps: 214 | - name: Checkout carddavclient 215 | uses: actions/checkout@v4 216 | - name: Set up CI environment 217 | uses: ./.github/actions/setup 218 | with: 219 | php-version: '8.0' 220 | - name: Create Davical Addressbooks 221 | run: | 222 | .github/scripts/createabook.sh "http://localhost:8088/caldav.php/admin/book1/" 'Test addressbook' 'Davical test addresses' admin admin 223 | - name: Run interop tests 224 | run: make tests-interop 225 | - name: Upload interop-davical test coverage reports to codecov.io 226 | uses: codecov/codecov-action@v4 227 | with: 228 | files: testreports/interop/clover.xml 229 | flags: interop 230 | token: ${{ secrets.CODECOV_TOKEN }} 231 | name: Carddavclient Davical interoperability test coverage 232 | 233 | interop-icloud: 234 | runs-on: ubuntu-latest 235 | 236 | env: 237 | XDEBUG_MODE: coverage 238 | CARDDAVCLIENT_INTEROP_SRV: iCloud 239 | 240 | steps: 241 | - name: Checkout carddavclient 242 | uses: actions/checkout@v4 243 | - name: Set up CI environment 244 | uses: ./.github/actions/setup 245 | with: 246 | php-version: '8.0' 247 | - name: Run interop tests 248 | env: 249 | ICLOUD_USER: ${{ secrets.ICLOUD_USER }} 250 | ICLOUD_PASSWORD: ${{ secrets.ICLOUD_PASSWORD }} 251 | ICLOUD_URL_ABOOK0: ${{ secrets.ICLOUD_URL_ABOOK0 }} 252 | run: make tests-interop 253 | - name: Upload interop-iCloud test coverage reports to codecov.io 254 | uses: codecov/codecov-action@v4 255 | with: 256 | files: testreports/interop/clover.xml 257 | flags: interop 258 | token: ${{ secrets.CODECOV_TOKEN }} 259 | name: Carddavclient iCloud interoperability test coverage 260 | 261 | interop-baikal: 262 | strategy: 263 | fail-fast: false 264 | matrix: 265 | baikal-version: ['0.9.3-php8.0', '0.8.0', '0.7.2'] 266 | runs-on: ubuntu-latest 267 | 268 | env: 269 | XDEBUG_MODE: coverage 270 | CARDDAVCLIENT_INTEROP_SRV: Baikal 271 | 272 | services: 273 | baikal: 274 | image: ckulka/baikal:${{ matrix.baikal-version }} 275 | options: >- 276 | --name baikal 277 | ports: 278 | - 8080:80 279 | 280 | steps: 281 | - name: Checkout carddavclient 282 | uses: actions/checkout@v4 283 | - name: Set up CI environment 284 | uses: ./.github/actions/setup 285 | with: 286 | php-version: '8.0' 287 | - name: Setup Baikal 288 | run: | 289 | .github/scripts/deployBaikal.sh 290 | - name: Run interop tests 291 | run: make tests-interop 292 | - name: Upload interop-baikal test coverage reports to codecov.io 293 | uses: codecov/codecov-action@v4 294 | with: 295 | files: testreports/interop/clover.xml 296 | flags: interop 297 | token: ${{ secrets.CODECOV_TOKEN }} 298 | name: Carddavclient Baikal interoperability test coverage 299 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .phpunit.result.cache 2 | .*.swp 3 | .*.drawio.bkp 4 | /.phpdoc/ 5 | /test*.php 6 | /*.log 7 | /tags 8 | /composer.lock 9 | /vendor 10 | /testreports 11 | /tests/Interop/AccountData*.php 12 | /xml 13 | /doc/api 14 | .DS_Store 15 | /.idea/ 16 | /tools/ 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog for CardDAV client library for PHP ("PHP-CardDavClient") 2 | 3 | ## Version 1.4.1 (to 1.4.0) 4 | 5 | - Report requests to Sabre/DAV servers with Http-Digest authentication failed if issued from an 6 | AddressbookCollection object that was not use for any other (non REPORT) requests before (Fixes #27). 7 | 8 | ## Version 1.4.0 (to 1.3.0) 9 | 10 | - Support servers with multiple addressbook home locations for one principal in the Discovery service. 11 | - Support configuration of server SSL certificate validation against custom CA (or disable verification) 12 | - Support preemptive basic authentication, i.e. send basic authentication Authorization header even if not requested by 13 | server. This is useful in rare use cases where the server allows unauthenticated access and would not challenge the 14 | client. It might also be useful to reduce the number of requests if the authentication scheme is known to the client. 15 | - Support specifying additional HTTP headers and query string options to be used with every request sent in association 16 | with an account. 17 | 18 | ## Version 1.3.0 (to 1.2.3) 19 | 20 | - New APIs AddressbookCollection::getDisplayName() and AddressbookCollection::getDescription() 21 | - Widen dependency on psr/log to include all v1-v3 versions. To enable this, remove dev-dependency on wa72/simplelogger 22 | (Fixes #23). 23 | 24 | ## Version 1.2.3 (to 1.2.2) 25 | 26 | - Fix: Throw an exception in the Discovery service in case no addressbook home could be discovered. Previously, an empty 27 | list would be returned without indication that the discovery was not successful. 28 | - Fix: After failure to authenticate with the server, the CardDavClient object might be left in a state that causes a 29 | PHP warning on next usage (a property of the object was unintentionally deleted in that case and the warning 30 | would be triggered on next attempt to access that property). 31 | 32 | ## Version 1.2.2 (to 1.2.1) 33 | 34 | - Config::init() now accepts an options array as third parameter, which currently allows to customize the log format for 35 | the HTTP logs. It is meant to be extended with further options in the future. 36 | - Use CURLAUTH_NEGOTIATE only when curl supports SPNEGO (Fixes #20) 37 | 38 | ## Version 1.2.1 (to 1.2.0) 39 | 40 | - Change license to less restrictive MIT license 41 | - Add workaround to enable Bearer authentication with yahoo CardDAV API (#14) 42 | 43 | ## Version 1.2.0 (to 1.1.0) 44 | 45 | - Support for OAUTH2/Bearer authentication. Specify bearertoken in credentials when creating Account. Acquiring the 46 | access token is outside the scope of this library. 47 | - The interface for specifying credentials for an Account changed. The old username/password parameters are deprecated, 48 | but still work. 49 | 50 | ## Version 1.1.0 (to 1.0.0) 51 | 52 | - New API AddressbookCollection::query() for server-side addressbook search 53 | - Generated API documentation for the latest release is now published to 54 | [github pages](https://mstilkerich.github.io/carddavclient/) 55 | 56 | 57 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2021 Michael Stilkerich 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DOCDIR := doc/api/ 2 | 3 | # Set some options on Github actions 4 | ifeq ($(CI),true) 5 | PSALM_XOPTIONS=--shepherd --no-progress --no-cache 6 | endif 7 | 8 | .PHONY: all stylecheck phpcompatcheck staticanalyses psalmanalysis doc tests verification 9 | 10 | all: staticanalyses doc 11 | 12 | verification: staticanalyses tests 13 | 14 | staticanalyses: stylecheck phpcompatcheck psalmanalysis 15 | 16 | stylecheck: 17 | vendor/bin/phpcs --colors --standard=PSR12 src/ tests/ 18 | 19 | phpcompatcheck: 20 | @for phpvers in 7.1 7.2 7.3 7.4 8.0 8.1 8.2 8.3 8.4; do \ 21 | echo Checking PHP $$phpvers compatibility ; \ 22 | vendor/bin/phpcs --colors --standard=PHPCompatibility --runtime-set testVersion $$phpvers src/ tests/ ; \ 23 | done 24 | 25 | psalmanalysis: tests/Interop/AccountData.php 26 | vendor/bin/psalm --threads=8 --no-cache --report=testreports/psalm.txt --report-show-info=true --no-diff $(PSALM_XOPTIONS) 27 | 28 | tests: tests-interop unittests 29 | vendor/bin/phpcov merge --html testreports/coverage testreports 30 | 31 | .PHONY: unittests 32 | unittests: tests/Unit/phpunit.xml 33 | @echo 34 | @echo ========================================================== 35 | @echo " EXECUTING UNIT TESTS" 36 | @echo ========================================================== 37 | @echo 38 | @mkdir -p testreports/unit 39 | vendor/bin/phpunit -c tests/Unit/phpunit.xml 40 | 41 | .PHONY: tests-interop 42 | tests-interop: tests/Interop/phpunit.xml tests/Interop/AccountData.php 43 | @echo 44 | @echo ========================================================== 45 | @echo " EXECUTING CARDDAV INTEROPERABILITY TESTS" 46 | @echo ========================================================== 47 | @echo 48 | @mkdir -p testreports/interop 49 | vendor/bin/phpunit -c tests/Interop/phpunit.xml 50 | 51 | doc: 52 | rm -rf $(DOCDIR) 53 | phpDocumentor.phar -d src/ -t $(DOCDIR) --title="CardDAV Client Library" --setting=graphs.enabled=true --validate 54 | [ -d ../carddavclient-pages ] && rsync -r --delete --exclude .git doc/api/ ../carddavclient-pages 55 | 56 | # For github CI system - if AccountData.php is not available, create from AccountData.php.dist 57 | tests/Interop/AccountData.php: | tests/Interop/AccountData.php.dist 58 | cp $| $@ 59 | 60 | .PHONY: codecov-upload 61 | codecov-upload: 62 | if [ -n "$$CODECOV_TOKEN" ]; then \ 63 | curl -s https://codecov.io/bash >testreports/codecov.sh; \ 64 | bash testreports/codecov.sh -F unittests -f testreports/unit/clover.xml -n 'Carddavclient unit test coverage'; \ 65 | bash testreports/codecov.sh -F interop -f testreports/interop/clover.xml -n 'Carddavclient interoperability test coverage'; \ 66 | else \ 67 | echo "Error: Set CODECOV_TOKEN environment variable first"; \ 68 | exit 1; \ 69 | fi 70 | 71 | -------------------------------------------------------------------------------- /NOTES.md: -------------------------------------------------------------------------------- 1 | # Specifics of CardDAV server implementations 2 | 3 | ## Google Contacts 4 | 5 | Google's implementation of CardDAV is not compliant with the RFCs. The following 6 | issues have so far been encountered: 7 | 8 | ### Creating / update of cards 9 | - New cards should be inserted using a POST request (RFC 5995). The server will choose the URL and report it back via the Location header. 10 | - New cards inserted using PUT will be created, but not at the requested URI. Thus the URI in this case would be unknown, and hence this method should not be used. 11 | - Cards stored to the server are adapted by the server. The following has been observed so far: 12 | - When creating a card, the UID contained in the card is replaced by a server-assigned UID. 13 | - The PRODID property is discarded by the server 14 | - The server inserts a REV property 15 | 16 | ### Synchronization 17 | - When requesting a sync-collection report with an empty sync-token, the server rejects it as a bad request. 18 | - Using an empty sync-token is explicitly allowed by RFC 6578 for the initial sync, and subsequent sync when the 19 | sync-token was invalidated 20 | - It works to fallback to using PROPFIND to determine all cards in the addressbook 21 | - When attempting a sync-collection report with a sync-token previously returned by the server, Depth: 1 header 22 | is needed as otherwise the Google server apparently will only report on the collection itself, i. e. the 23 | sync result would look like there were no changes. According to RFC 6578, a Depth: 0 header is required and 24 | a bad request result should occur for any other value. [Issue](https://issuetracker.google.com/issues/160190530) 25 | - Google reports cards as deleted that have not been deleted between the last sync and the current one, 26 | but probably before that. [Issue](https://issuetracker.google.com/issues/160192237) 27 | 28 | 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CardDAV client library for PHP ("PHP-CardDavClient") 2 | ![CI Build](https://github.com/mstilkerich/carddavclient/workflows/CI%20Build/badge.svg) 3 | [![codecov](https://codecov.io/gh/mstilkerich/carddavclient/branch/master/graph/badge.svg)](https://codecov.io/gh/mstilkerich/carddavclient/branch/master) 4 | [![Type Coverage](https://shepherd.dev/github/mstilkerich/carddavclient/coverage.svg)](https://shepherd.dev/github/mstilkerich/carddavclient) 5 | [![Psalm level](https://shepherd.dev/github/mstilkerich/carddavclient/level.svg?)](https://psalm.dev/) 6 | 7 | 8 | This is a library for PHP applications to interact with addressbooks stored on CardDAV servers. 9 | 10 | ## Index 11 | 12 | - [Features](#features) 13 | - [Tested Servers](#tested-servers) 14 | - [Installation instructions](#installation-instructions) 15 | - [Quickstart](#quickstart) 16 | - [API documentation](#api-documentation) 17 | 18 | ## Features 19 | 20 | - CardDAV addressbook discovery as defined by [RFC 6764](https://tools.ietf.org/html/rfc6764) (using DNS SRV records 21 | and/or well-known URIs) 22 | - Synchronization of the server-side addressbook and a local cache 23 | - Using efficient sync-collection ([RFC 6578](https://tools.ietf.org/html/rfc6578)) and addressbook-multiget 24 | ([RFC 6352](https://tools.ietf.org/html/rfc6352)) reports if supported by server 25 | - Falling back to synchronization via PROPFIND and comparison against the local cache state if the server does not 26 | support these reports 27 | - Modification of addressbooks (adding/changing/deleting address objects) 28 | - Uses [Guzzle](https://github.com/guzzle/guzzle) HTTP client library, including support for HTTP/2 and various 29 | authentication schemes, including OAuth2 bearer token 30 | - Uses [Sabre/VObject](https://github.com/sabre-io/vobject) at the application-side interface to exchange VCards 31 | - Uses any PSR-3 compliant logger object to record log messages and the HTTP traffic. A separate logger object is used 32 | for the HTTP traffic, which tends to be verbose and therefore logging for HTTP could be done to a separate location or 33 | disabled independent of the library's own log messages. 34 | 35 | See the [feature matrix](doc/QUIRKS.md) for which services to my observations support which features; the file also 36 | contains a list of the known issues I am aware of with the different servers. 37 | 38 | ## Tested Servers 39 | 40 | Currently, this library has been tested to interoperate with: 41 | 42 | * Nextcloud 18 and later (Basic Auth and GSSAPI/Kerberos 5) 43 | * iCloud 44 | * Google Contacts via CardDAV API 45 | * Radicale 3 (also used by Synology as DSM CardDAV server) 46 | * Owncloud 10 47 | * Baïkal 0.7 (Digest Auth and GSSAPI/Kerberos 5) and later 48 | * Davical 1.1.7 and later 49 | * SOGo 5.10 50 | 51 | In theory, it should work with any CardDAV server. If it does not, please open an issue. 52 | 53 | __Note: For using any authentication mechanism other than Basic, you need to have the php-curl extension installed with 54 | support for the corresponding authentication mechanism.__ 55 | 56 | ## Installation instructions 57 | 58 | This library is intended to be used with [composer](https://getcomposer.org/) to install/update the library and its 59 | dependencies. It is intended to be used with a PSR-4 compliant autoloader (as provided by composer). 60 | 61 | To add the library as a dependency to your project via composer: 62 | 63 | 1. Download composer (skip if you already have composer): [Instructions](https://getcomposer.org/download/) 64 | 65 | 2. Add this library as a dependency to your project 66 | ```sh 67 | php composer.phar require mstilkerich/carddavclient 68 | ``` 69 | 70 | 3. To use the library in your application with composer, simply load composer's autoloader in your main php file: 71 | ```php 72 | require 'vendor/autoload.php'; 73 | ``` 74 | The autoloader will take care of loading this and other PSR-0/PSR-4 autoloader-compliant libraries. 75 | 76 | ## Documentation 77 | 78 | ### Quickstart 79 | 80 | Generally, an application using this library will want to do some or all of the following things: 81 | 82 | 1. Discover addressbooks from the information provided by a user: For this operation, the library provides a service 83 | class *MStilkerich\CardDavClient\Services\Discovery*. 84 | The service takes the account credentials and a partial URI (at the minimum a domain name) and with that attempts to 85 | discover the user's addressbooks. It returns an array of *MStilkerich\CardDavClient\AddressbookCollection* objects, 86 | each representing an addressbook. 87 | 88 | 2. Recreate addressbooks in known locations, discovered earlier. This is possible by simply creating instances of 89 | *MStilkerich\CardDavClient\AddressbookCollection*. 90 | 91 | 3. Initially and periodically synchronize the server-side addressbook with a local cache: For this operation, the 92 | library provides a service class *MStilkerich\CardDavClient\Services\Sync*. 93 | This service performs synchronization given *MStilkerich\CardDavClient\AddressbookCollection* object and optionally a 94 | synchronization token returned by the previous sync operation. A synchronization token is a server-side 95 | identification of the state of the addressbook at a certain time. When a synchronization token is given, the server 96 | will be asked to only report the delta between the state identified by the synchronization token and the current 97 | state. This may not work for various reasons, the most common being that synchronization tokens are not kept 98 | indefinitly by the server. In such cases, a full synchronization will be performed. At the end of the sync, the 99 | service returns the synchronization token reflecting the synchronized state of the addressbook, if provided by the 100 | server. 101 | 102 | 4. Perform changes to the server-side addressbook such as creating new address objects. These operations are directly 103 | provided as methods of the *MStilkerich\CardDavClient\AddressbookCollection* class. 104 | 105 | 5. Search the server-side addressbook to retrieve cards matching certain filter criteria. This operation is provided via 106 | the *MStilkerich\CardDavClient\AddressbookCollection::query()* API. 107 | 108 | There is a demo script [doc/quickstart.php](doc/quickstart.php) distributed with the library that shows how to perform 109 | all the above operations. 110 | 111 | ### Sample Applications 112 | 113 | For a simple demo application that makes use of this library, see [davshell](https://github.com/mstilkerich/davshell/). 114 | It shows how to use the library for the discovery and synchronization of addressbooks. 115 | 116 | As a more complex real-world application, you can also take a look at the 117 | [Roundcube CardDAV](https://github.com/mstilkerich/rcmcarddav) plugin, which also uses this library for the interaction 118 | with the CardDAV server. 119 | 120 | ### API documentation 121 | 122 | An overview of the API is available [here](doc/README.md). 123 | 124 | The API documentation for the latest released version can be found [here](https://mstilkerich.github.io/carddavclient/). 125 | The public API of the library can be found via the `Public` package in the navigation sidebar. 126 | 127 | Documentation for the API can be generated from the source code using [phpDocumentor](https://www.phpdoc.org/) by 128 | running `make doc`. 129 | 130 | 131 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mstilkerich/carddavclient", 3 | "description": "CardDAV client library to discover and synchronize with CardDAV servers", 4 | "type": "library", 5 | "keywords": ["addressbook","carddav","contacts","owncloud","nextcloud"], 6 | "homepage": "https://github.com/mstilkerich/carddavclient", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Michael Stilkerich", 11 | "email": "ms@mike2k.de", 12 | "homepage": "https://github.com/mstilkerich", 13 | "role": "Developer" 14 | } 15 | ], 16 | "support": { 17 | "email": "ms@mike2k.de", 18 | "issues": "https://github.com/mstilkerich/carddavclient/issues", 19 | "source": "https://github.com/mstilkerich/carddavclient", 20 | "docs": "https://github.com/mstilkerich/carddavclient" 21 | }, 22 | "require": { 23 | "php": ">=7.1.0", 24 | "guzzlehttp/guzzle": "^6.0.0 || ^7.0.0", 25 | "psr/http-client": "^1.0", 26 | "psr/http-message": "^1.0 || ^2.0", 27 | "psr/log": "^1.0 || ^2.0 || ^3.0", 28 | "sabre/vobject": "^3.3.5 || ^4.0.0", 29 | "sabre/xml": "^2.2 || ^3.0.0 || ^4.0.0", 30 | "sabre/uri": "^2.2" 31 | }, 32 | "autoload": { 33 | "psr-4": { 34 | "MStilkerich\\CardDavClient\\": "src/" 35 | } 36 | }, 37 | "autoload-dev": { 38 | "psr-4": { 39 | "MStilkerich\\Tests\\CardDavClient\\": "tests/" 40 | } 41 | }, 42 | "require-dev": { 43 | "phpcompatibility/php-compatibility": "dev-develop", 44 | "dealerdirect/phpcodesniffer-composer-installer": ">= 0.7.0", 45 | "phpunit/phpunit": "~9", 46 | "phpunit/phpcov": "*", 47 | "vimeo/psalm": "~5.23.0", 48 | "psalm/plugin-phpunit": "*", 49 | "alexeyshockov/guzzle-psalm-plugin": "*", 50 | "donatj/mock-webserver": "*" 51 | }, 52 | "extra": { 53 | "branch-alias": { 54 | "dev-master": "1.4.x-dev" 55 | } 56 | }, 57 | "prefer-stable" : true, 58 | "config": { 59 | "allow-plugins": { 60 | "dealerdirect/phpcodesniffer-composer-installer": true 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /doc/CardDAV_Discovery.drawio: -------------------------------------------------------------------------------- 1 | 5Vtdc9q8Ev41zKQXMGCDgcuUlDYzb9uchPe0veoIW2BNbcuVZQjn159dSf52EqchpE1zEezVh6X9fHZl9+xFePtekNj/yD0a9Kyhd9uzL3qWNR878B8JB01wZkNN2ArmadKoINyw/1FDzLqlzKNJpaPkPJAsrhJdHkXUlRUaEYLvq902PKg+NSZb2iDcuCRoUr8wT/qGOnLmRcMHyra+efTMMhteE/fHVvA0Ms/rWfZG/enmkGRzmY0mPvH4vkSy3/XsheBc6qvwdkEDZG3GNj1ueUdrvm5BI9llwJV1++nbl4+Be86+XC3ctbf59LY/NtPsSJDSbB9qtfKQcQimAWHAzVtfhgHQRnAJu4mxPZFEyBtJJLZvWBAseMCFGmgP1R92loL/oKWWzca0NDeRLYgKSW9LJLOp95SHVIoDdDGtk/FEDzEKaBt+7wtpZiS/JMeMRoz+bPOJCx7ChWHjY1hqdWCpBzpobrmQPt/yiATvCmqZ1cgKBgp7HrBtBLQ1l5KH0EAj7xwtACeJaaQpxsJmv8b2hKfCpR0UBndwr3QEDYhku6qVtTHbDL3iDNZSSNUZVqQ6smryAr3bUmlG1USWL+MJUrQbUlz41P0BpH8TKuDnMopTmTREqzwC9Yzs9j6T9CYmiqd7cKBV0RLhGnmNUSwbHsk2+6nZFToZ9/ntaniHBEqGldPKljV+Nssav2rLsl/GsqxTW9akIcWPLGJhCow/71lOAHx7uwYLc7ZSMdQhIZpNtE7wpw9Db6jYKRu84CFhEVyc0cF2gE92A556A5eHb7pNhcYckZB2631FkmTPhdfau0b5HEuGutd5V8tUMY94nqBJsuZcOZvrf4rtVcf4UiJGOkcJWcvYsfqAkSRxZTIo2ADtY9Ss5ciykfNLlwjPIzuf457VHfx0ZNYVWBT8RGm4Vuw/A5VRyo5UvtHL7TqX4JK7PLhrmp61QL8Ce0RdSQbmJlHTAczD56cRjVxxiCX42xwhAtP1WHobB8xlOGkSU5dtwNR1q36ImjtjH+pOoozrZ8oE9d40PEuGeCKusM4Dnj3RzmI0bnEzkmNnYu4CukEeJTAVi7YrbLvoO8fx4Y5TxUYtLtxpc+Gj50NHTgcfXnjfiEfIbI8kfh5TS1ymt0x+LV1/g+vhYGLuLpALw+zmkN1EsI+v5ZvSKLwthqm7yrgrKhgwggpDfLq71962g798MC6UhDppkWlGe2L4cCZVlXJGNVXRO2+Ej8ZE02F1ojwunSoOTRuaeMESl+vIkqgQgx6BROhcYnB9eJcmYKQYeT7dYCC6/i/8/5mCVtC/CAq2+ZFWKDh5Njcy+/2hYOFoCt/yreJa2h3NE3zKtKNPmVgvgzXtE2PNTP8qamLQSQZONizyvhtzP4OwP+yrfctURGjwe6XOCmAELMkRStn+Aa6lgfIOZz7HLgvjMMyVQTo5MIKtrO/Gjf8Bb3JoPMEF1JnkwOe7QXHqmXsGNmgNV//c4Oor7djMNvcipRwmBQcDf2iie60PLSApLmBbHVQpIJ4CJ/vQacc8NcmdgLBGyXc0+C7dON/41fXlZ/j58u7y/YcVXCgsO/QU7Ed4O7h7pvsnwirl/fM0KZfIyTDF2iS4j7LcwVY1b7Jtr3MJLlQwCXJ2h6lSorUaQAXIM9QjSKJ+VadYMC6YPOThR2thpnzSpxWlUPjZiENNokSRbYCgGAXdKP13fqZYcKwlD/v9fiA2bp96THIx4AJC3BII+r81nVnFyIwb18uFaciUmmRNg1cJnkezF0fPky7l2u7o+ZhI+Feg+Cki3fD3Qs9WLSGrF8u6oueGcs5PHFmbVe4SesZCBL2VSrgqOO0YyUPa6uvqbwTN9ovXTyfNmvarAc2Z+ylczreSMzq2+8m0/0H343R1P08G2tag6hDG9dzrDocAciKHUrcYOyTP4jLmDfWrYl3tGOpYF6uzxZ30iWyBxT7ZUYWqaFQCYp1QXQN56mXE2fntMsfSD8/1CeGNNRwpYDVUeJHkQLFnSowFTFzkWK4EHeu+s5au1GupGk+2JhaVfgpw6ocRV6YkaHPTJXSJGRFzaQsLzSatR2xSFWEbW3nU0tOkANZ6hQuQy8U56oEbMDBhLBF/hiaxZwnNmYv9Q6XhG6J0FM/tsYnnEyEAVvq7/BHxfdRPBXudCLYtBLUj2HpR72gxyGlm5Iu6FtbzqKpfsJdNzODzcJ0mD4vlhYBAzTNnFd0yEBi1SGH6bEJophERfxoUqKnzsyKDx2Urvx7lM119OMp3Pbo1GtEfHSeZmFcR5ng6OWkO4DRzgEMLoj+mHmnf+SfVZDsr0fRllKheop3MTptIOs2kZIXrbYKt5QCjdF/F6DIse60JZMO8nZdOIJ0/4AWc3MJHjwsTp00gna6nv529whPdwHRScwMnPo11mm8FrarJiklIenkJHZB4zxTSA+4SfWihskPp6xxEwUldgKf1pOJo1XFn6owHSqfbSuTYekeJfIjp04fV6gp+rmFuga83o3UrbsVGPqU8JWcFbqxTblvNRz+v1TFXKYFyfdpH+C3UgY5Piaff61H8OuIBgjNywEYTfQjSH40H8/bzBNWvyaw3GR886kJigEzyKWZ6veIsRPNPn7H46kBEjVDqAXluAuOE0pAdR0NRBzhnbIAJLqgPOBNsSzH/pztdlJAspJ3OrqoJ8XkKS4pkcS6ms8+1XqZ+uyhb3U9T/9CbKIe34b/Xl68jCZ3VAMZ4Mh00E6BxexqaV7WOH8uO+yLSn3uU0rmWmdXvfpejFLuqV9akpiqdj1KmtTOZjpXTo4W+5otIf6cmdgZFzm/2SlxNE+26Av2qJtqzEyf0zQp9UxPv+xoIQfpLfgs0r8Wa6byZMp30Y6Bps9yZHZPqkjQAEa/B41eTws5HtZdFW+Rx2hR22uUNij/2G5JM3Z47W5zXHNX0xG8fTJtFo+zrLH1MWPqIIsmyQg8DW8gi817a5V/07sGk5dWlY9kdpnj5R6xawMWHwva7/wM=7Vtbl6I4EP41PrqHcFMfvfX0nNMz4+l2d2af5kQIyDYSToitzq/fAOEOyigt2M5LS4oQQn1VX1Uq6Z403ew/Eeiuv2Ad2T1R0Pc9adYTRRGII/bjSw6hZASkUGASSw9FIBG8WL8QFwpcurV05GU6UoxtarlZoYYdB2k0I4OE4F22m4Ht7FtdaKKC4EWDdlH63dLpOpQOxUEif0SWuY7eDFT+wRsYdeZf4q2hjncpkTTvSVOCMQ2vNvspsn3lRXrRieD93CrjyVMfeYqx/7qQvvbDwR5+55H4EwhyaLNDSyL/NnqIFIZ0pj/edLDDfiYEbx0d+eMIrIUJXWMTO9B+wthlQsCE/yFKDxx9uKWYidZ0Y/O7aG/RH/7jfym89W/qzmzPRw4aB97wKMGvMWZ+33Cq/vxy0J7QC+/n4S3R0JF+XBcUEhMdG0+OwWdeg/AGUXJgzxFkQ2q9ZScHufmacb8EInbBUfodxKSrIgZSeCXolSPGUCCHH+lG6im/mTwWtLqPNBDbhJrP8g3aW/6m3lTsjSeaDT3Pv5wwRak2+4DJirArkwaq45JIoEGi6/AtkrOZrJK+OUtKGYBOsLuM9CPEnd8QoWh/BipFJfJRJIHzKw8wosLbu4SuwZDL1imqHgnv5WJFvecVxWKB619qeOMyj/M/P3SoyIqHkSAOLvJFOqwwxOwoKY2JJQqLZLWtl79vgS02t/hl/Sh2csAG2QGwYXjMaPIgxJO+ABe5ReoTPwr3yXW5r90wp7SI9eDusFbbxFpuMs79XEHtFTn6zcQ7WWg73ikdjHcV5HNX8U5txC8eKXWNrX2xPxiWbU+xjUnQVTIMQ9S0mNVSd3R1pSrqO3oQEEZZF5LEtl1o0EEXquD0u3Kh4WlcWKwY+3WmJKvQobcOkgqQTRay8Vu5NH4XoUmBoZSAoTQDhpgLQOow5xVhWsGfSleUTgyUHydMOwrjNIXtqIDtZ4ciYkCWEvlFRvbn2c8Pte0KbaBlF5FnuD3BFbKzMEPbMh3fUZE/HBP4zmdp0B7zGxtL1+0g/USe9QuugvF8inT9Tw0+Xpn0lNkx7+XlTv5wL3a0k6YzPOrVfZaFDtWss4HLrOb9nTQqFf/x0pRzAaEpNy2MdGU/BeC0o05Z8j4b/8OuXhBh7vIxfBUI5RZ0y84a74Yk2HSp2Ny5JTjgXHZ6DV7B7NdZg0fTbGgRrluehpkrHTq7DFeULCkCte01BOhi3blqB+SuVhFiMYKVBqimq5MtsFXtimG7bNVsydA7ONoSR5nHjRBW+0UP0MXCYdW2xX0R1nX4qTRvOyNrA93L2tTb4MFmSsRpHnwgeHNjTCgPW2fCLtZ/qzb17ooJJbU9Jvwwx6XAsCYbtrqNDEqK/ReQ4Q6t/B/L6CwPDjq3DQaKNfkO8ODxkvmd8CAoANEVHmzh+KdUk88qih9XOv5ZUg86g8/iPTA21MLempbDLr6Yl6d33TkQ0Je6doRULClQtE6EoUHdOxEW9y+6QoS3kxCKSk0CbfUMqVhSn7qQQMe6TpDnrTB+/fAs2v7BRLGkvNE+iyp/WLQvFQseaTdJtvOjjCOHGtM6LdvriOyd03DaObio/o7/bm1R9OLCgMl2BLoFXjewQzmDAzVq80kq51vIcTeTB1k3GxW9TJKPmEzzR+2L6+aXreuyoMcUJQrf0SqA8uFxuVywNnvpGutsEuMid/bZ/U/zZfmNxd8VN2bzp/lyXvHQ87fFw+evs/K7z/PFt+dlJSc4mFYZQtryuAXIJcZE/XAf25yNDP/NHhvKcsxlkAr01ZwhveuKf5i1nYFck6LzR36aM54aK/5zz26BEs3G/0lateUwGBbSKtZYIGKxD/YJI5dqJdlVnZ2I906spLqVtqqixnUOpyk5O5TPPZum5nMOKTfQ2WfTWDP51+ywe/IP7tL8fw== -------------------------------------------------------------------------------- /doc/README.md: -------------------------------------------------------------------------------- 1 | # Overview on library classes and public interface 2 | 3 | The following diagram shows the classes of the library and shows the public (blue) vs. internal (orange) parts of the library. 4 | 5 | ![Class diagram](Classes.svg) 6 | 7 | This library uses [semantic versioning](https://semver.org), for which the above identifies the public API. 8 | 9 | 10 | -------------------------------------------------------------------------------- /doc/SPNEGO.md: -------------------------------------------------------------------------------- 1 | # Using SPNEGO / GSSAPI / Kerberos authentication 2 | 3 | The carddavclient library supports authentication using the SPNEGO mechanism, which includes the possibility to 4 | authenticate using a kerberos ticket without the need for a password. 5 | 6 | ## Prerequisites 7 | 8 | - PHP curl extension with GSSAPI/SPNEGO support and support for the authentication mechanism to use (e.g. Kerberos 5) 9 | 10 | ## Usage 11 | 12 | If the prerequisites are available and the client-side Kerberos configuration is properly available on the client 13 | machine (e. g. `/etc/krb5.conf`), provide the kerberos principal name as username. The password is optional in this case 14 | (empty string). If your server provides additional authentication options in case a ticket is not available, provide the 15 | password for that mechanism. 16 | 17 | ## Notes on server-side setup 18 | 19 | It is quite common that CardDAV servers running inside a webserver let the webserver handle the authentication for 20 | SPNEGO. Nextcloud does it this way, and also Sabre/DAV provides an authentication backend where the actual 21 | authentication is carried out by Apache, allowing it to be used with any authentication mechanism that Apache supports. 22 | 23 | Therefore, the following configuration snippet may be useful to setup Apache for use with SPNEGO / Kerberos 5. I use a 24 | modified version of the Baïkal server to test the library with Kerberos authentication. This is the configuration I use 25 | in Apache (it requires the Apache mod\_auth\_gssapi): 26 | 27 | ``` 28 | 29 | ServerName baikal.domain.com 30 | 31 | ServerAdmin webmaster@localhost 32 | DocumentRoot /var/www/baikalKrb/html 33 | 34 | RewriteEngine on 35 | RewriteRule /.well-known/carddav /dav.php [R=308,L] 36 | 37 | 38 | Options None 39 | # If you install cloning git repository, you may need the following 40 | # Options +FollowSymlinks 41 | AllowOverride None 42 | 43 | AuthType GSSAPI 44 | AuthName "GSSAPI Logon" 45 | 46 | # The server needs access to its kerberos key in the keytab 47 | # It should contain a service principal like HTTP/baikal.domain.com@REALM 48 | GssapiCredStore keytab:/etc/apache2/apache.keytab 49 | 50 | # The following enables server-side support for credential delegation (not that it needs to 51 | # be enabled on the client-side as well, if desired. You need to specify a directory that the 52 | # webserver can write to 53 | # GSSAPI delegation enables the server to acquire tickets for additional backend services. For 54 | # a CardDAV server, you will not normally need this. For a different service like roundcube 55 | # webmail, this would enable the webmail client for example to authenticate on the user's behalf 56 | # with backend IMAP, SMTP or CardDAV servers. 57 | # GssapiDelegCcacheDir /var/run/apache2/krbclientcache 58 | 59 | # maps the kerberos principal to a local username based on the settings in /etc/krb5.conf 60 | # e. g. username@REALM -> username 61 | GssapiLocalName On 62 | 63 | # Restrict the mechanisms offered by SPNEGO to Kerberos 5 64 | GssapiAllowedMech krb5 65 | 66 | # Optional: The following allows to fallback to Basic authentication if no ticket is available. 67 | # In this case, the username and kerberos password are required and the webserver would use them 68 | # to acquire a ticket-granting ticket for the user from the KDC itself. 69 | #GssapiBasicAuth On 70 | #GssapiBasicAuthMech krb5 71 | 72 | Require valid-user 73 | 74 | 75 | 76 | ExpiresActive Off 77 | 78 | 79 | ``` 80 | 81 | 82 | -------------------------------------------------------------------------------- /doc/quickstart.php: -------------------------------------------------------------------------------- 1 | FN ?? ""; 56 | echo " +++ Changed or new card $uri (ETag $etag): $fn\n"; 57 | } else { 58 | echo " +++ Changed or new card $uri (ETag $etag): Error: failed to retrieve/parse card's address data\n"; 59 | } 60 | } 61 | 62 | public function addressObjectDeleted(string $uri): void 63 | { 64 | echo " --- Deleted Card $uri\n"; 65 | } 66 | 67 | public function getExistingVCardETags(): array 68 | { 69 | return []; 70 | } 71 | 72 | public function finalizeSync(): void 73 | { 74 | } 75 | } 76 | 77 | $log = new StdoutLogger(); 78 | $httplog = new NullLogger(); // parameter could simply be omitted for the same effect 79 | 80 | // Initialize the library. Currently, only objects for logging need to be provided, which are two optional logger 81 | // objects implementing the PSR-3 logger interface. The first object logs the log messages of the library itself, the 82 | // second can be used to log the HTTP traffic. If no logger is given, no log output will be created. For that, simply 83 | // call Config::init() and the library will internally use NullLogger objects. 84 | Config::init($log, $httplog); 85 | 86 | // Now create an Account object that contains credentials and discovery information 87 | $account = new Account(DISCOVERY_URI, ["username" => USERNAME, "password" => PASSWORD]); 88 | 89 | // Discover the addressbooks for that account 90 | try { 91 | $log->notice("Attempting discovery of addressbooks"); 92 | 93 | $discover = new Discovery(); 94 | $abooks = $discover->discoverAddressbooks($account); 95 | } catch (\Exception $e) { 96 | $log->error("!!! Error during addressbook discovery: " . $e->getMessage()); 97 | exit(1); 98 | } 99 | 100 | $log->notice(">>> " . count($abooks) . " addressbooks discovered"); 101 | foreach ($abooks as $abook) { 102 | $log->info(">>> - $abook"); 103 | } 104 | 105 | if (count($abooks) <= 0) { 106 | $log->warning("Cannot proceed because no addressbooks were found - exiting"); 107 | exit(0); 108 | } 109 | ////////////////////////////////////////////////////////// 110 | // THE FOLLOWING SHOWS HOW TO PERFORM A SYNCHRONIZATION // 111 | ////////////////////////////////////////////////////////// 112 | $abook = $abooks[0]; 113 | $synchandler = new EchoSyncHandler(); 114 | $syncmgr = new Sync(); 115 | 116 | // initial sync - we don't have a sync-token yet 117 | $log->notice("Performing initial sync"); 118 | $lastSyncToken = $syncmgr->synchronize($abook, $synchandler, [ "FN" ], ""); 119 | $log->notice(">>> Initial Sync completed, new sync token is $lastSyncToken"); 120 | 121 | // every subsequent sync would be passed the sync-token returned by the previous sync 122 | // there most certainly won't be any changes to the preceding one at this point and we 123 | // can expect the same sync-token be returned again 124 | $log->notice("Performing followup sync"); 125 | $lastSyncToken = $syncmgr->synchronize($abook, $synchandler, [ "FN" ], $lastSyncToken); 126 | $log->notice(">>> Re-Sync completed, new sync token is $lastSyncToken"); 127 | 128 | ////////////////////////////////////////////////////////////// 129 | // THE FOLLOWING SHOWS HOW TO PERFORM CHANGES ON THE SERVER // 130 | ////////////////////////////////////////////////////////////// 131 | 132 | 133 | // First, we want to insert a new card, so we create a fresh one 134 | // See https://sabre.io/vobject/vcard/ on how to work with Sabre VCards 135 | // CardDAV VCards require a UID property, which the carddavclient library will 136 | // generate and insert automatically upon storing a new card lacking this property 137 | 138 | try { 139 | $vcard = new VCard([ 140 | 'FN' => 'John Doe', 141 | 'N' => ['Doe', 'John', '', '', ''], 142 | ]); 143 | 144 | 145 | $log->notice("Attempting to create a new card on the server"); 146 | [ 'uri' => $cardUri, 'etag' => $cardETag ] = $abook->createCard($vcard); 147 | $log->notice(">>> New card created at $cardUri with ETag $cardETag"); 148 | 149 | // now a sync should return that card as well - lets see! 150 | $log->notice("Performing followup sync"); 151 | $lastSyncToken = $syncmgr->synchronize($abook, $synchandler, [ "FN" ], $lastSyncToken); 152 | $log->notice(">>> Re-Sync completed, new sync token is $lastSyncToken"); 153 | 154 | // add an EMAIL address to the card and update the card on the server 155 | $vcard->add( 156 | 'EMAIL', 157 | 'johndoe@example.org', 158 | [ 159 | 'type' => ['home'], 160 | 'pref' => 1, 161 | ] 162 | ); 163 | 164 | // we pass the ETag of our local copy of the card to updateCard. This 165 | // will make the update operation fail if the card has changed on the 166 | // server since we fetched our local copy 167 | $log->notice("Attempting to update the previously created card at $cardUri"); 168 | $cardETag = $abook->updateCard($cardUri, $vcard, $cardETag); 169 | $log->notice(">>> Card updated, new ETag: $cardETag"); 170 | 171 | // again, a sync should report that the card was updated 172 | $log->notice("Performing followup sync"); 173 | $lastSyncToken = $syncmgr->synchronize($abook, $synchandler, [ "FN" ], $lastSyncToken); 174 | $log->notice(">>> Re-Sync completed, new sync token is $lastSyncToken"); 175 | 176 | // finally, delete the card 177 | $log->notice("Deleting card at $cardUri"); 178 | $abook->deleteCard($cardUri); 179 | // now, the sync should report the card was deleted 180 | $log->notice("Performing followup sync"); 181 | $lastSyncToken = $syncmgr->synchronize($abook, $synchandler, [ "FN" ], $lastSyncToken); 182 | $log->notice(">>> Re-Sync completed, new sync token is $lastSyncToken"); 183 | 184 | $log->notice("All done, good bye"); 185 | } catch (\Exception $e) { 186 | $log->error("Error while making changes to the addressbook: " . $e->getMessage()); 187 | $log->error("Manual cleanup (deletion of the John Doe card) may be needed"); 188 | 189 | // do one final attempt to delete the card 190 | try { 191 | if (isset($cardUri)) { 192 | $abook->deleteCard($cardUri); 193 | } 194 | } catch (\Exception $e) { 195 | } 196 | 197 | exit(1); 198 | } 199 | 200 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 201 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/Account.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\CardDavClient; 13 | 14 | use MStilkerich\CardDavClient\XmlElements\ElementNames as XmlEN; 15 | 16 | /** 17 | * Represents an account on a CardDAV Server. 18 | * 19 | * @psalm-type HttpOptions = array{ 20 | * username?: string, 21 | * password?: string, 22 | * bearertoken?: string, 23 | * verify?: bool|string, 24 | * preemptive_basic_auth?: bool, 25 | * query?: array, 26 | * headers?: array>, 27 | * } 28 | * 29 | * @psalm-type SerializedAccount = HttpOptions & array{discoveryUri: string, baseUrl?: ?string} 30 | * 31 | * @package Public\Entities 32 | */ 33 | class Account implements \JsonSerializable 34 | { 35 | /** 36 | * Options for the HTTP communication, including authentication credentials. 37 | * @var HttpOptions 38 | */ 39 | private $httpOptions; 40 | 41 | /** 42 | * URI originally used to discover the account. 43 | * Example: _example.com_ 44 | * @var string 45 | */ 46 | private $discoveryUri; 47 | 48 | /** 49 | * URL of the discovered CardDAV server, with empty path. May be null if not discovered yet. 50 | * Example: _https://carddav.example.com:443_ 51 | * @var ?string 52 | */ 53 | private $baseUrl; 54 | 55 | /** 56 | * Construct a new Account object. 57 | * 58 | * @param string $discoveryUri 59 | * The URI to use for service discovery. This can be a partial URI, in the simplest case just a domain name. Note 60 | * that if no protocol is given, https will be used. Unencrypted HTTP will only be done if explicitly given (e.g. 61 | * _http://example.com_). 62 | * @psalm-param string|HttpOptions $httpOptions 63 | * @param string|array $httpOptions 64 | * The options for HTTP communication, including authentication credentials. 65 | * An array with any of the keys (if no password is needed, the array may be empty, e.g. GSSAPI/Kerberos): 66 | * - username/password: The username and password for mechanisms requiring such credentials (e.g. Basic, Digest) 67 | * - bearertoken: The token to use for Bearer authentication (OAUTH2) 68 | * - verify: How to verify the server-side HTTPS certificate. True (the default) enables certificate 69 | * verification against the default CA bundle of the OS, false disables certificate verification 70 | * (note that this defeats the purpose of HTTPS and opens the door for man in the middle attacks). 71 | * Set to the path of a PEM file containing a custom CA bundle to perform verification against a 72 | * custom set of certification authorities. 73 | * - preemptive_basic_auth: Set to true to always submit an Authorization header for HTTP Basic authentication 74 | * (username and password options also required in this case) even if not challenged by the server. This may be 75 | * required in rare use cases where the server allows unauthenticated access and will not challenge the client. 76 | * - query: Query options to append to every URL queried for this account, as an associative array of query 77 | * options and values. 78 | * - headers: Headers to add to each request sent for this account, an associative array mapping header name to 79 | * header value (string) or header values (list). 80 | * 81 | * Deprecated: username as string for authentication mechanisms requiring username / password. 82 | * @param string $password 83 | * Deprecated: The password to use for authentication. This parameter is deprecated, include the password in the 84 | * $httpOptions parameter. This parameter is ignored unless $httpOptions is used in its deprecated string form. 85 | * @param string $baseUrl 86 | * The URL of the CardDAV server without the path part (e.g. _https://carddav.example.com:443_). This URL is used 87 | * as base URL for the underlying {@see CardDavClient} that can be retrieved using {@see Account::getClient()}. 88 | * When relative URIs are passed to the client, they will be relative to this base URL. If this account is used for 89 | * discovery with the {@see Services\Discovery} service, this parameter can be omitted. 90 | * @api 91 | */ 92 | public function __construct(string $discoveryUri, $httpOptions, string $password = "", ?string $baseUrl = null) 93 | { 94 | $this->discoveryUri = $discoveryUri; 95 | $this->baseUrl = $baseUrl; 96 | 97 | if (is_string($httpOptions)) { 98 | $this->httpOptions = [ 99 | 'username' => $httpOptions, 100 | 'password' => $password 101 | ]; 102 | } else { 103 | $this->httpOptions = $httpOptions; 104 | } 105 | } 106 | 107 | /** 108 | * Constructs an Account object from an array representation. 109 | * 110 | * This can be used to reconstruct/deserialize an Account from a stored (JSON) representation. 111 | * 112 | * @psalm-param SerializedAccount $props 113 | * @param array $props An associative array containing the Account attributes. 114 | * Keys with the meaning from {@see Account::__construct()}: 115 | * `discoveryUri`, `baseUrl`, `username`, `password`, `bearertoken`, `verify`, `preemptive_basic_auth` 116 | * @see Account::jsonSerialize() 117 | * @api 118 | */ 119 | public static function constructFromArray(array $props): Account 120 | { 121 | $requiredProps = [ 'discoveryUri' ]; 122 | foreach ($requiredProps as $prop) { 123 | if (!isset($props[$prop])) { 124 | throw new \Exception("Array used to reconstruct account does not contain required property $prop"); 125 | } 126 | } 127 | 128 | /** @psalm-var SerializedAccount $props vimeo/psalm#10853 */ 129 | $discoveryUri = $props["discoveryUri"]; 130 | $baseUrl = $props["baseUrl"] ?? null; 131 | unset($props["discoveryUri"], $props["baseUrl"]); 132 | 133 | return new Account($discoveryUri, $props, "", $baseUrl); 134 | } 135 | 136 | /** 137 | * Allows to serialize an Account object to JSON. 138 | * 139 | * @psalm-return SerializedAccount 140 | * @return array Associative array of attributes to serialize. 141 | * @see Account::constructFromArray() 142 | */ 143 | public function jsonSerialize(): array 144 | { 145 | return [ 146 | "discoveryUri" => $this->discoveryUri, 147 | "baseUrl" => $this->baseUrl 148 | ] + $this->httpOptions; 149 | } 150 | 151 | /** 152 | * Provides a CardDavClient object to interact with the server for this account. 153 | * 154 | * @param string $baseUrl 155 | * A base URL to use by the client to resolve relative URIs. If not given, the base url of the Account is used. 156 | * This is useful, for example, to override the base path with that of a collection. 157 | * 158 | * @return CardDavClient 159 | * A CardDavClient object to interact with the server for this account. 160 | */ 161 | public function getClient(?string $baseUrl = null): CardDavClient 162 | { 163 | $clientUri = $baseUrl ?? $this->getUrl(); 164 | return new CardDavClient($clientUri, $this->httpOptions); 165 | } 166 | 167 | /** 168 | * Returns the discovery URI for this Account. 169 | * @api 170 | */ 171 | public function getDiscoveryUri(): string 172 | { 173 | return $this->discoveryUri; 174 | } 175 | 176 | /** 177 | * Set the base URL of this account once the service URL has been discovered. 178 | */ 179 | public function setUrl(string $url): void 180 | { 181 | $this->baseUrl = $url; 182 | } 183 | 184 | /** 185 | * Returns the base URL of the CardDAV service. 186 | * @api 187 | */ 188 | public function getUrl(): string 189 | { 190 | if (is_null($this->baseUrl)) { 191 | throw new \Exception("The base URI of the account has not been discovered yet"); 192 | } 193 | 194 | return $this->baseUrl; 195 | } 196 | 197 | /** 198 | * Provides a readable form of the core properties of the Account. 199 | * 200 | * This is meant for printing to a human, not for parsing, and therefore may change without considering this a 201 | * backwards incompatible change. 202 | */ 203 | public function __toString(): string 204 | { 205 | $str = $this->discoveryUri; 206 | $str .= ", user: " . ($this->httpOptions['username'] ?? ""); 207 | $str .= ", CardDAV URI: "; 208 | $str .= $this->baseUrl ?? "not discovered yet"; 209 | return $str; 210 | } 211 | 212 | /** 213 | * Queries the given URI for the current-user-principal property. 214 | * 215 | * Property description by RFC5397: The DAV:current-user-principal property contains either a DAV:href or 216 | * DAV:unauthenticated XML element. The DAV:href element contains a URL to a principal resource corresponding to the 217 | * currently authenticated user. That URL MUST be one of the URLs in the DAV:principal-URL or DAV:alternate-URI-set 218 | * properties defined on the principal resource and MUST be an http(s) scheme URL. When authentication has not been 219 | * done or has failed, this property MUST contain the DAV:unauthenticated pseudo-principal. 220 | * In some cases, there may be multiple principal resources corresponding to the same authenticated principal. In 221 | * that case, the server is free to choose any one of the principal resource URIs for the value of the 222 | * DAV:current-user-principal property. However, servers SHOULD be consistent and use the same principal resource 223 | * URI for each authenticated principal. 224 | * 225 | * @param string $contextPathUri 226 | * The given URI should typically be a context path per the terminology of RFC6764. 227 | * 228 | * @return ?string 229 | * The principal URI (string), or NULL in case of error. The returned URI is suited to be used for queries with 230 | * this client (i.e. either a full URI, or meaningful as relative URI to the base URI of this client). 231 | */ 232 | public function findCurrentUserPrincipal(string $contextPathUri): ?string 233 | { 234 | $princUrl = null; 235 | 236 | try { 237 | $client = $this->getClient(); 238 | $result = $client->findProperties($contextPathUri, [XmlEN::CURUSRPRINC]); 239 | 240 | if (isset($result[0]["props"][XmlEN::CURUSRPRINC])) { 241 | $princUrl = $result[0]["props"][XmlEN::CURUSRPRINC]; 242 | $princUrl = CardDavClient::concatUrl($result[0]["uri"], $princUrl); 243 | Config::$logger->info("principal URL: $princUrl"); 244 | } 245 | } catch (\Exception $e) { 246 | Config::$logger->info("Exception while querying current-user-principal: " . $e->getMessage()); 247 | } 248 | 249 | return $princUrl; 250 | } 251 | 252 | /** 253 | * Queries the given URI for the CARDDAV:addressbook-home-set property. 254 | * 255 | * Property description by RFC6352: The CARDDAV:addressbook-home-set property is meant to allow users to easily find 256 | * the address book collections owned by the principal. Typically, users will group all the address book collections 257 | * that they own under a common collection. This property specifies the URL of collections that are either address 258 | * book collections or ordinary collections that have child or descendant address book collections owned by the 259 | * principal. 260 | * 261 | * @param string $principalUri 262 | * The given URI should be (one of) the authenticated user's principal URI(s). 263 | * 264 | * @return null|list 265 | * The user's addressbook home URIs, or null in case of error. The returned URIs are suited to be used for queries 266 | * with this client (i.e. either a full URI, or meaningful as relative URI to the base URI of this client). 267 | */ 268 | public function findAddressbookHomes(string $principalUri): ?array 269 | { 270 | $addressbookHomeUris = []; 271 | 272 | try { 273 | $client = $this->getClient(); 274 | $result = $client->findProperties($principalUri, [XmlEN::ABOOK_HOME]); 275 | 276 | if (isset($result[0]["props"][XmlEN::ABOOK_HOME])) { 277 | $hrefs = $result[0]["props"][XmlEN::ABOOK_HOME]; 278 | 279 | foreach ($hrefs as $href) { 280 | $addressbookHomeUri = CardDavClient::concatUrl($result[0]["uri"], $href); 281 | $addressbookHomeUris[] = $addressbookHomeUri; 282 | Config::$logger->info("addressbook home: $addressbookHomeUri"); 283 | } 284 | } 285 | } catch (\Exception $e) { 286 | Config::$logger->info("Exception while querying addressbook-home-set: " . $e->getMessage()); 287 | return null; 288 | } 289 | 290 | return $addressbookHomeUris; 291 | } 292 | } 293 | 294 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 295 | -------------------------------------------------------------------------------- /src/Config.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\CardDavClient; 13 | 14 | use Psr\Log\{LoggerInterface, NullLogger}; 15 | 16 | /** 17 | * Central configuration of the carddavclient library. 18 | * 19 | * @package Public\Infrastructure 20 | * 21 | * @psalm-type LibOptionsInput = array{ 22 | * guzzle_logformat?: string, 23 | * } 24 | * 25 | * @psalm-type LibOptions = array{ 26 | * guzzle_logformat: string, 27 | * } 28 | */ 29 | class Config 30 | { 31 | public const GUZZLE_LOGFMT_DEBUG = 32 | '"{method} {target} HTTP/{version}" {code}' . "\n>>>>>>>>\n{request}\n<<<<<<<<\n{response}\n--------\n{error}"; 33 | 34 | public const GUZZLE_LOGFMT_SHORT = 35 | '"{method} {target} HTTP/{version}" {code} {res_header_Content-Length}'; 36 | 37 | /** @var LoggerInterface */ 38 | public static $logger; 39 | 40 | /** @var LoggerInterface */ 41 | public static $httplogger; 42 | 43 | /** 44 | * Configuration options of the library. 45 | * @var LibOptions 46 | * @psalm-readonly-allow-private-mutation 47 | */ 48 | public static $options = [ 49 | 'guzzle_logformat' => self::GUZZLE_LOGFMT_DEBUG, 50 | ]; 51 | 52 | /** 53 | * Initialize the library. 54 | * 55 | * The functions accepts two logger objects complying with the standard PSR-3 Logger interface, one of which is used 56 | * by the carddavclient lib itself, the other is passed to the HTTP client library to log the HTTP traffic. Pass 57 | * null for any logger to disable the corresponding logging. 58 | * 59 | * The $options parameter allows to override the default behavior of the library in various options. It is an 60 | * associative array that may have the following keys and values: 61 | * - guzzle_logformat(string): 62 | * Value is a string defining the HTTP log format when Guzzle is used as HTTP client library. See the 63 | * documentation of the Guzzle MessageFormatter class for the available placeholders. This class offers two 64 | * constants that can be used as template: 65 | * - {@see Config::GUZZLE_LOGFMT_SHORT}: A compact log format with only request type/URI and the response status 66 | * and content length 67 | * - {@see Config::GUZZLE_LOGFMT_DEBUG}: The default, which logs the full HTTP traffic including request bodies 68 | * and response bodies. 69 | * 70 | * @psalm-param LibOptionsInput $options Options to override defaults. 71 | */ 72 | public static function init( 73 | ?LoggerInterface $logger = null, 74 | ?LoggerInterface $httplogger = null, 75 | array $options = [] 76 | ): void { 77 | self::$logger = $logger ?? new NullLogger(); 78 | self::$httplogger = $httplogger ?? new NullLogger(); 79 | self::$options = $options + self::$options; 80 | } 81 | } 82 | 83 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 84 | -------------------------------------------------------------------------------- /src/Exception/ClientException.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\CardDavClient\Exception; 13 | 14 | use Psr\Http\Client\ClientExceptionInterface; 15 | 16 | /** 17 | * Implementation of PSR-18 ClientExceptionInterface. 18 | * 19 | * @package Public\Exceptions 20 | */ 21 | class ClientException extends \Exception implements ClientExceptionInterface 22 | { 23 | } 24 | 25 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 26 | -------------------------------------------------------------------------------- /src/Exception/NetworkException.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\CardDavClient\Exception; 13 | 14 | use Psr\Http\Message\RequestInterface; 15 | use Psr\Http\Client\NetworkExceptionInterface; 16 | 17 | /** 18 | * Implementation of PSR-18 NetworkExceptionInterface. 19 | * 20 | * @package Public\Exceptions 21 | */ 22 | class NetworkException extends ClientException implements NetworkExceptionInterface 23 | { 24 | /** @var RequestInterface */ 25 | private $request; 26 | 27 | public function __construct(string $message, int $code, RequestInterface $request, ?\Throwable $previous = null) 28 | { 29 | parent::__construct($message, $code, $previous); 30 | $this->request = $request; 31 | } 32 | 33 | /** 34 | * Returns the request. 35 | * 36 | * The request object MAY be a different object from the one passed to ClientInterface::sendRequest() 37 | * 38 | * @return RequestInterface 39 | */ 40 | public function getRequest(): RequestInterface 41 | { 42 | return $this->request; 43 | } 44 | } 45 | 46 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 47 | -------------------------------------------------------------------------------- /src/Exception/XmlParseException.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\CardDavClient\Exception; 13 | 14 | /** 15 | * Exception type to indicate that a parsed XML did not comply with the requirements described in its RFC definition. 16 | * 17 | * @package Public\Exceptions 18 | */ 19 | class XmlParseException extends \Exception 20 | { 21 | } 22 | 23 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 24 | -------------------------------------------------------------------------------- /src/HttpClientAdapter.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\CardDavClient; 13 | 14 | use Psr\Http\Message\ResponseInterface as Psr7Response; 15 | 16 | /** 17 | * Abstract base class for the internal HTTP client adapter. 18 | * 19 | * This class intends to decouple the rest of this library from the underlying HTTP client library to allow for 20 | * future replacement. 21 | * 22 | * We aim at staying close to the PSR-18 definition of the Http ClientInterface, however, because Guzzle does currently 23 | * not expose this interface (in particular its Psr7Request creation), compliance would mean to define an own 24 | * implementation of the Psr7 Request Interface to create request objects, that would have to be deconstructed when 25 | * interaction with Guzzle again. 26 | * 27 | * So for now, this is not compliant with PSR-18 for simplicity, but we aim at staying close to the definition 28 | * considering a potential later refactoring. 29 | * 30 | * @psalm-type RequestOptions = array{ 31 | * allow_redirects?: bool, 32 | * body?: string, 33 | * headers?: array> 34 | * } 35 | * 36 | * @psalm-import-type HttpOptions from Account 37 | * 38 | * @package Internal\Communication 39 | */ 40 | abstract class HttpClientAdapter 41 | { 42 | /** 43 | * Defines which credential attributes are required for auth mechanisms. 44 | * If a mechanism is not listed, it is assumed that no credentials are mandatory (e.g. GSSAPI/Kerberos). 45 | */ 46 | protected const NEEDED_AUTHNFO = [ 47 | 'basic' => [ 'username', 'password' ], 48 | 'digest' => [ 'username', 'password' ], 49 | 'bearer' => [ 'bearertoken' ] 50 | ]; 51 | 52 | /** 53 | * The base URI for requests. 54 | * @var string 55 | */ 56 | protected $baseUri; 57 | 58 | /** 59 | * The HTTP options to use for communication, including authentication credentials. 60 | * @var HttpOptions 61 | */ 62 | protected $httpOptions; 63 | 64 | /** Constructs a HttpClientAdapter object. 65 | * 66 | * @param string $base_uri Base URI to be used when relative URIs are given to requests. 67 | * @param HttpOptions $httpOptions Credentials used to authenticate with the server. 68 | */ 69 | protected function __construct(string $base_uri, array $httpOptions) 70 | { 71 | $this->baseUri = $base_uri; 72 | $this->httpOptions = $httpOptions; 73 | } 74 | 75 | /** 76 | * Sends an HTTP request and returns a PSR-7 response. 77 | * 78 | * @param string $method The request method (GET, PROPFIND, etc.) 79 | * @param string $uri The target URI. If relative, taken relative to the internal base URI of the HTTP client 80 | * @psalm-param RequestOptions $options 81 | * @param array $options 82 | * Request-specific options, merged with/override the default options of the client. Supported options are: 83 | * - 'allow_redirects' => boolean: True, if redirect responses should be resolved by the client. 84 | * - 'body' => Request body as string: Optional body to send with the HTTP request 85 | * - 'headers' => [ 'Headername' => 'Value' | [ 'Val1', 'Val2', ...] ]: Headers to include with the request 86 | * 87 | * @return Psr7Response The response retrieved from the server. 88 | * 89 | * @throws \Psr\Http\Client\ClientExceptionInterface if request could not be sent or response could not be parsed 90 | * @throws \Psr\Http\Client\RequestExceptionInterface if request is not a well-formed HTTP request or is missing 91 | * some critical piece of information (such as a Host or Method) 92 | * @throws \Psr\Http\Client\NetworkExceptionInterface if the request cannot be sent due to a network failure of any 93 | * kind, including a timeout 94 | */ 95 | abstract public function sendRequest(string $method, string $uri, array $options = []): Psr7Response; 96 | 97 | /** 98 | * Checks whether the given URI has the same domain as the base URI of this HTTP client. 99 | * 100 | * If the given URI does not contain a domain part, true is returned (as when used, it will 101 | * get that part from the base URI). 102 | * 103 | * @param string $uri The URI to check 104 | * @return bool True if the URI shares the same domain as the base URI. 105 | */ 106 | protected function checkSameDomainAsBase(string $uri): bool 107 | { 108 | $compUri = \Sabre\Uri\parse($uri); 109 | 110 | // if the URI is relative, the domain is the same 111 | if (isset($compUri["host"])) { 112 | $compBase = \Sabre\Uri\parse($this->baseUri); 113 | 114 | $result = strcasecmp( 115 | self::getDomainFromSubdomain($compUri["host"]), 116 | self::getDomainFromSubdomain($compBase["host"] ?? "") 117 | ) === 0; 118 | } else { 119 | $result = true; 120 | } 121 | 122 | return $result; 123 | } 124 | 125 | /** 126 | * Checks if the needed credentials for an authentication scheme are available. 127 | * 128 | * @param lowercase-string $scheme The authentication scheme to check for 129 | * @return bool True if the credentials needed for the scheme are available. 130 | */ 131 | protected function checkCredentialsAvailable(string $scheme): bool 132 | { 133 | if (isset(self::NEEDED_AUTHNFO[$scheme])) { 134 | foreach (self::NEEDED_AUTHNFO[$scheme] as $c) { 135 | if (!isset($this->httpOptions[$c])) { 136 | return false; 137 | } 138 | } 139 | } 140 | 141 | return true; 142 | } 143 | 144 | /** 145 | * Extracts the domain name from a subdomain. 146 | * 147 | * If the given string does not have a subdomain (i.e. top-level domain or domain only), 148 | * it is returned as provided. 149 | * 150 | * @param string $subdomain The subdomain (e.g. sub.example.com) 151 | * @return string The domain of $subdomain (e.g. example.com) 152 | */ 153 | protected static function getDomainFromSubdomain(string $subdomain): string 154 | { 155 | $parts = explode(".", $subdomain); 156 | 157 | if (count($parts) > 2) { 158 | $subdomain = implode(".", array_slice($parts, -2)); 159 | } 160 | 161 | return $subdomain; 162 | } 163 | } 164 | 165 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 166 | -------------------------------------------------------------------------------- /src/Services/Discovery.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\CardDavClient\Services; 13 | 14 | use MStilkerich\CardDavClient\{Account, AddressbookCollection, CardDavClient, Config, WebDavCollection}; 15 | 16 | /** 17 | * Provides a service to discover the addressbooks for a CardDAV account. 18 | * 19 | * It implements the discovery using the mechanisms specified in RFC 6764, which is based on DNS SRV/TXT records and/or 20 | * well-known URI redirection on the server. 21 | * 22 | * @psalm-type Server = array{ 23 | * host: string, 24 | * port: string, 25 | * scheme: string, 26 | * dnsrr?: string, 27 | * userinput?: bool 28 | * } 29 | * 30 | * @psalm-type SrvRecord = array{pri: int, weight: int, target: string, port: int} 31 | * @psalm-type TxtRecord = array{txt: string} 32 | * 33 | * @package Public\Services 34 | */ 35 | class Discovery 36 | { 37 | /** 38 | * Some builtins for public providers that don't have discovery properly set up. 39 | * 40 | * It maps a domain name that is part of the typically used usernames to a working discovery URI. This allows 41 | * discovery from data as typically provided by a user without the application having to care about it. 42 | * 43 | * @var array 44 | */ 45 | private const KNOWN_SERVERS = [ 46 | "gmail.com" => "www.googleapis.com", 47 | "googlemail.com" => "www.googleapis.com", 48 | ]; 49 | 50 | /** 51 | * Discover the addressbooks for a CardDAV account. 52 | * 53 | * @param Account $account The CardDAV account providing credentials and initial discovery URI. 54 | * @psalm-return list 55 | * @return array The discovered addressbooks. 56 | * 57 | * @throws \Exception 58 | * In case of error, sub-classes of \Exception are thrown, with an error message contained within the \Exception 59 | * object. 60 | * 61 | * @api 62 | */ 63 | public function discoverAddressbooks(Account $account): array 64 | { 65 | $uri = $account->getDiscoveryUri(); 66 | Config::$logger->debug("Starting discovery with input $uri"); 67 | if (!preg_match(';^(([^:]+)://)?(([^/:]+)(:([0-9]+))?)(/?.*)$;', $uri, $match)) { 68 | throw new \InvalidArgumentException("The account's discovery URI must contain a hostname (got: $uri)"); 69 | } 70 | 71 | $protocol = $match[2]; // optional 72 | $host = $match[4]; // mandatory 73 | $port = $match[6]; // optional 74 | $path = $match[7]; // optional 75 | 76 | // plain is only used if http was explicitly given 77 | $force_ssl = ($protocol !== "http"); 78 | 79 | // setup default values if no user values given 80 | if (strlen($protocol) == 0) { 81 | $protocol = $force_ssl ? 'https' : 'http'; 82 | } 83 | if (strlen($port) == 0) { 84 | $port = $force_ssl ? '443' : '80'; 85 | } 86 | 87 | // (1) Discover the hostname and port (may be multiple results for failover setups) 88 | // $servers is array of: 89 | // [host => "contacts.abc.com", port => "443", scheme => "https", dnsrr => '_carddavs._tcp.abc.com'] 90 | // servers in the array are ordered by precedence, highest first 91 | // dnsrr is only set when the server was discovered by lookup of DNS SRV record 92 | $servers = $this->discoverServers($host, $force_ssl); 93 | 94 | // some builtins for providers that have discovery for the domains known to 95 | // users not properly set up 96 | if (key_exists($host, self::KNOWN_SERVERS)) { 97 | $servers[] = [ "host" => self::KNOWN_SERVERS[$host], "port" => $port, "scheme" => $protocol]; 98 | } 99 | 100 | // as a fallback, we will last try what the user provided 101 | $servers[] = [ "host" => $host, "port" => $port, "scheme" => $protocol, "userinput" => true ]; 102 | 103 | $addressbooks = []; 104 | 105 | // (2) Discover the "initial context path" for each servers (until first success) 106 | foreach ($servers as $server) { 107 | $baseurl = $server["scheme"] . "://" . $server["host"] . ":" . $server["port"]; 108 | $account->setUrl($baseurl); 109 | 110 | $contextpaths = $this->discoverContextPath($server); 111 | 112 | // as a fallback, we will last try what the user provided 113 | if (($server["userinput"] ?? false) && (!empty($path))) { 114 | $contextpaths[] = $path; 115 | } 116 | 117 | foreach ($contextpaths as $contextpath) { 118 | Config::$logger->debug("Try context path $contextpath"); 119 | // (3) Attempt a PROPFIND asking for the DAV:current-user-principal property 120 | $principalUri = $account->findCurrentUserPrincipal($contextpath); 121 | if (isset($principalUri)) { 122 | // (4) Attempt a PROPFIND asking for the addressbook home of the user on the principal URI 123 | $addressbookHomeUris = $account->findAddressbookHomes($principalUri); 124 | try { 125 | foreach ($addressbookHomeUris ?? [] as $addressbookHomeUri) { 126 | // (5) Attempt PROPFIND (Depth 1) to discover all addressbooks of the user 127 | $addressbookHome = new WebDavCollection($addressbookHomeUri, $account); 128 | 129 | foreach ($addressbookHome->getChildren() as $abookCandidate) { 130 | if ($abookCandidate instanceof AddressbookCollection) { 131 | $addressbooks[] = $abookCandidate; 132 | } 133 | } 134 | } 135 | 136 | // We found valid addressbook homes. If they contain no addressbooks, this is fine and the 137 | // result of the discovery is an empty set. 138 | return $addressbooks; 139 | } catch (\Exception $e) { 140 | Config::$logger->info("Exception while querying addressbooks: " . $e->getMessage()); 141 | } 142 | } 143 | } 144 | } 145 | 146 | throw new \Exception("Could not determine the addressbook home"); 147 | } 148 | 149 | /** 150 | * Discovers the CardDAV service for the given domain using DNS SRV lookups. 151 | * 152 | * @param string $host A domain name to discover the service for 153 | * @param bool $force_ssl If true, only services with transport encryption (carddavs) will be discovered, 154 | * otherwise the function will try to discover unencrypted (carddav) services after failing 155 | * to discover encrypted ones. 156 | * @psalm-return list 157 | * @return array 158 | * Returns an array of associative arrays of services discovered via DNS. If nothing was found, the returned array 159 | * is empty. 160 | */ 161 | private function discoverServers(string $host, bool $force_ssl): array 162 | { 163 | $servers = []; 164 | 165 | $rrnamesAndSchemes = [ ["_carddavs._tcp.$host", 'https'] ]; 166 | if ($force_ssl === false) { 167 | $rrnamesAndSchemes[] = ["_carddav._tcp.$host", 'http']; 168 | } 169 | 170 | foreach ($rrnamesAndSchemes as $rrnameAndScheme) { 171 | list($rrname, $scheme) = $rrnameAndScheme; 172 | 173 | // query SRV records 174 | /** @psalm-var list | false */ 175 | $dnsresults = dns_get_record($rrname, DNS_SRV); 176 | 177 | if (is_array($dnsresults)) { 178 | break; 179 | } 180 | } 181 | 182 | if (is_array($dnsresults)) { 183 | usort($dnsresults, [self::class, 'orderDnsRecords']); 184 | 185 | // build results 186 | foreach ($dnsresults as $dnsres) { 187 | if (isset($dnsres['target']) && isset($dnsres['port'])) { 188 | $servers[] = 189 | [ 190 | "host" => $dnsres['target'], 191 | "port" => (string) $dnsres['port'], 192 | "scheme" => $scheme, 193 | "dnsrr" => $rrname 194 | ]; 195 | Config::$logger->info("Found server per DNS SRV $rrname: {$dnsres['target']}: {$dnsres['port']}"); 196 | } 197 | } 198 | } 199 | 200 | return $servers; 201 | } 202 | 203 | /** 204 | * Orders DNS records by their prio and weight. 205 | * 206 | * @psalm-param SrvRecord $a 207 | * @psalm-param SrvRecord $b 208 | * 209 | * @todo weight is not quite correctly handled atm, see RFC2782, but this is not crucial to functionality 210 | */ 211 | private static function orderDnsRecords(array $a, array $b): int 212 | { 213 | if ($a['pri'] != $b['pri']) { 214 | return $b['pri'] - $a['pri']; 215 | } 216 | 217 | return $a['weight'] - $b['weight']; 218 | } 219 | 220 | /** 221 | * Provides a list of URIs to check for discovering the location of the CardDAV service. 222 | * 223 | * The provided context paths comprise both well-known URIs as well as paths discovered via DNS TXT records. DNS TXT 224 | * lookup is only performed for servers that have themselves been discovery using DNS SRV lookups, using the same 225 | * service resource record. 226 | * 227 | * @psalm-param Server $server 228 | * @param array $server A server record (associative array) as returned by discoverServers() 229 | * @psalm-return list 230 | * @return string[] The context paths that should be tried for discovery in the provided order. 231 | * @see Discovery::discoverServers() 232 | */ 233 | private function discoverContextPath(array $server): array 234 | { 235 | $contextpaths = []; 236 | 237 | if (isset($server["dnsrr"])) { 238 | /** @psalm-var list | false */ 239 | $dnsresults = dns_get_record($server["dnsrr"], DNS_TXT); 240 | if (is_array($dnsresults)) { 241 | foreach ($dnsresults as $dnsresult) { 242 | if (preg_match('/^path=(.+)/', $dnsresult['txt'] ?? "", $match)) { 243 | $contextpaths[] = $match[1]; 244 | Config::$logger->info("Discovered context path $match[1] per DNS TXT record\n"); 245 | } 246 | } 247 | } 248 | } 249 | 250 | $contextpaths[] = '/.well-known/carddav'; 251 | $contextpaths[] = '/'; 252 | $contextpaths[] = '/co'; // workaround for iCloud 253 | 254 | return $contextpaths; 255 | } 256 | } 257 | 258 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 259 | -------------------------------------------------------------------------------- /src/Services/SyncHandler.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\CardDavClient\Services; 13 | 14 | use Sabre\VObject\Component\VCard; 15 | 16 | /** 17 | * Interface for application-level synchronization handler. 18 | * 19 | * During an addressbook synchronization, the corresponding methods of this interface are invoked for events such as 20 | * changed or deleted address objects, to be handled in an application-specific manner. 21 | * 22 | * @package Public\Services 23 | */ 24 | interface SyncHandler 25 | { 26 | /** 27 | * This method is called for each changed address object, including new address objects. 28 | * 29 | * In case an error occurs attempting to retrieve or to parse the address data for an URI that the server reported 30 | * as changed, this method is invoked with a null $card parameter. This allows the client to know that there was a 31 | * change that is missing from the sync, and to handle or ignore it as it sees fit. 32 | * 33 | * @param string $uri 34 | * URI of the changed or added address object. 35 | * @param string $etag 36 | * ETag of the retrieved version of the address object. 37 | * @param ?VCard $card 38 | * A (partial) VCard containing (at least, if available)the requested VCard properties. Null in case an error 39 | * occurred retrieving or parsing the VCard retrieved from the server. 40 | * 41 | * @see Sync 42 | * @api 43 | */ 44 | public function addressObjectChanged(string $uri, string $etag, ?VCard $card): void; 45 | 46 | /** 47 | * This method is called for each deleted address object. 48 | * 49 | * @param string $uri 50 | * URI of the deleted address object. 51 | * 52 | * @see Sync 53 | * @api 54 | */ 55 | public function addressObjectDeleted(string $uri): void; 56 | 57 | /** 58 | * Provides the URIs and ETags of all VCards existing locally. 59 | * 60 | * During synchronization, it may be required to identify the version of locally existing address objects to 61 | * determine whether the server-side version is newer than the local version. This is the case if the server does 62 | * not support the sync-collection report, or if the sync-token has expired on the server and thus the server is not 63 | * able to report the changes against the local state. 64 | * 65 | * For the first sync, returns an empty array. The {@see Sync} service will consider cards as: 66 | * - new: URI not contained in the returned aray 67 | * - changed: URI contained, assigned local ETag differs from server-side ETag 68 | * - unchanged: URI contained, assigned local ETag equals server-side ETag 69 | * - deleted: URI contained in array, but not reported by server as content of the addressbook 70 | * 71 | * Note: This array is only requested by the {@see Sync} service if needed, which is only the case if the 72 | * sync-collection REPORT cannot be used. Therefore, if it is expensive to construct this array, make sure 73 | * construction is done on demand in this method, which will not be called if the data is not needed. 74 | * 75 | * @return array 76 | * Associative array with URIs (URL path component without server) as keys, ETags as values. 77 | * 78 | * @see Sync 79 | * @api 80 | */ 81 | public function getExistingVCardETags(): array; 82 | 83 | /** 84 | * Called upon completion of the synchronization process to enable the handler to perform final actions if needed. 85 | * 86 | * @see Sync 87 | * @api 88 | */ 89 | public function finalizeSync(): void; 90 | } 91 | 92 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 93 | -------------------------------------------------------------------------------- /src/Services/SyncResult.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\CardDavClient\Services; 13 | 14 | use Sabre\VObject\Component\VCard; 15 | use MStilkerich\CardDavClient\{CardDavClient, Config}; 16 | 17 | /** 18 | * Stores the changes reported by the server to be processed during a sync operation. 19 | * 20 | * This class is used internally only by the {@see Sync} service. 21 | * 22 | * @package Internal\Services 23 | */ 24 | class SyncResult 25 | { 26 | /** 27 | * The new sync token returned by the server. 28 | * @var string 29 | */ 30 | public $syncToken; 31 | 32 | /** 33 | * True if the server limited the returned differences and another followup sync is needed. 34 | * @var bool 35 | */ 36 | public $syncAgain = false; 37 | 38 | /** 39 | * URIs of deleted objects. 40 | * 41 | * @psalm-var list 42 | * @var array 43 | */ 44 | public $deletedObjects = []; 45 | 46 | /** 47 | * URIs and ETags of new or changed address objects. 48 | * 49 | * @psalm-var list 50 | * @var array 51 | */ 52 | public $changedObjects = []; 53 | 54 | /** 55 | * Construct a new sync result. 56 | * 57 | * @param string $syncToken The new sync token returned by the server. 58 | */ 59 | public function __construct(string $syncToken) 60 | { 61 | $this->syncToken = $syncToken; 62 | } 63 | 64 | /** 65 | * Creates VCard objects for all changed cards. 66 | * 67 | * The objects are inserted into the {@see SyncResult::$changedObjects} array. In case the VCard object cannot be 68 | * created for some of the cards (for example parse error), an error is logged. If no vcard string data is available 69 | * in {@see SyncResult::$changedObjects} for a VCard, a warning is logged. 70 | * 71 | * @return bool 72 | * True if a VCard could be created for all cards in {@see SyncResult::$changedObjects}, false otherwise. 73 | */ 74 | public function createVCards(): bool 75 | { 76 | $ret = true; 77 | 78 | foreach ($this->changedObjects as &$obj) { 79 | if (!isset($obj["vcard"])) { 80 | if (isset($obj["vcf"])) { 81 | try { 82 | $obj["vcard"] = \Sabre\VObject\Reader::read($obj["vcf"]); 83 | } catch (\Exception $e) { 84 | Config::$logger->error("Could not parse VCF for " . $obj["uri"], [ 'exception' => $e ]); 85 | $ret = false; 86 | } 87 | } else { 88 | Config::$logger->warning("No VCF for address object " . $obj["uri"] . " available"); 89 | $ret = false; 90 | } 91 | } 92 | } 93 | unset($obj); 94 | 95 | return $ret; 96 | } 97 | } 98 | 99 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 100 | -------------------------------------------------------------------------------- /src/WebDavCollection.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\CardDavClient; 13 | 14 | use MStilkerich\CardDavClient\XmlElements\ElementNames as XmlEN; 15 | 16 | /** 17 | * Represents a collection on a WebDAV server. 18 | * 19 | * @package Public\Entities 20 | */ 21 | class WebDavCollection extends WebDavResource 22 | { 23 | /** 24 | * List of properties to query in refreshProperties() and returned by getProperties(). 25 | * @psalm-var list 26 | * @see WebDavResource::getProperties() 27 | * @see WebDavResource::refreshProperties() 28 | */ 29 | private const PROPNAMES = [ 30 | XmlEN::SYNCTOKEN, 31 | XmlEN::SUPPORTED_REPORT_SET, 32 | XmlEN::ADD_MEMBER 33 | ]; 34 | 35 | /** 36 | * Returns the sync token of this collection. 37 | * 38 | * Note that the value may be cached. If this resource was just created, this is not an issue, but if a property 39 | * cache may exist for a longer time call {@see WebDavResource::refreshProperties()} first to ensure an up to date 40 | * sync token is provided. 41 | * 42 | * @return ?string The sync token, or null if the server does not provide a sync-token for this collection. 43 | * @api 44 | */ 45 | public function getSyncToken(): ?string 46 | { 47 | $props = $this->getProperties(); 48 | return $props[XmlEN::SYNCTOKEN] ?? null; 49 | } 50 | 51 | /** 52 | * Queries whether the server supports the sync-collection REPORT on this collection. 53 | * @return bool True if sync-collection is supported for this collection. 54 | * @api 55 | */ 56 | public function supportsSyncCollection(): bool 57 | { 58 | return $this->supportsReport(XmlEN::REPORT_SYNCCOLL); 59 | } 60 | 61 | /** 62 | * Returns the child resources of this collection. 63 | * 64 | * @psalm-return list 65 | * @return array The children of this collection. 66 | * @api 67 | */ 68 | public function getChildren(): array 69 | { 70 | $childObjs = []; 71 | 72 | try { 73 | $client = $this->getClient(); 74 | $children = $client->findProperties($this->getUri(), [ XmlEN::RESTYPE ], "1"); 75 | 76 | $path = $this->getUriPath(); 77 | 78 | foreach ($children as $child) { 79 | $obj = parent::createInstance($child["uri"], $this->account, $child["props"][XmlEN::RESTYPE] ?? null); 80 | if ($obj->getUriPath() != $path) { 81 | $childObjs[] = $obj; 82 | } 83 | } 84 | } catch (\Exception $e) { 85 | Config::$logger->info("Exception while querying collection children: " . $e->getMessage()); 86 | } 87 | 88 | return $childObjs; 89 | } 90 | 91 | /** 92 | * {@inheritdoc} 93 | */ 94 | protected function getNeededCollectionPropertyNames(): array 95 | { 96 | $parentPropNames = parent::getNeededCollectionPropertyNames(); 97 | $propNames = array_merge($parentPropNames, self::PROPNAMES); 98 | return array_values(array_unique($propNames)); 99 | } 100 | 101 | /** 102 | * Checks if the server supports the given REPORT on this collection. 103 | * 104 | * @param string $reportElement 105 | * The XML element name of the REPORT of interest, including namespace (e.g. {DAV:}sync-collection). 106 | * @return bool True if the report is supported on this collection. 107 | */ 108 | protected function supportsReport(string $reportElement): bool 109 | { 110 | $props = $this->getProperties(); 111 | return in_array($reportElement, $props[XmlEN::SUPPORTED_REPORT_SET] ?? [], true); 112 | } 113 | } 114 | 115 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 116 | -------------------------------------------------------------------------------- /src/WebDavResource.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\CardDavClient; 13 | 14 | use MStilkerich\CardDavClient\XmlElements\ElementNames as XmlEN; 15 | use MStilkerich\CardDavClient\XmlElements\Prop; 16 | 17 | /** 18 | * Represents a resource on a WebDAV server. 19 | * 20 | * @psalm-import-type PropTypes from Prop 21 | * 22 | * @package Public\Entities 23 | */ 24 | class WebDavResource implements \JsonSerializable 25 | { 26 | /** 27 | * URI of the resource 28 | * @var string 29 | */ 30 | protected $uri; 31 | 32 | /** 33 | * Cached WebDAV properties of the resource 34 | * @psalm-var PropTypes 35 | * @var array 36 | */ 37 | private $props = []; 38 | 39 | /** 40 | * The CardDAV account this resource is associated/accessible with 41 | * @var Account 42 | */ 43 | protected $account; 44 | 45 | /** 46 | * CardDavClient object for the account's base URI 47 | * @var CardDavClient 48 | */ 49 | private $client; 50 | 51 | /** 52 | * List of properties to query in refreshProperties() and returned by getProperties(). 53 | * @psalm-var list 54 | */ 55 | private const PROPNAMES = [ 56 | XmlEN::RESTYPE, 57 | ]; 58 | 59 | /** 60 | * Factory for WebDavResource objects. 61 | * 62 | * Given an account and URI, it attempts to create an instance of the most specific subclass matching the 63 | * resource identified by the given URI. In case no resource can be accessed via the given account and 64 | * URI, an exception is thrown. 65 | * 66 | * Compared to direct construction of the object, creation via this factory involves querying the resourcetype of 67 | * the URI with the server, so this is a checked form of instantiation whereas no server communication occurs when 68 | * using the constructor. 69 | * 70 | * @param string $uri 71 | * The target URI of the resource. 72 | * @param Account $account 73 | * The account by which the URI shall be accessed. 74 | * @psalm-param null|list $restype 75 | * @param null|array $restype 76 | * Array with the DAV:resourcetype properties of the URI (if already available saves the query) 77 | * @return WebDavResource An object that is an instance of the most suited subclass of WebDavResource. 78 | * @api 79 | */ 80 | public static function createInstance(string $uri, Account $account, ?array $restype = null): WebDavResource 81 | { 82 | if (!isset($restype)) { 83 | $res = new self($uri, $account); 84 | $props = $res->getProperties(); 85 | $restype = $props[XmlEN::RESTYPE] ?? []; 86 | } 87 | 88 | if (in_array(XmlEN::RESTYPE_ABOOK, $restype)) { 89 | return new AddressbookCollection($uri, $account); 90 | } elseif (in_array(XmlEN::RESTYPE_COLL, $restype)) { 91 | return new WebDavCollection($uri, $account); 92 | } else { 93 | return new self($uri, $account); 94 | } 95 | } 96 | 97 | /** 98 | * Constructs a WebDavResource object. 99 | * 100 | * @param string $uri 101 | * The target URI of the resource. 102 | * @param Account $account 103 | * The account by which the URI shall be accessed. 104 | * @api 105 | */ 106 | public function __construct(string $uri, Account $account) 107 | { 108 | $this->uri = $uri; 109 | $this->account = $account; 110 | 111 | $this->client = $account->getClient($uri); 112 | } 113 | 114 | /** 115 | * Returns the standard WebDAV properties for this resource. 116 | * 117 | * Retrieved from the server on first request, cached afterwards. Use {@see WebDavResource::refreshProperties()} to 118 | * force update of cached properties. 119 | * 120 | * @psalm-return PropTypes 121 | * @return array 122 | * Array mapping property name to corresponding value(s). The value type depends on the property. 123 | */ 124 | protected function getProperties(): array 125 | { 126 | if (empty($this->props)) { 127 | $this->refreshProperties(); 128 | } 129 | return $this->props; 130 | } 131 | 132 | /** 133 | * Forces a refresh of the cached standard WebDAV properties for this resource. 134 | * 135 | * @see WebDavResource::getProperties() 136 | * @api 137 | */ 138 | public function refreshProperties(): void 139 | { 140 | $propNames = $this->getNeededCollectionPropertyNames(); 141 | $client = $this->getClient(); 142 | $result = $client->findProperties($this->uri, $propNames); 143 | if (isset($result[0]["props"])) { 144 | $this->props = $result[0]["props"]; 145 | } else { 146 | throw new \Exception("Failed to retrieve properties for resource " . $this->uri); 147 | } 148 | } 149 | 150 | /** 151 | * Allows to serialize WebDavResource object to JSON. 152 | * 153 | * @return array Associative array of attributes to serialize. 154 | */ 155 | public function jsonSerialize(): array 156 | { 157 | return [ "uri" => $this->uri ]; 158 | } 159 | 160 | /** 161 | * Returns the Account this resource belongs to. 162 | * @api 163 | */ 164 | public function getAccount(): Account 165 | { 166 | return $this->account; 167 | } 168 | 169 | /** 170 | * Provides a CardDavClient object to interact with the server for this resource. 171 | * 172 | * The base URL used by the returned client is the URL of this resource. 173 | * 174 | * @return CardDavClient 175 | * A CardDavClient object to interact with the server for this resource. 176 | */ 177 | public function getClient(): CardDavClient 178 | { 179 | return $this->client; 180 | } 181 | 182 | /** 183 | * Returns the URI of this resource. 184 | * @api 185 | */ 186 | public function getUri(): string 187 | { 188 | return $this->uri; 189 | } 190 | 191 | /** 192 | * Returns the path component of the URI of this resource. 193 | * @api 194 | */ 195 | public function getUriPath(): string 196 | { 197 | $uricomp = \Sabre\Uri\parse($this->getUri()); 198 | return $uricomp["path"] ?? "/"; 199 | } 200 | 201 | /** 202 | * Returns the basename (last path component) of the URI of this resource. 203 | * @api 204 | */ 205 | public function getBasename(): string 206 | { 207 | $path = $this->getUriPath(); 208 | /** @var ?string $basename */ 209 | [ , $basename ] = \Sabre\Uri\split($path); 210 | return $basename ?? ""; 211 | } 212 | 213 | /** 214 | * Downloads the content of a given resource. 215 | * 216 | * @param string $uri 217 | * URI of the requested resource. 218 | * 219 | * @psalm-return array{body: string} 220 | * @return array 221 | * An associative array where the key 'body' maps to the content of the requested resource. 222 | * @api 223 | */ 224 | public function downloadResource(string $uri): array 225 | { 226 | $client = $this->getClient(); 227 | $response = $client->getResource($uri); 228 | $body = (string) $response->getBody(); // checked to be present in CardDavClient::getResource() 229 | return [ 'body' => $body ]; 230 | } 231 | 232 | /** 233 | * Provides the list of property names that should be requested upon call of refreshProperties(). 234 | * 235 | * @psalm-return list 236 | * @return array A list of property names including namespace prefix (e. g. '{DAV:}resourcetype'). 237 | * 238 | * @see WebDavResource::getProperties() 239 | * @see WebDavResource::refreshProperties() 240 | */ 241 | protected function getNeededCollectionPropertyNames(): array 242 | { 243 | return self::PROPNAMES; 244 | } 245 | } 246 | 247 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 248 | -------------------------------------------------------------------------------- /src/XmlElements/Deserializers.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\CardDavClient\XmlElements; 13 | 14 | use Sabre\Xml\Reader as Reader; 15 | use MStilkerich\CardDavClient\XmlElements\ElementNames as XmlEN; 16 | 17 | /** 18 | * Contains static deserializer functions to be used with Sabre/XML. 19 | * 20 | * @psalm-type DeserializedElem = array{ 21 | * name: string, 22 | * attributes: array, 23 | * value: mixed 24 | * } 25 | * 26 | * @package Internal\XmlElements 27 | */ 28 | class Deserializers 29 | { 30 | /** 31 | * Deserializes a single DAV:href child element to a string. 32 | * 33 | * @return ?string 34 | * If no href child element is present, null is returned. If multiple href child elements are present, the value of 35 | * the first one is returned. 36 | */ 37 | public static function deserializeHrefSingle(Reader $reader): ?string 38 | { 39 | $hrefs = self::deserializeHrefMulti($reader); 40 | return $hrefs[0] ?? null; 41 | } 42 | 43 | /** 44 | * Deserializes a multiple DAV:href child elements to an array of strings. 45 | * 46 | * @psalm-return list 47 | * @return array 48 | * An array of strings, each representing the value of a href child element. Empty array if no href child elements 49 | * present. 50 | */ 51 | public static function deserializeHrefMulti(Reader $reader): array 52 | { 53 | $hrefs = []; 54 | $children = $reader->parseInnerTree(); 55 | if (is_array($children)) { 56 | /** @psalm-var DeserializedElem $child */ 57 | foreach ($children as $child) { 58 | if (strcasecmp($child["name"], XmlEN::HREF) == 0) { 59 | if (is_string($child["value"])) { 60 | $hrefs[] = $child["value"]; 61 | } 62 | } 63 | } 64 | } 65 | 66 | return $hrefs; 67 | } 68 | 69 | /** 70 | * Deserializes XML DAV:supported-report-set elements to an array (RFC3253). 71 | * 72 | * Per RFC3252, arbitrary report element types are nested within the DAV:supported-report-set element. 73 | * Example: 74 | * ```xml 75 | * 76 | * <--- 0+ supported-report elements --> 77 | * <--- 1 each containing exactly one report element --> 78 | * <--- 1 containing exactly one ANY element for the corresponding report --> 79 | * 80 | * 81 | * 82 | * 83 | * 84 | * 85 | * 86 | * 87 | * ``` 88 | * 89 | * @psalm-return list 90 | * @return array Array with the element names of the supported reports. 91 | */ 92 | public static function deserializeSupportedReportSet(Reader $reader): array 93 | { 94 | $srs = []; 95 | 96 | $supportedReports = $reader->parseInnerTree(); 97 | 98 | // First run over all the supported-report elements (there is one for each supported report) 99 | if (is_array($supportedReports)) { 100 | /** @psalm-var DeserializedElem $supportedReport */ 101 | foreach ($supportedReports as $supportedReport) { 102 | if (strcasecmp($supportedReport['name'], XmlEN::SUPPORTED_REPORT) === 0) { 103 | if (is_array($supportedReport['value'])) { 104 | // Second run over all the report elements (there should be exactly one per RFC3253) 105 | /** @psalm-var DeserializedElem $report */ 106 | foreach ($supportedReport['value'] as $report) { 107 | if (strcasecmp($report['name'], XmlEN::REPORT) === 0) { 108 | if (is_array($report['value'])) { 109 | // Finally, get the actual element specific for the supported report 110 | // (there should be exactly one per RFC3253) 111 | /** @psalm-var DeserializedElem $reportelem */ 112 | foreach ($report['value'] as $reportelem) { 113 | $srs[] = $reportelem["name"]; 114 | } 115 | } 116 | } 117 | } 118 | } 119 | } 120 | } 121 | } 122 | return $srs; 123 | } 124 | 125 | /** 126 | * Deserializes an XML element to an array of its attributes, discarding its contents. 127 | * 128 | * @return array Mapping attribute names to values. 129 | */ 130 | public static function deserializeToAttributes(Reader $reader): array 131 | { 132 | /** @var array */ 133 | $attributes = $reader->parseAttributes(); 134 | $reader->next(); 135 | return $attributes; 136 | } 137 | 138 | /** 139 | * Deserializes a CARDDAV:supported-address-data element (RFC 6352). 140 | * 141 | * It contains one or more CARDDAV:address-data-type elements. 142 | */ 143 | public static function deserializeSupportedAddrData(Reader $reader): array 144 | { 145 | return \Sabre\Xml\Deserializer\repeatingElements($reader, XmlEN::ADDRDATATYPE); 146 | } 147 | } 148 | 149 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 150 | -------------------------------------------------------------------------------- /src/XmlElements/ElementNames.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\CardDavClient\XmlElements; 13 | 14 | /** 15 | * Defines constants with fully-qualified XML element names. 16 | * 17 | * The syntax used is clark notation, i.e. including the namespace as a braced prefix. This syntax is understood by the 18 | * Sabre libraries. 19 | * 20 | * @package Internal\XmlElements 21 | */ 22 | class ElementNames 23 | { 24 | /** @var string */ 25 | public const NSDAV = 'DAV:'; 26 | /** @var string */ 27 | public const NSCARDDAV = 'urn:ietf:params:xml:ns:carddav'; 28 | /** @var string */ 29 | public const NSCS = 'http://calendarserver.org/ns/'; 30 | 31 | /** @var string */ 32 | public const CURUSRPRINC = "{" . self::NSDAV . "}current-user-principal"; 33 | /** @var string */ 34 | public const ABOOK_HOME = "{" . self::NSCARDDAV . "}addressbook-home-set"; 35 | 36 | /** @var string */ 37 | public const DISPNAME = "{" . self::NSDAV . "}displayname"; 38 | /** @var string */ 39 | public const RESTYPE = "{" . self::NSDAV . "}resourcetype"; 40 | /** @var string */ 41 | public const RESTYPE_COLL = "{" . self::NSDAV . "}collection"; 42 | /** @var string */ 43 | public const RESTYPE_ABOOK = "{" . self::NSCARDDAV . "}addressbook"; 44 | 45 | /** @var string */ 46 | public const GETCTAG = "{" . self::NSCS . "}getctag"; 47 | /** @var string */ 48 | public const GETETAG = "{" . self::NSDAV . "}getetag"; 49 | 50 | /** @var string */ 51 | public const ADD_MEMBER = "{" . self::NSDAV . "}add-member"; 52 | /** @var string */ 53 | public const SUPPORTED_REPORT_SET = "{" . self::NSDAV . "}supported-report-set"; 54 | /** @var string */ 55 | public const SUPPORTED_REPORT = "{" . self::NSDAV . "}supported-report"; 56 | /** @var string */ 57 | public const REPORT = "{" . self::NSDAV . "}report"; 58 | /** @var string */ 59 | public const REPORT_SYNCCOLL = "{" . self::NSDAV . "}sync-collection"; 60 | /** @var string */ 61 | public const REPORT_MULTIGET = "{" . self::NSCARDDAV . "}addressbook-multiget"; 62 | /** @var string */ 63 | public const REPORT_QUERY = "{" . self::NSCARDDAV . "}addressbook-query"; 64 | 65 | /** @var string */ 66 | public const SYNCTOKEN = "{" . self::NSDAV . "}sync-token"; 67 | /** @var string */ 68 | public const SYNCLEVEL = "{" . self::NSDAV . "}sync-level"; 69 | 70 | /** @var string */ 71 | public const SUPPORTED_ADDRDATA = "{" . self::NSCARDDAV . "}supported-address-data"; 72 | /** @var string */ 73 | public const ADDRDATA = "{" . self::NSCARDDAV . "}address-data"; 74 | /** @var string */ 75 | public const ADDRDATATYPE = "{" . self::NSCARDDAV . "}address-data-type"; 76 | /** @var string */ 77 | public const VCFPROP = "{" . self::NSCARDDAV . "}prop"; 78 | /** @var string */ 79 | public const ABOOK_DESC = "{" . self::NSCARDDAV . "}addressbook-description"; 80 | /** @var string */ 81 | public const MAX_RESSIZE = "{" . self::NSCARDDAV . "}max-resource-size"; 82 | 83 | /** @var string */ 84 | public const MULTISTATUS = "{" . self::NSDAV . "}multistatus"; 85 | /** @var string */ 86 | public const RESPONSE = "{" . self::NSDAV . "}response"; 87 | /** @var string */ 88 | public const STATUS = "{" . self::NSDAV . "}status"; 89 | /** @var string */ 90 | public const PROPFIND = "{" . self::NSDAV . "}propfind"; 91 | /** @var string */ 92 | public const PROPSTAT = "{" . self::NSDAV . "}propstat"; 93 | /** @var string */ 94 | public const PROP = "{" . self::NSDAV . "}prop"; 95 | /** @var string */ 96 | public const HREF = "{" . self::NSDAV . "}href"; 97 | /** @var string */ 98 | public const LIMIT = "{" . self::NSCARDDAV . "}limit"; 99 | /** @var string */ 100 | public const NRESULTS = "{" . self::NSCARDDAV . "}nresults"; 101 | /** @var string */ 102 | public const SUPPORTED_FILTER = "{" . self::NSCARDDAV . "}supported-filter"; 103 | /** @var string */ 104 | public const FILTER = "{" . self::NSCARDDAV . "}filter"; 105 | /** @var string */ 106 | public const PROPFILTER = "{" . self::NSCARDDAV . "}prop-filter"; 107 | /** @var string */ 108 | public const PARAMFILTER = "{" . self::NSCARDDAV . "}param-filter"; 109 | /** @var string */ 110 | public const TEXTMATCH = "{" . self::NSCARDDAV . "}text-match"; 111 | /** @var string */ 112 | public const ISNOTDEFINED = "{" . self::NSCARDDAV . "}is-not-defined"; 113 | } 114 | 115 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 116 | -------------------------------------------------------------------------------- /src/XmlElements/Filter.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\CardDavClient\XmlElements; 13 | 14 | use MStilkerich\CardDavClient\XmlElements\ElementNames as XmlEN; 15 | use MStilkerich\CardDavClient\Exception\XmlParseException; 16 | 17 | /** 18 | * Represents XML urn:ietf:params:xml:ns:carddav:filter elements as PHP objects (RFC 6352). 19 | * 20 | * From RFC 6352: 21 | * The "filter" element specifies the search filter used to match address objects that should be returned by a report. 22 | * The "test" attribute specifies whether any (logical OR) or all (logical AND) of the prop-filter tests need to match 23 | * in order for the overall filter to match. 24 | * 25 | * 26 | * 27 | * 30 | * 31 | * @psalm-type PropName = string 32 | * @psalm-type NotDefined = null 33 | * @psalm-type TextMatchSpec = string 34 | * @psalm-type ParamFilterSpec = array{string, NotDefined | TextMatchSpec} 35 | * 36 | * @psalm-type SimpleCondition = NotDefined|TextMatchSpec|ParamFilterSpec 37 | * @psalm-type SimpleConditions = array 38 | * @psalm-type ComplexCondition = array{matchAll?: bool} & array 39 | * @psalm-type ComplexConditions = list 40 | * 41 | * @package Internal\XmlElements 42 | */ 43 | class Filter implements \Sabre\Xml\XmlSerializable 44 | { 45 | /** 46 | * Semantics of match for multiple conditions (AND or OR). 47 | * @psalm-var 'anyof'|'allof' 48 | * @var string 49 | * @psalm-readonly 50 | */ 51 | public $testType; 52 | 53 | /** 54 | * The PropFilter child elements of this filter. 55 | * @psalm-var list 56 | * @var array 57 | * @psalm-readonly 58 | */ 59 | public $propFilters = []; 60 | 61 | /** 62 | * Constructs a Filter consisting of zero or more PropFilter elements. 63 | * 64 | * For ease of use, the $conditions parameter can take a simple form, which allows exactly one match criterion per 65 | * VCard property. Or it can take a more elaborate form where for each property, _several_ lists of match criteria 66 | * can be defined. 67 | * 68 | * Note that property names can be prefixed with a group name like "GROUP.EMAIL" to only match properties that 69 | * belong to the given group. If no group prefix is given, the match applies to all properties of the type, 70 | * independent of whether they belong to a group or not. 71 | * 72 | * __Simple form__ 73 | * 74 | * The simple form is an associative array mapping property names to null or a filter condition. 75 | * 76 | * A filter condition can either be a string with a text match specification (see TextMatch constructor for format) 77 | * or a two-element array{string,?string} where the first element is the name of a parameter and the second is a 78 | * string for TextMatch or null with a meaning as for a property filter. 79 | * 80 | * Examples for the simple form: 81 | * - `['EMAIL' => null]`: Matches all VCards that do NOT have an EMAIL property 82 | * - `['EMAIL' => "//"]`: Matches all VCards that DO have an EMAIL property (with any value) 83 | * - `['EMAIL' => '/@example.com/$']`: 84 | * Matches all VCards that have an EMAIL property with an email address of the example.com domain 85 | * - `['EMAIL' => '/@example.com/$', 'N' => '/Mustermann;/^']`: 86 | * Like before, but additionally/alternatively the surname must be Mustermann (depending on $matchAll) 87 | * - `['EMAIL' => ['TYPE' => '/home/=']]`: 88 | * Matches all VCards with an EMAIL property that has a TYPE parameter with value home 89 | * 90 | * __Elaborate form__ 91 | * 92 | * The more elaborate form is an array of two-element arrays where the first element is a property name and 93 | * the second element is any of the values possible in the simple form, or an array object with a list of 94 | * conditions of which all/any need to apply, plus an optional key "matchAll" that can be set to true to indicate 95 | * that all conditions need to match (AND semantics). 96 | * 97 | * Examples for the elaborate form: 98 | * - `[['EMAIL', ['/@example.com/$', ['TYPE', '/home/='], 'matchAll' => true]], ['N', '/Mustermann;/^']]`: 99 | * Matches all VCards, that have an EMAIL property with an address in the domain example.com and at the same 100 | * time a TYPE parameter with value home, and/or an N property with a surname of Mustermann. 101 | * 102 | * It is also possible to mix both forms, where string keys are used for the simple form and numeric indexes are 103 | * used for the elaborate form filters. 104 | * 105 | * @psalm-param SimpleConditions|ComplexConditions $conditions 106 | * @param array $conditions 107 | * The match conditions for the query, or for one property filter. An empty array will cause all VCards to match. 108 | * @param bool $matchAll Whether all or any of the conditions needs to match. 109 | */ 110 | public function __construct(array $conditions, bool $matchAll) 111 | { 112 | $this->testType = $matchAll ? 'allof' : 'anyof'; 113 | 114 | foreach ($conditions as $idx => $condition) { 115 | if (is_string($idx)) { 116 | // simple form - single condition only 117 | /** @psalm-var SimpleCondition $condition */ 118 | $this->propFilters[] = new PropFilter($idx, [$condition]); 119 | } elseif (is_array($condition) && count($condition) == 2) { 120 | // elaborate form [ property name, list of simple conditions ] 121 | [ $propname, $simpleConditions ] = $condition; 122 | /** @psalm-var ComplexCondition $simpleConditions */ 123 | $this->propFilters[] = new PropFilter($propname, $simpleConditions); 124 | } else { 125 | throw new \InvalidArgumentException("Invalid complex condition: " . var_export($condition, true)); 126 | } 127 | } 128 | } 129 | 130 | /** 131 | * This function encodes the element's value (not the element itself!) to the given XML writer. 132 | */ 133 | public function xmlSerialize(\Sabre\Xml\Writer $writer): void 134 | { 135 | foreach ($this->propFilters as $propFilter) { 136 | $writer->write([ 137 | 'name' => XmlEN::PROPFILTER, 138 | 'attributes' => $propFilter->xmlAttributes(), 139 | 'value' => $propFilter 140 | ]); 141 | } 142 | } 143 | 144 | /** 145 | * Produces a list of attributes for this filter suitable to pass to a Sabre XML Writer. 146 | * 147 | * The attributes produced are: 148 | * - `test="allof/anyof"` 149 | * 150 | * @return array A list of attributes (attrname => attrvalue) 151 | */ 152 | public function xmlAttributes(): array 153 | { 154 | return [ 'test' => $this->testType ]; 155 | } 156 | } 157 | 158 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 159 | -------------------------------------------------------------------------------- /src/XmlElements/Multistatus.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\CardDavClient\XmlElements; 13 | 14 | use MStilkerich\CardDavClient\XmlElements\ElementNames as XmlEN; 15 | use MStilkerich\CardDavClient\Exception\XmlParseException; 16 | 17 | /** 18 | * Represents XML DAV:multistatus elements as PHP objects (RFC 4918). 19 | * 20 | * The response child elements can be of two types, response elements containing a propstat ({@see ResponsePropstat}) or 21 | * reponse elements containing a status (@{see ResponseStatus}). Depending on the request, either on response type is 22 | * expected or a mixture of both is possible. This class has a template parameter that allows to define the specific 23 | * expected response type. 24 | * 25 | * From RFC 4918: 26 | * The ’multistatus’ root element holds zero or more ’response’ elements in any order, each with information about an 27 | * individual resource. 28 | * 29 | * RFC 6578 adds the sync-token child element: 30 | * ```xml 31 | * 32 | * ``` 33 | * 34 | * @psalm-immutable 35 | * @template RT of Response 36 | * 37 | * @psalm-import-type DeserializedElem from Deserializers 38 | * 39 | * @package Internal\XmlElements 40 | */ 41 | class Multistatus implements \Sabre\Xml\XmlDeserializable 42 | { 43 | /** 44 | * The optional sync-token child element of this multistatus. 45 | * @var ?string $synctoken 46 | */ 47 | public $synctoken; 48 | 49 | /** 50 | * The reponse children of this multistatus element. 51 | * @psalm-var list 52 | * @var array 53 | */ 54 | public $responses = []; 55 | 56 | /** 57 | * @psalm-param list $responses 58 | * @param array $responses 59 | * @param ?string $synctoken 60 | */ 61 | public function __construct(array $responses, ?string $synctoken) 62 | { 63 | $this->responses = $responses; 64 | $this->synctoken = $synctoken; 65 | } 66 | 67 | /** 68 | * Deserializes the child elements of a DAV:multistatus element and creates a new instance of Multistatus. 69 | */ 70 | public static function xmlDeserialize(\Sabre\Xml\Reader $reader): Multistatus 71 | { 72 | $responses = []; 73 | $synctoken = null; 74 | 75 | $children = $reader->parseInnerTree(); 76 | if (is_array($children)) { 77 | /** @psalm-var DeserializedElem $child */ 78 | foreach ($children as $child) { 79 | if ($child["value"] instanceof Response) { 80 | $responses[] = $child["value"]; 81 | } elseif ($child["name"] === XmlEN::SYNCTOKEN) { 82 | if (is_string($child["value"])) { 83 | $synctoken = $child["value"]; 84 | } 85 | } 86 | } 87 | } 88 | 89 | return new self($responses, $synctoken); 90 | } 91 | } 92 | 93 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 94 | -------------------------------------------------------------------------------- /src/XmlElements/ParamFilter.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\CardDavClient\XmlElements; 13 | 14 | use MStilkerich\CardDavClient\XmlElements\ElementNames as XmlEN; 15 | use MStilkerich\CardDavClient\Exception\XmlParseException; 16 | 17 | /** 18 | * Represents XML urn:ietf:params:xml:ns:carddav:param-filter elements as PHP objects (RFC 6352). 19 | * 20 | * From RFC 6352: 21 | * The CARDDAV:param-filter XML element specifies search criteria on a specific vCard property parameter (e.g., TYPE) in 22 | * the scope of a given CARDDAV:prop-filter. A vCard property is said to match a CARDDAV:param-filter if: 23 | * - A parameter of the type specified by the "name" attribute exists, and the CARDDAV:param-filter is empty, or it 24 | * matches the CARDDAV:text-match conditions if specified. 25 | * or: 26 | * - A parameter of the type specified by the "name" attribute does not exist, and the CARDDAV:is-not-defined element 27 | * is specified. 28 | * 29 | * ```xml 30 | * 31 | * 32 | * 33 | * ``` 34 | * 35 | * @package Internal\XmlElements 36 | */ 37 | class ParamFilter implements \Sabre\Xml\XmlSerializable 38 | { 39 | /** 40 | * Parameter this filter matches on (e.g. TYPE). 41 | * @var string 42 | * @psalm-readonly 43 | */ 44 | public $param; 45 | 46 | /** 47 | * Filter condition. Null to match if the parameter is not defined. 48 | * @var ?TextMatch 49 | * @psalm-readonly 50 | */ 51 | public $filter; 52 | 53 | /** 54 | * Constructs a ParamFilter element. 55 | * 56 | * @param string $param The name of the parameter to match for 57 | * @param ?string $matchSpec 58 | * The match specifier. Null to match for non-existence of the parameter, otherwise a match specifier for 59 | * {@see TextMatch}. 60 | */ 61 | public function __construct(string $param, ?string $matchSpec) 62 | { 63 | $this->param = $param; 64 | 65 | if (isset($matchSpec)) { 66 | $this->filter = new TextMatch($matchSpec); 67 | } 68 | } 69 | 70 | /** 71 | * This function encodes the element's value (not the element itself!) to the given XML writer. 72 | */ 73 | public function xmlSerialize(\Sabre\Xml\Writer $writer): void 74 | { 75 | if (isset($this->filter)) { 76 | $this->filter->xmlSerializeElement($writer); 77 | } else { 78 | $writer->write([XmlEN::ISNOTDEFINED => null]); 79 | } 80 | } 81 | 82 | /** 83 | * This function serializes the full element to the given XML writer. 84 | */ 85 | public function xmlSerializeElement(\Sabre\Xml\Writer $writer): void 86 | { 87 | $writer->write([ 88 | 'name' => XmlEN::PARAMFILTER, 89 | 'attributes' => $this->xmlAttributes(), 90 | 'value' => $this 91 | ]); 92 | } 93 | 94 | /** 95 | * Produces a list of attributes for this filter suitable to pass to a Sabre XML Writer. 96 | * 97 | * @return array A list of attributes (attrname => attrvalue) 98 | */ 99 | public function xmlAttributes(): array 100 | { 101 | return [ 'name' => $this->param ]; 102 | } 103 | } 104 | 105 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 106 | -------------------------------------------------------------------------------- /src/XmlElements/Prop.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\CardDavClient\XmlElements; 13 | 14 | use MStilkerich\CardDavClient\Config; 15 | use MStilkerich\CardDavClient\XmlElements\ElementNames as XmlEN; 16 | 17 | /** 18 | * Represents XML DAV:prop elements as PHP objects. 19 | * 20 | * @psalm-import-type DeserializedElem from Deserializers 21 | * 22 | * @psalm-type PropTypes = array{ 23 | * '{DAV:}add-member'?: string, 24 | * '{DAV:}current-user-principal'?: string, 25 | * '{DAV:}getetag'?: string, 26 | * '{DAV:}resourcetype'?: list, 27 | * '{DAV:}supported-report-set'?: list, 28 | * '{DAV:}sync-token'?: string, 29 | * '{DAV:}displayname'?: string, 30 | * '{urn:ietf:params:xml:ns:carddav}supported-address-data'?: list, 31 | * '{http://calendarserver.org/ns/}getctag'?: string, 32 | * '{urn:ietf:params:xml:ns:carddav}address-data'?: string, 33 | * '{urn:ietf:params:xml:ns:carddav}addressbook-description'?: string, 34 | * '{urn:ietf:params:xml:ns:carddav}max-resource-size'?: int, 35 | * '{urn:ietf:params:xml:ns:carddav}addressbook-home-set'?: list, 36 | * } 37 | * 38 | * @package Internal\XmlElements 39 | */ 40 | class Prop implements \Sabre\Xml\XmlDeserializable 41 | { 42 | /* Currently used properties and types 43 | * 44 | * Contains child elements where we are interested in the element names: 45 | * XmlEN::RESTYPE - Contains one child element per resource type, e.g. 46 | * XmlEN::SUPPORTED_REPORT_SET - Contains supported-report elements 47 | * - XmlEN::SUPPORTED_REPORT - Contains report elements 48 | * - XmlEN::REPORT - Contains a child element that indicates the report, e.g. 49 | * 50 | * Contains one or more hrefs: 51 | * XmlEN::ADD_MEMBER - Contains one href child element 52 | * XmlEN::CURUSRPRINC - Contains one href child element (might also contain an unauthenticated element instead) 53 | * XmlEN::ABOOK_HOME - Contains one or more href child elements 54 | * 55 | * Contains string value: 56 | * XmlEN::ABOOK_DESC - Contains addressbook description as string 57 | * XmlEN::ADDRDATA - When part of a REPORT response (our use case), contains the address object data as string 58 | * XmlEN::DISPNAME - Contains resource displayname as string 59 | * XmlEN::GETCTAG - Contains the CTag as string 60 | * XmlEN::GETETAG - Contains the ETag as string 61 | * XmlEN::SYNCTOKEN - Contains the sync-token as string 62 | * 63 | * Contains numeric string value: 64 | * XmlEN::MAX_RESSIZE - Contains maximum size of an address object resource as a numeric string (positive int) 65 | * 66 | * XmlEN::SUPPORTED_ADDRDATA - Address object formats supported by server. Contains address-data-type elements with 67 | * attributes content-type and version 68 | */ 69 | 70 | /** 71 | * Deserializers for various child elements of prop. 72 | * 73 | * @psalm-var array> 74 | * @var array 75 | */ 76 | public const PROP_DESERIALIZERS = [ 77 | XmlEN::ABOOK_HOME => [ Deserializers::class, 'deserializeHrefMulti' ], 78 | XmlEN::ADD_MEMBER => [ Deserializers::class, 'deserializeHrefSingle' ], 79 | XmlEN::CURUSRPRINC => [ Deserializers::class, 'deserializeHrefSingle' ], 80 | XmlEN::RESTYPE => '\Sabre\Xml\Deserializer\enum', 81 | XmlEN::SUPPORTED_REPORT_SET => [ Deserializers::class, 'deserializeSupportedReportSet' ], 82 | XmlEN::SUPPORTED_ADDRDATA => [ Deserializers::class, 'deserializeSupportedAddrData' ], 83 | XmlEN::ADDRDATATYPE => [ Deserializers::class, 'deserializeToAttributes' ], 84 | ]; 85 | 86 | /** 87 | * The child elements of this Prop element. 88 | * Maps child element name to a child-element specific value. 89 | * @psalm-var PropTypes 90 | * @var array 91 | */ 92 | public $props = []; 93 | 94 | /** 95 | * Deserializes the child elements of a DAV:prop element and creates a new instance of Prop. 96 | */ 97 | public static function xmlDeserialize(\Sabre\Xml\Reader $reader) 98 | { 99 | $prop = new self(); 100 | $children = $reader->parseInnerTree(); 101 | if (is_array($children)) { 102 | /** @psalm-var DeserializedElem $child */ 103 | foreach ($children as $child) { 104 | $prop->storeProperty($child); 105 | } 106 | } 107 | return $prop; 108 | } 109 | 110 | /** 111 | * Processes a deserialized prop child element. 112 | * 113 | * If the child element is known to this class, the deserialized value is stored to {@see Prop::$props}. 114 | * 115 | * @psalm-param DeserializedElem $deserElem 116 | * @param array $deserElem 117 | */ 118 | private function storeProperty(array $deserElem): void 119 | { 120 | $name = $deserElem["name"]; 121 | $err = false; 122 | 123 | if (!isset($deserElem["value"])) { 124 | return; 125 | } 126 | 127 | switch ($name) { 128 | // Elements where content is a string 129 | case XmlEN::ADD_MEMBER: 130 | case XmlEN::CURUSRPRINC: 131 | case XmlEN::ABOOK_DESC: 132 | case XmlEN::ADDRDATA: 133 | case XmlEN::DISPNAME: 134 | case XmlEN::GETCTAG: 135 | case XmlEN::GETETAG: 136 | case XmlEN::SYNCTOKEN: 137 | if (is_string($deserElem["value"])) { 138 | $this->props[$name] = $deserElem["value"]; 139 | } else { 140 | $err = true; 141 | } 142 | break; 143 | 144 | case XmlEN::MAX_RESSIZE: 145 | if (is_string($deserElem["value"]) && preg_match("/^\d+$/", $deserElem["value"])) { 146 | $this->props[$name] = intval($deserElem["value"]); 147 | } else { 148 | $err = true; 149 | } 150 | break; 151 | 152 | // Elements where content is a list of strings 153 | case XmlEN::ABOOK_HOME: 154 | case XmlEN::RESTYPE: 155 | case XmlEN::SUPPORTED_REPORT_SET: 156 | if (is_array($deserElem["value"])) { 157 | $strings = []; 158 | foreach (array_keys($deserElem["value"]) as $i) { 159 | if (is_string($deserElem["value"][$i])) { 160 | $strings[] = $deserElem["value"][$i]; 161 | } else { 162 | $err = true; 163 | } 164 | } 165 | $this->props[$name] = $strings; 166 | } else { 167 | $err = true; 168 | } 169 | break; 170 | 171 | // Special handling 172 | case XmlEN::SUPPORTED_ADDRDATA: 173 | if (!isset($this->props[XmlEN::SUPPORTED_ADDRDATA])) { 174 | $this->props[XmlEN::SUPPORTED_ADDRDATA] = []; 175 | } 176 | 177 | if (is_array($deserElem["value"])) { 178 | foreach (array_keys($deserElem["value"]) as $i) { 179 | if (is_array($deserElem["value"][$i])) { 180 | $addrDataXml = $deserElem["value"][$i]; 181 | $addrData = [ 'content-type' => 'text/vcard', 'version' => '3.0' ]; // defaults 182 | foreach (['content-type', 'version'] as $a) { 183 | if (isset($addrDataXml[$a]) && is_string($addrDataXml[$a])) { 184 | $addrData[$a] = $addrDataXml[$a]; 185 | } 186 | } 187 | $this->props[XmlEN::SUPPORTED_ADDRDATA][] = $addrData; 188 | } 189 | } 190 | } else { 191 | $err = true; 192 | } 193 | break; 194 | 195 | default: 196 | $err = true; 197 | break; 198 | } 199 | 200 | if ($err) { 201 | Config::$logger->warning( 202 | "Ignoring unexpected content for property $name: " . print_r($deserElem["value"], true) 203 | ); 204 | } 205 | } 206 | } 207 | 208 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 209 | -------------------------------------------------------------------------------- /src/XmlElements/PropFilter.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\CardDavClient\XmlElements; 13 | 14 | use MStilkerich\CardDavClient\XmlElements\ElementNames as XmlEN; 15 | use MStilkerich\CardDavClient\Exception\XmlParseException; 16 | 17 | /** 18 | * Represents XML urn:ietf:params:xml:ns:carddav:prop-filter elements as PHP objects (RFC 6352). 19 | * 20 | * From RFC 6352: 21 | * The CARDDAV:prop-filter XML element specifies search criteria on a specific vCard property (e.g., "NICKNAME"). An 22 | * address object is said to match a CARDDAV:prop-filter if: 23 | * - A vCard property of the type specified by the "name" attribute exists, and the CARDDAV:prop-filter is empty, or 24 | * it matches any specified CARDDAV:text-match or CARDDAV:param-filter conditions. The "test" attribute specifies 25 | * whether any (logical OR) or all (logical AND) of the text-filter and param- filter tests need to match in order 26 | * for the overall filter to match. 27 | * Or: 28 | * - A vCard property of the type specified by the "name" attribute does not exist, and the CARDDAV:is-not-defined 29 | * element is specified. 30 | * 31 | * vCard allows a "group" prefix to appear before a property name in the vCard data. When the "name" attribute does not 32 | * specify a group prefix, it MUST match properties in the vCard data without a group prefix or with any group prefix. 33 | * When the "name" attribute includes a group prefix, it MUST match properties that have exactly the same group prefix 34 | * and name. For example, a "name" set to "TEL" will match "TEL", "X-ABC.TEL", "X-ABC-1.TEL" vCard properties. A 35 | * "name" set to "X-ABC.TEL" will match an "X-ABC.TEL" vCard property only, it will not match "TEL" or "X-ABC-1.TEL". 36 | * 37 | * ```xml 38 | * 39 | * 41 | * 45 | * ``` 46 | * @psalm-import-type ComplexCondition from Filter 47 | * 48 | * @package Internal\XmlElements 49 | */ 50 | class PropFilter implements \Sabre\Xml\XmlSerializable 51 | { 52 | /** 53 | * Semantics of match for multiple conditions (AND or OR). 54 | * 55 | * @psalm-var 'anyof'|'allof' 56 | * @var string 57 | * @psalm-readonly 58 | */ 59 | public $testType = 'anyof'; 60 | 61 | /** 62 | * Property this filter matches on (e.g. EMAIL), including optional group prefix (e.g. G1.EMAIL). 63 | * @var string 64 | * @psalm-readonly 65 | */ 66 | public $property; 67 | 68 | /** 69 | * List of filter conditions. Null to match if the property is not defined. 70 | * @psalm-var null|list 71 | * @var null|array 72 | * @psalm-readonly 73 | */ 74 | public $conditions; 75 | 76 | /** 77 | * Constructs a PropFilter element. 78 | * 79 | * The $conditions parameter is an array of all the filter conditions for this property filter. An empty array 80 | * causes the filter to always match. Otherwise, the $conditions array has entries according to the ComplexFilter / 81 | * elaborate form described in the {@see Filter} class. 82 | * 83 | * @param string $propname The name of the VCard property this filter matches on. 84 | * @psalm-param ComplexCondition $conditions 85 | * @param array $conditions The match conditions for the property 86 | */ 87 | public function __construct(string $propname, array $conditions) 88 | { 89 | if (strlen($propname) > 0) { 90 | $this->property = $propname; 91 | } else { 92 | throw new \InvalidArgumentException("Property name must be a non-empty string"); 93 | } 94 | 95 | if ($conditions["matchAll"] ?? false) { 96 | $this->testType = 'allof'; 97 | } 98 | 99 | foreach ($conditions as $idx => $condition) { 100 | if (is_string($idx)) { // matchAll 101 | continue; 102 | } 103 | 104 | if (isset($condition)) { 105 | if (is_array($condition)) { 106 | // param filter 107 | if (count($condition) == 2) { 108 | [ $paramname, $paramcond ] = $condition; 109 | $this->conditions[] = new ParamFilter($paramname, $paramcond); 110 | } else { 111 | throw new \InvalidArgumentException( 112 | "Param filter on property $propname must be an element of two entries" . 113 | var_export($condition, true) 114 | ); 115 | } 116 | } elseif (is_string($condition)) { 117 | // text match filter 118 | $this->conditions[] = new TextMatch($condition); 119 | } else { 120 | throw new \InvalidArgumentException( 121 | "Invalid condition for property $propname: " . var_export($condition, true) 122 | ); 123 | } 124 | } else { 125 | // is-not-defined filter 126 | if (count($conditions) > 1) { 127 | throw new \InvalidArgumentException( 128 | "PropFilter on $propname can have ONE not-defined (null) OR several match conditions: " . 129 | var_export($conditions, true) 130 | ); 131 | } 132 | $this->conditions = null; 133 | break; 134 | } 135 | } 136 | } 137 | 138 | /** 139 | * This function encodes the element's value (not the element itself!) to the given XML writer. 140 | */ 141 | public function xmlSerialize(\Sabre\Xml\Writer $writer): void 142 | { 143 | if (isset($this->conditions)) { 144 | foreach ($this->conditions as $condition) { 145 | // either ParamFilter or TextMatch 146 | $condition->xmlSerializeElement($writer); 147 | } 148 | } else { 149 | $writer->write([XmlEN::ISNOTDEFINED => null]); 150 | } 151 | } 152 | 153 | /** 154 | * Produces a list of attributes for this filter suitable to pass to a Sabre XML Writer. 155 | * 156 | * @return array A list of attributes (attrname => attrvalue) 157 | */ 158 | public function xmlAttributes(): array 159 | { 160 | return [ 'name' => $this->property, 'test' => $this->testType ]; 161 | } 162 | } 163 | 164 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 165 | -------------------------------------------------------------------------------- /src/XmlElements/Propstat.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\CardDavClient\XmlElements; 13 | 14 | use MStilkerich\CardDavClient\XmlElements\ElementNames as XmlEN; 15 | use MStilkerich\CardDavClient\Exception\XmlParseException; 16 | 17 | /** 18 | * Represents XML DAV:propstat elements as PHP objects. 19 | * 20 | * From RFC 4918: 21 | * The propstat XML element MUST contain one prop XML element and one status XML element. The contents of the prop XML 22 | * element MUST only list the names of properties to which the result in the status element applies. The optional 23 | * precondition/ postcondition element and ’responsedescription’ text also apply to the properties named in ’prop’. 24 | * 25 | * ```xml 26 | * 27 | * ``` 28 | * 29 | * @psalm-immutable 30 | * 31 | * @psalm-import-type DeserializedElem from Deserializers 32 | * 33 | * @package Internal\XmlElements 34 | */ 35 | class Propstat implements \Sabre\Xml\XmlDeserializable 36 | { 37 | /** 38 | * Holds a single HTTP status-line. 39 | * @var string 40 | */ 41 | public $status; 42 | 43 | /** 44 | * Contains properties related to a resource. 45 | * @var Prop 46 | */ 47 | public $prop; 48 | 49 | /** 50 | * Constructs a Propstat element. 51 | * 52 | * @param string $status The status value of the Propstat element. 53 | * @param Prop $prop The Prop child element, containing the reported properties. 54 | */ 55 | public function __construct(string $status, Prop $prop) 56 | { 57 | $this->status = $status; 58 | $this->prop = $prop; 59 | } 60 | 61 | /** 62 | * Deserializes the child elements of a DAV:propstat element and creates a new instance of Propstat. 63 | */ 64 | public static function xmlDeserialize(\Sabre\Xml\Reader $reader): Propstat 65 | { 66 | $prop = null; 67 | $status = null; 68 | 69 | $children = $reader->parseInnerTree(); 70 | if (is_array($children)) { 71 | /** @psalm-var DeserializedElem $child */ 72 | foreach ($children as $child) { 73 | if ($child["value"] instanceof Prop) { 74 | if (isset($prop)) { 75 | throw new XmlParseException("DAV:propstat element contains multiple DAV:prop children"); 76 | } 77 | $prop = $child["value"]; 78 | } elseif (strcasecmp($child["name"], XmlEN::STATUS) == 0) { 79 | if (isset($status)) { 80 | throw new XmlParseException("DAV:propstat element contains multiple DAV:status children"); 81 | } 82 | 83 | if (is_string($child["value"])) { 84 | $status = $child["value"]; 85 | } 86 | } 87 | } 88 | } 89 | 90 | if (!isset($status) || !isset($prop)) { 91 | throw new XmlParseException("DAV:propstat element must have ONE DAV:status and one DAV:prop child"); 92 | } 93 | 94 | return new self($status, $prop); 95 | } 96 | } 97 | 98 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 99 | -------------------------------------------------------------------------------- /src/XmlElements/Response.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\CardDavClient\XmlElements; 13 | 14 | use MStilkerich\CardDavClient\XmlElements\ElementNames as XmlEN; 15 | use MStilkerich\CardDavClient\Exception\XmlParseException; 16 | 17 | /** 18 | * Represents XML DAV:response elements as PHP objects. 19 | * 20 | * From RFC 4918: 21 | * Each ’response’ element MUST have an ’href’ element to identify the resource. 22 | * A Multi-Status response uses one out of two distinct formats for representing the status: 23 | * 24 | * 1. A ’status’ element as child of the ’response’ element indicates the status of the message execution for the 25 | * identified resource as a whole (for instance, see Section 9.6.2). Some method definitions provide information about 26 | * specific status codes clients should be prepared to see in a response. However, clients MUST be able to handle other 27 | * status codes, using the generic rules defined in Section 10 of [RFC2616]. 28 | * 29 | * 2. For PROPFIND and PROPPATCH, the format has been extended using the ’propstat’ element instead of ’status’, 30 | * providing information about individual properties of a resource. This format is specific to PROPFIND and PROPPATCH, 31 | * and is described in detail in Sections 9.1 and 9.2. 32 | * 33 | * The ’href’ element contains an HTTP URL pointing to a WebDAV resource when used in the ’response’ container. A 34 | * particular ’href’ value MUST NOT appear more than once as the child of a ’response’ XML element under a ’multistatus’ 35 | * XML element. This requirement is necessary in order to keep processing costs for a response to linear time. 36 | * 37 | * Essentially, this prevents having to search in order to group together all the responses by ’href’. There are, 38 | * however, no requirements regarding ordering based on ’href’ values. The optional precondition/postcondition element 39 | * and ’responsedescription’ text can provide additional information about this resource relative to the request or 40 | * result. 41 | * 42 | * ```xml 43 | * 44 | * ``` 45 | * 46 | * @psalm-immutable 47 | * 48 | * @psalm-import-type DeserializedElem from Deserializers 49 | * 50 | * @package Internal\XmlElements 51 | */ 52 | abstract class Response implements \Sabre\Xml\XmlDeserializable 53 | { 54 | /** 55 | * Deserializes the child elements of a DAV:response element and creates a new instance of the proper subclass of 56 | * Response. 57 | */ 58 | public static function xmlDeserialize(\Sabre\Xml\Reader $reader): Response 59 | { 60 | $hrefs = []; 61 | $propstat = []; 62 | $status = null; 63 | 64 | $children = $reader->parseInnerTree(); 65 | if (is_array($children)) { 66 | /** @psalm-var DeserializedElem $child */ 67 | foreach ($children as $child) { 68 | if ($child["value"] instanceof Propstat) { 69 | $propstat[] = $child["value"]; 70 | } elseif (strcasecmp($child["name"], XmlEN::HREF) == 0) { 71 | if (is_string($child["value"])) { 72 | $hrefs[] = $child["value"]; 73 | } 74 | } elseif (strcasecmp($child["name"], XmlEN::STATUS) == 0) { 75 | if (isset($status)) { 76 | throw new XmlParseException("DAV:response contains multiple DAV:status children"); 77 | } 78 | 79 | if (is_string($child["value"])) { 80 | $status = $child["value"]; 81 | } 82 | } 83 | } 84 | } 85 | 86 | if (count($hrefs) == 0) { 87 | throw new XmlParseException("DAV:response contains no DAV:href child"); 88 | } 89 | 90 | /* By RFC 6578, there must be either a status OR a propstat child element. 91 | * 92 | * In practice however, we see the following uncompliances: 93 | * 94 | * Sabre/DAV always adds a propstat member, so for a 404 status, we will get an additional propstat with a 95 | * pseudo status HTTP/1.1 418 I'm a teapot. 96 | * 97 | * SOGO on the other hand adds a status for answers where only a propstat is expected (new or changed items). 98 | * 99 | * To enable interoperability, we apply the following heuristic: 100 | * 101 | * 1) If we have a 404 status child element -> ResponseStatus 102 | * 2) If we have a propstat element -> ResponsePropstat 103 | * 3) If we have a status -> ResponseStatus 104 | * 4) Error 105 | */ 106 | if (isset($status) && (stripos($status, " 404 ") !== false)) { 107 | // Disable this exception for now as Sabre/DAV always inserts a propstat element to a response element 108 | //if (count($propstat) > 0) { 109 | // throw new XmlParseException("DAV:response contains both DAV:status and DAV:propstat children"); 110 | //} 111 | 112 | return new ResponseStatus($hrefs, $status); 113 | } elseif (count($propstat) > 0) { 114 | if (count($hrefs) > 1) { 115 | throw new XmlParseException("Propstat-type DAV:response contains multiple DAV:href children"); 116 | } 117 | 118 | return new ResponsePropstat($hrefs[0], $propstat); 119 | } elseif (isset($status)) { 120 | return new ResponseStatus($hrefs, $status); 121 | } 122 | 123 | throw new XmlParseException("DAV:response contains neither DAV:status nor DAV:propstat children"); 124 | } 125 | } 126 | 127 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 128 | -------------------------------------------------------------------------------- /src/XmlElements/ResponsePropstat.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\CardDavClient\XmlElements; 13 | 14 | use MStilkerich\CardDavClient\XmlElements\ElementNames as XmlEN; 15 | use MStilkerich\CardDavClient\Exception\XmlParseException; 16 | 17 | /** 18 | * Represents XML DAV:response elements with propstat children as PHP objects. 19 | * 20 | * @psalm-immutable 21 | * 22 | * @package Internal\XmlElements 23 | */ 24 | class ResponsePropstat extends Response 25 | { 26 | /** 27 | * URI the response applies to. MUST contain a URI or a relative reference. 28 | * @var string 29 | */ 30 | public $href; 31 | 32 | /** 33 | * Propstat child elements. 34 | * @psalm-var list 35 | * @var array 36 | */ 37 | public $propstat; 38 | 39 | /** 40 | * Constructs a new ResponsePropstat element. 41 | * 42 | * @param string $href URI the response applies to 43 | * @psalm-param list $propstat 44 | * @param array $propstat Propstat child elements 45 | */ 46 | public function __construct(string $href, array $propstat) 47 | { 48 | $this->href = $href; 49 | $this->propstat = $propstat; 50 | } 51 | } 52 | 53 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 54 | -------------------------------------------------------------------------------- /src/XmlElements/ResponseStatus.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\CardDavClient\XmlElements; 13 | 14 | use MStilkerich\CardDavClient\XmlElements\ElementNames as XmlEN; 15 | use MStilkerich\CardDavClient\Exception\XmlParseException; 16 | 17 | /** 18 | * Represents XML DAV:response elements with status children as PHP objects. 19 | * 20 | * @psalm-immutable 21 | * 22 | * @package Internal\XmlElements 23 | */ 24 | class ResponseStatus extends Response 25 | { 26 | /** 27 | * URIs the status in this reponse applies to. MUST contain a URI or a relative reference. 28 | * @psalm-var list 29 | * @var array 30 | */ 31 | public $hrefs; 32 | 33 | /** 34 | * The HTTP status value of this response. 35 | * @var string 36 | */ 37 | public $status; 38 | 39 | /** 40 | * Constructs a new ResponseStatus object. 41 | * 42 | * @psalm-param list $hrefs 43 | * @param array $hrefs 44 | * @param string $status 45 | */ 46 | public function __construct(array $hrefs, string $status) 47 | { 48 | $this->hrefs = $hrefs; 49 | $this->status = $status; 50 | } 51 | } 52 | 53 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 54 | -------------------------------------------------------------------------------- /src/XmlElements/TextMatch.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\CardDavClient\XmlElements; 13 | 14 | use MStilkerich\CardDavClient\XmlElements\ElementNames as XmlEN; 15 | use MStilkerich\CardDavClient\Exception\XmlParseException; 16 | 17 | /** 18 | * Represents XML urn:ietf:params:xml:ns:carddav:text-match elements as PHP objects (RFC 6352). 19 | * 20 | * From RFC 6352: 21 | * The CARDDAV:text-match XML element specifies text used for a substring match against the vCard 22 | * property or parameter value specified in an address book REPORT request. 23 | * 24 | * The "collation" attribute is used to select the collation that the server MUST use for character string matching. In 25 | * the absence of this attribute, the server MUST use the "i;unicode-casemap" collation. 26 | * 27 | * The "negate-condition" attribute is used to indicate that this test returns a match if the text matches, when the 28 | * attribute value is set to "no", or return a match if the text does not match, if the attribute value is set to "yes". 29 | * For example, this can be used to match components with a CATEGORIES property not set to PERSON. 30 | * 31 | * The "match-type" attribute is used to indicate the type of match operation to use. Possible choices are: 32 | * - "equals" - an exact match to the target string 33 | * - "contains" - a substring match, matching anywhere within the target string 34 | * - "starts-with" - a substring match, matching only at the start of the target string 35 | * - "ends-with" - a substring match, matching only at the end of the target string 36 | * 37 | * ```xml 38 | * 39 | * 40 | * 44 | * ``` 45 | * @package Internal\XmlElements 46 | */ 47 | class TextMatch implements \Sabre\Xml\XmlSerializable 48 | { 49 | /** 50 | * Collation to use for comparison (currently constant) 51 | * @var string 52 | * @psalm-readonly 53 | */ 54 | public $collation = 'i;unicode-casemap'; 55 | 56 | /** 57 | * Whether to invert the result of the match 58 | * @var bool 59 | * @psalm-readonly 60 | */ 61 | public $invertMatch = false; 62 | 63 | /** 64 | * The type of text match to apply 65 | * @psalm-var 'equals' | 'contains' | 'starts-with' | 'ends-with' 66 | * @var string 67 | * @psalm-readonly 68 | */ 69 | public $matchType = 'contains'; 70 | 71 | /** 72 | * The string to match for 73 | * @var string 74 | * @psalm-readonly 75 | */ 76 | public $needle = ''; 77 | 78 | /** 79 | * Constructs a TextMatch element. 80 | * 81 | * The match is specified in a string form that encodes all properties of the match. 82 | * - The match string must be enclosed in / (e.g. `/foo/`) 83 | * - The / character has no special meaning other than to separate the match string from modifiers. No escaping is 84 | * needed if / appears as part of the match string (e.g. `/http:///` matches for "http://"). 85 | * - To invert the match, insert ! before the initial / (e.g. `!/foo/`) 86 | * - The default match type is "contains" semantics. If you want to match the start or end of the property value, 87 | * or perform an exact match, use the ^/$/= modifiers after the final slash. Examples: 88 | * - `/abc/=`: The property/parameter must match the value "abc" exactly 89 | * - `/abc/^`: The property/parameter must start with "abc" 90 | * - `/abc/$`: The property/parameter must end with "abc" 91 | * - `/abc/`: The property/parameter must contain "abc" 92 | * - The matching is performed case insensitive with UTF8 character set (this is currently not changeable). 93 | * 94 | * @param string $matchSpec Specification of the text match that encodes all properties of the match. 95 | */ 96 | public function __construct(string $matchSpec) 97 | { 98 | if (preg_match('/^(!?)\/(.*)\/([$=^]?)$/', $matchSpec, $matches)) { 99 | if (count($matches) === 4) { 100 | [ , $inv, $needle, $matchType ] = $matches; 101 | 102 | $this->invertMatch = ($inv == "!"); 103 | $this->needle = $needle; 104 | 105 | if ($matchType == '^') { 106 | $this->matchType = 'starts-with'; 107 | } elseif ($matchType == '$') { 108 | $this->matchType = 'ends-with'; 109 | } elseif ($matchType == '=') { 110 | $this->matchType = 'equals'; 111 | } else { 112 | $this->matchType = 'contains'; 113 | } 114 | 115 | return; 116 | } 117 | } 118 | 119 | throw new \InvalidArgumentException("Not a valid match specifier for TextMatch: $matchSpec"); 120 | } 121 | 122 | /** 123 | * This function encodes the element's value (not the element itself!) to the given XML writer. 124 | */ 125 | public function xmlSerialize(\Sabre\Xml\Writer $writer): void 126 | { 127 | $writer->write($this->needle); 128 | } 129 | 130 | /** 131 | * This function serializes the full element to the given XML writer. 132 | */ 133 | public function xmlSerializeElement(\Sabre\Xml\Writer $writer): void 134 | { 135 | if (strlen($this->needle) > 0) { 136 | $writer->write([ 137 | 'name' => XmlEN::TEXTMATCH, 138 | 'attributes' => $this->xmlAttributes(), 139 | 'value' => $this 140 | ]); 141 | } 142 | } 143 | 144 | /** 145 | * Produces a list of attributes for this filter suitable to pass to a Sabre XML Writer. 146 | * 147 | * @return array A list of attributes (attrname => attrvalue) 148 | */ 149 | public function xmlAttributes(): array 150 | { 151 | return [ 152 | 'negate-condition' => ($this->invertMatch ? 'yes' : 'no'), 153 | 'collation' => $this->collation, 154 | 'match-type' => $this->matchType 155 | ]; 156 | } 157 | } 158 | 159 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 160 | -------------------------------------------------------------------------------- /tests/Interop/AccountData.php.dist: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\Tests\CardDavClient\Interop; 13 | 14 | /** 15 | * @psalm-import-type TestAccount from TestInfrastructureSrv 16 | * @psalm-import-type TestAddressbook from TestInfrastructureSrv 17 | */ 18 | final class AccountData 19 | { 20 | /** @var array */ 21 | public const ACCOUNTS = [ 22 | /* 23 | "Google" => [ 24 | "username" => "%GOOGLE_USER%", 25 | "password" => "%GOOGLE_PASSWORD%", 26 | "discoveryUri" => "gmail.com", 27 | "syncAllowExtraChanges" => true, 28 | "featureSet" => TestInfrastructureSrv::SRVFEATS_GOOGLE, 29 | ], 30 | */ 31 | "iCloud" => [ 32 | "username" => "%ICLOUD_USER%", 33 | "password" => "%ICLOUD_PASSWORD%", 34 | "discoveryUri" => "icloud.com", 35 | 36 | // For providers that report extra changes or deleted cards between two syncs, set this to true to limit the 37 | // sync tests to check whether all known changes are actually reported, without raising an error on any 38 | // additional changes that the server reports. (Google has been observed to behave like this) 39 | "syncAllowExtraChanges" => true, 40 | 41 | // known/expected features 42 | "featureSet" => TestInfrastructureSrv::SRVFEATS_ICLOUD, 43 | ], 44 | "Davical" => [ 45 | "username" => "admin", 46 | "password" => "admin", 47 | "discoveryUri" => "http://localhost:8088/", 48 | "syncAllowExtraChanges" => false, 49 | "featureSet" => TestInfrastructureSrv::SRVFEATS_DAVICAL, 50 | ], 51 | "Nextcloud" => [ 52 | "username" => "ncadm", 53 | "password" => "ncadmPassw0rd", 54 | "discoveryUri" => "http://localhost:8080/remote.php/dav/", 55 | "syncAllowExtraChanges" => false, 56 | // Nextcloud 21 (oldest still supported) uses Sabre 4.1.4 which still contains the reported issues, so we 57 | // need to exclude some tests until 21 is EOL 58 | "featureSet" => TestInfrastructureSrv::SRVFEATS_SABRE, 59 | ], 60 | "Radicale" => [ 61 | "username" => "citest", 62 | "password" => "citest", 63 | "httpOptions" => [ 64 | "preemptive_basic_auth" => true, 65 | ], 66 | "discoveryUri" => "http://localhost:5232/", 67 | "syncAllowExtraChanges" => false, 68 | "featureSet" => TestInfrastructureSrv::SRVFEATS_RADICALE, 69 | ], 70 | "Owncloud" => [ 71 | "username" => "admin", 72 | "password" => "admin", 73 | "discoveryUri" => "http://localhost:8080/remote.php/dav/", 74 | "syncAllowExtraChanges" => false, 75 | // Owncloud as of 10.9 uses Sabre 4.2.0, which has the bugs reported for sabre/dav fixed 76 | "featureSet" => TestInfrastructureSrv::SRVFEATSONLY_SABRE, 77 | ], 78 | "Baikal" => [ 79 | "username" => "citest", 80 | "password" => "citest", 81 | "discoveryUri" => "http://localhost:8080/", 82 | "syncAllowExtraChanges" => false, 83 | // as of Baikal 0.8.0, the shipped Sabre/DAV version 4.1.4 still does not contain the fix for this bug 84 | // Bug is fixed in Sabre/DAV 4.1.5 85 | "featureSet" => TestInfrastructureSrv::SRVFEATS_SABRE, 86 | ], 87 | ]; 88 | 89 | /** @var array */ 90 | public const ADDRESSBOOKS = [ 91 | /* 92 | "Google" => [ 93 | "account" => "Google", 94 | "url" => "%GOOGLE_URL_ABOOK0%", 95 | "displayname" => "Address Book", 96 | "description" => 'My Contacts', 97 | ], 98 | */ 99 | "iCloud" => [ 100 | "account" => "iCloud", 101 | "url" => "%ICLOUD_URL_ABOOK0%", 102 | "displayname" => null, 103 | "description" => null, 104 | ], 105 | "Davical_0" => [ 106 | "account" => "Davical", 107 | "url" => "http://localhost:8088/caldav.php/admin/book1/", 108 | "displayname" => "Test addressbook", 109 | "description" => "Davical test addresses", 110 | ], 111 | "Nextcloud" => [ 112 | "account" => "Nextcloud", 113 | "url" => "http://localhost:8080/remote.php/dav/addressbooks/users/ncadm/contacts/", 114 | "displayname" => "Contacts", 115 | "description" => null, 116 | "readonly" => false, 117 | ], 118 | "Radicale_0" => [ 119 | "account" => "Radicale", 120 | "url" => "http://localhost:5232/citest/book1/", 121 | "displayname" => "Book 1", 122 | "description" => "First addressbook", 123 | ], 124 | "Radicale_1" => [ 125 | "account" => "Radicale", 126 | "url" => "http://localhost:5232/citest/book2/", 127 | "displayname" => "Book 2", 128 | "description" => "Second addressbook", 129 | "readonly" => true, 130 | ], 131 | "Owncloud" => [ 132 | "account" => "Owncloud", 133 | "url" => "http://localhost:8080/remote.php/dav/addressbooks/users/admin/contacts/", 134 | "displayname" => "Contacts", 135 | "description" => null, 136 | ], 137 | "Baikal_0" => [ 138 | "account" => "Baikal", 139 | "url" => "http://localhost:8080/dav.php/addressbooks/citest/default/", 140 | "displayname" => "Default Address Book", 141 | "description" => "Default Address Book for citest", 142 | ], 143 | ]; 144 | } 145 | 146 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120:ft=php 147 | -------------------------------------------------------------------------------- /tests/Interop/DiscoveryTest.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\Tests\CardDavClient\Interop; 13 | 14 | use MStilkerich\Tests\CardDavClient\TestInfrastructure; 15 | use MStilkerich\CardDavClient\Account; 16 | use MStilkerich\CardDavClient\Services\Discovery; 17 | use PHPUnit\Framework\TestCase; 18 | 19 | /** 20 | * @psalm-import-type TestAccount from TestInfrastructureSrv 21 | */ 22 | final class DiscoveryTest extends TestCase 23 | { 24 | public static function setUpBeforeClass(): void 25 | { 26 | TestInfrastructureSrv::init(); 27 | } 28 | 29 | protected function setUp(): void 30 | { 31 | } 32 | 33 | protected function tearDown(): void 34 | { 35 | TestInfrastructure::logger()->reset(); 36 | } 37 | 38 | public static function tearDownAfterClass(): void 39 | { 40 | } 41 | 42 | 43 | /** 44 | * @return array 45 | */ 46 | public function accountProvider(): array 47 | { 48 | return TestInfrastructureSrv::accountProvider(); 49 | } 50 | 51 | /** 52 | * @param TestAccount $cfg 53 | * @dataProvider accountProvider 54 | */ 55 | public function testAllAddressbooksCanBeDiscovered(string $accountname, array $cfg): void 56 | { 57 | $account = TestInfrastructureSrv::getAccount($accountname); 58 | $this->assertInstanceOf(Account::class, $account); 59 | 60 | $abookUris = []; 61 | foreach (AccountData::ADDRESSBOOKS as $abookname => $abookcfg) { 62 | if ($abookcfg['account'] === $accountname) { 63 | $abook = TestInfrastructureSrv::getAddressbook($abookname); 64 | if ($abook->getAccount() === $account) { 65 | $abookUris[] = TestInfrastructure::normalizeUri($abook, $abook->getUri()); 66 | } 67 | } 68 | } 69 | 70 | $discover = new Discovery(); 71 | $abooks = $discover->discoverAddressbooks($account); 72 | 73 | $this->assertCount(count($abookUris), $abooks, "Unexpected number of addressbooks discovered"); 74 | 75 | foreach ($abooks as $abook) { 76 | $uri = TestInfrastructure::normalizeUri($abook, $abook->getUri()); 77 | $this->assertContains($uri, $abookUris, "Unexpected addressbook discovered"); 78 | } 79 | } 80 | } 81 | 82 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 83 | -------------------------------------------------------------------------------- /tests/Interop/SyncTest.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\Tests\CardDavClient\Interop; 13 | 14 | use MStilkerich\Tests\CardDavClient\TestInfrastructure; 15 | use MStilkerich\CardDavClient\{Account,AddressbookCollection}; 16 | use MStilkerich\CardDavClient\Services\Sync; 17 | use PHPUnit\Framework\TestCase; 18 | use Sabre\VObject\Component\VCard; 19 | 20 | /** 21 | * @psalm-import-type TestAddressbook from TestInfrastructureSrv 22 | */ 23 | final class SyncTest extends TestCase 24 | { 25 | /** 26 | * @var array> $insertedUris Uris inserted to addressbooks by tests in this class 27 | * Maps addressbook name to a string[] of the URIs. 28 | */ 29 | private static $insertedUris; 30 | 31 | /** 32 | * @var array, synctoken: string}> 33 | * Simulate a local VCard cache for the sync. 34 | */ 35 | private static $cacheState; 36 | 37 | public static function setUpBeforeClass(): void 38 | { 39 | self::$insertedUris = []; 40 | self::$cacheState = []; 41 | TestInfrastructureSrv::init(); 42 | } 43 | 44 | protected function setUp(): void 45 | { 46 | } 47 | 48 | protected function tearDown(): void 49 | { 50 | TestInfrastructure::logger()->reset(); 51 | } 52 | 53 | public static function tearDownAfterClass(): void 54 | { 55 | // try to clean up leftovers 56 | foreach (self::$insertedUris as $abookname => $uris) { 57 | $abook = TestInfrastructureSrv::getAddressbook($abookname); 58 | foreach ($uris as $uri) { 59 | $abook->deleteCard($uri); 60 | } 61 | } 62 | } 63 | 64 | /** @return array */ 65 | public function addressbookProvider(): array 66 | { 67 | return TestInfrastructureSrv::addressbookProvider(); 68 | } 69 | 70 | /** 71 | * @param TestAddressbook $cfg 72 | * @dataProvider addressbookProvider 73 | */ 74 | public function testInitialSyncWorks(string $abookname, array $cfg): void 75 | { 76 | $abook = TestInfrastructureSrv::getAddressbook($abookname); 77 | $this->assertInstanceOf(AddressbookCollection::class, $abook); 78 | 79 | // insert two cards we can expect to be reported by the initial sync 80 | $createdCards = $this->createCards($abook, $abookname, 2); 81 | $this->assertCount(2, $createdCards); 82 | $syncHandler = new SyncTestHandler($abook, true, $createdCards); 83 | $syncmgr = new Sync(); 84 | $synctoken = $syncmgr->synchronize($abook, $syncHandler); 85 | $this->assertNotEmpty($synctoken, "Empty synctoken after initial sync"); 86 | 87 | // run sync handler's verification routine after the test 88 | $cacheState = $syncHandler->testVerify(); 89 | 90 | self::$cacheState[$abookname] = [ 91 | 'cache' => $cacheState, 92 | 'synctoken' => $synctoken 93 | ]; 94 | 95 | if (TestInfrastructureSrv::hasFeature($abookname, TestInfrastructureSrv::BUG_REJ_EMPTY_SYNCTOKEN)) { 96 | TestInfrastructure::logger()->expectMessage('error', 'sync-collection REPORT produced exception'); 97 | } 98 | } 99 | 100 | /** 101 | * @param TestAddressbook $cfg 102 | * @depends testInitialSyncWorks 103 | * @dataProvider addressbookProvider 104 | */ 105 | public function testImmediateFollowupSyncEmpty(string $abookname, array $cfg): void 106 | { 107 | $accountname = AccountData::ADDRESSBOOKS[$abookname]["account"]; 108 | $this->assertArrayHasKey($accountname, AccountData::ACCOUNTS); 109 | $accountcfg = AccountData::ACCOUNTS[$accountname]; 110 | $this->assertArrayHasKey("syncAllowExtraChanges", $accountcfg); 111 | 112 | $abook = TestInfrastructureSrv::getAddressbook($abookname); 113 | $this->assertInstanceOf(AddressbookCollection::class, $abook); 114 | $this->assertArrayHasKey($abookname, self::$cacheState); 115 | 116 | $syncHandler = new SyncTestHandler( 117 | $abook, 118 | $accountcfg["syncAllowExtraChanges"], 119 | [], 120 | [], 121 | self::$cacheState[$abookname]["cache"] 122 | ); 123 | $syncmgr = new Sync(); 124 | $synctoken = $syncmgr->synchronize($abook, $syncHandler, [], self::$cacheState[$abookname]["synctoken"]); 125 | $this->assertNotEmpty($synctoken, "Empty synctoken after followup sync"); 126 | 127 | // run sync handler's verification routine after the test 128 | $cacheState = $syncHandler->testVerify(); 129 | 130 | self::$cacheState[$abookname] = [ 131 | 'cache' => $cacheState, 132 | 'synctoken' => $synctoken 133 | ]; 134 | } 135 | 136 | /** 137 | * @param TestAddressbook $cfg 138 | * @depends testInitialSyncWorks 139 | * @dataProvider addressbookProvider 140 | */ 141 | public function testFollowupSyncDifferencesProperlyReported(string $abookname, array $cfg): void 142 | { 143 | $accountname = AccountData::ADDRESSBOOKS[$abookname]["account"]; 144 | $this->assertArrayHasKey($accountname, AccountData::ACCOUNTS); 145 | $accountcfg = AccountData::ACCOUNTS[$accountname]; 146 | $this->assertArrayHasKey("syncAllowExtraChanges", $accountcfg); 147 | 148 | $abook = TestInfrastructureSrv::getAddressbook($abookname); 149 | $this->assertInstanceOf(AddressbookCollection::class, $abook); 150 | $this->assertArrayHasKey($abookname, self::$cacheState); 151 | 152 | // delete one of the cards inserted earlier 153 | $delCardUri = array_shift(self::$insertedUris[$abookname]); 154 | $this->assertNotEmpty($delCardUri); 155 | $abook->deleteCard($delCardUri); 156 | 157 | // and add one that should be reported as changed 158 | $createdCards = $this->createCards($abook, $abookname, 1); 159 | $this->assertCount(1, $createdCards); 160 | 161 | $syncHandler = new SyncTestHandler( 162 | $abook, 163 | $accountcfg["syncAllowExtraChanges"], 164 | $createdCards, // exp changed 165 | [ $delCardUri ], // exp deleted 166 | self::$cacheState[$abookname]["cache"] 167 | ); 168 | $syncmgr = new Sync(); 169 | $synctoken = $syncmgr->synchronize($abook, $syncHandler, [], self::$cacheState[$abookname]["synctoken"]); 170 | $this->assertNotEmpty($synctoken, "Empty synctoken after followup sync"); 171 | 172 | // run sync handler's verification routine after the test 173 | $cacheState = $syncHandler->testVerify(); 174 | 175 | self::$cacheState[$abookname] = [ 176 | 'cache' => $cacheState, 177 | 'synctoken' => $synctoken 178 | ]; 179 | } 180 | 181 | /** 182 | * @return array 183 | */ 184 | private function createCards(AddressbookCollection $abook, string $abookname, int $num): array 185 | { 186 | $createdCards = []; 187 | for ($i = 0; $i < $num; ++$i) { 188 | $vcard = TestInfrastructure::createVCard(); 189 | [ 'uri' => $cardUri, 'etag' => $cardETag ] = $abook->createCard($vcard); 190 | $cardUri = TestInfrastructure::normalizeUri($abook, $cardUri); 191 | $createdCards[$cardUri] = [ "vcard" => $vcard, "etag" => $cardETag ]; 192 | if (!isset(self::$insertedUris[$abookname])) { 193 | self::$insertedUris[$abookname] = []; 194 | } 195 | self::$insertedUris[$abookname][] = $cardUri; 196 | } 197 | 198 | return $createdCards; 199 | } 200 | } 201 | 202 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 203 | -------------------------------------------------------------------------------- /tests/Interop/SyncTestHandler.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\Tests\CardDavClient\Interop; 13 | 14 | use MStilkerich\Tests\CardDavClient\TestInfrastructure; 15 | use MStilkerich\CardDavClient\{Account,AddressbookCollection}; 16 | use MStilkerich\CardDavClient\Services\{SyncHandler}; 17 | use PHPUnit\Framework\Assert; 18 | use Sabre\VObject\Component\VCard; 19 | 20 | /** 21 | * This is a sync handler to test reported sync results against expected ones. 22 | * 23 | * Generally, the sync handler will check that the changes performed by the test case between two syncs are reported in 24 | * the 2nd sync. In the first sync, the contents of the addressbook are not known, so any cards reported have to be 25 | * accepted. However, if the test performed some changes before the first sync, it can check for those. Between the two 26 | * syncs, no external changes to the addressbook are assumed, to the handler can check for the exact changes. 27 | */ 28 | final class SyncTestHandler implements SyncHandler 29 | { 30 | /** 31 | * @var AddressbookCollection The addressbook on that the sync is performed. 32 | */ 33 | private $abook; 34 | 35 | /** 36 | * @var bool $allowAdditionalChanges If true, the sync result may include unknown changes/deletes which are 37 | * accepted. 38 | */ 39 | private $allowAdditionalChanges; 40 | 41 | /** 42 | * Maps URI to [ 'vcard' => VCard object, 'etag' => string expected etag ] 43 | * @var array 44 | * The new/changed cards that are expected to be reported by the sync 45 | */ 46 | private $expectedChangedCards; 47 | 48 | /** 49 | * @var array An array of URIs => bool expected to be reported as deleted by the Sync. 50 | * The values are used to record which cards have been reported as deleted during the sync. 51 | */ 52 | private $expectedDeletedUris; 53 | 54 | /** 55 | * @var string $opSequence A log of the operation sequence invoked on this sync handler. 56 | * 57 | * String contains the characters: 58 | * - C (addressObjectChanged) 59 | * - D (addressObjectDeleted) 60 | * - F (finalizeSync) 61 | * 62 | * getExistingVCardETags is not recorded as no logical ordering is defined. 63 | */ 64 | private $opSequence = ""; 65 | 66 | /** 67 | * @var array 68 | * The state of the (simulated) local cache. This is an associative array mapping URIs of 69 | * cards that are assumed to be locally present to the ETags of their local version. Is provided during sync to 70 | * the sync service upon request. Is updated during the sync according to the changes reported by the sync 71 | * handler. 72 | */ 73 | private $cacheState; 74 | 75 | /** 76 | * @param array $expectedChangedCards 77 | * @param list $expectedDeletedUris 78 | * @param array $cacheState 79 | */ 80 | public function __construct( 81 | AddressBookCollection $abook, 82 | bool $allowAdditionalChanges, 83 | array $expectedChangedCards = [], 84 | array $expectedDeletedUris = [], 85 | array $cacheState = [] 86 | ) { 87 | $this->abook = $abook; 88 | $this->expectedChangedCards = $expectedChangedCards; 89 | $this->expectedDeletedUris = array_fill_keys($expectedDeletedUris, false); 90 | $this->allowAdditionalChanges = $allowAdditionalChanges; 91 | $this->cacheState = $cacheState; 92 | } 93 | 94 | public function addressObjectChanged(string $uri, string $etag, ?VCard $card): void 95 | { 96 | $this->opSequence .= "C"; 97 | $this->cacheState[$uri] = $etag; // need the relative URI as reported by the server here 98 | 99 | Assert::assertNotNull($card, "VCard data for $uri could not be retrieved/parsed"); 100 | 101 | $uri = TestInfrastructure::normalizeUri($this->abook, $uri); 102 | 103 | if ($this->allowAdditionalChanges === false) { 104 | Assert::assertArrayHasKey($uri, $this->expectedChangedCards, "Unexpected change reported: $uri"); 105 | } 106 | 107 | if (isset($this->expectedChangedCards[$uri])) { 108 | Assert::assertArrayNotHasKey( 109 | "seen", 110 | $this->expectedChangedCards[$uri], 111 | "Change reported multiple times: $uri" 112 | ); 113 | $this->expectedChangedCards[$uri]["seen"] = true; 114 | 115 | // the ETag is optional in the expected cards - the server may not report it after the insert in case 116 | // the card was changed server side 117 | if (!empty($this->expectedChangedCards[$uri]["etag"])) { 118 | Assert::assertEquals( 119 | $this->expectedChangedCards[$uri]["etag"], 120 | $etag, 121 | "ETag of changed card different from time ETag reported after change" 122 | ); 123 | } 124 | 125 | TestInfrastructure::compareVCards($this->expectedChangedCards[$uri]["vcard"], $card, true); 126 | } 127 | } 128 | 129 | public function addressObjectDeleted(string $uri): void 130 | { 131 | $this->opSequence .= "D"; 132 | 133 | if (! $this->allowAdditionalChanges) { 134 | Assert::assertArrayHasKey($uri, $this->cacheState, "Delete for URI not in cache: $uri"); 135 | } 136 | unset($this->cacheState[$uri]); 137 | 138 | $uri = TestInfrastructure::normalizeUri($this->abook, $uri); 139 | 140 | if (! $this->allowAdditionalChanges) { 141 | Assert::assertArrayHasKey($uri, $this->expectedDeletedUris, "Unexpected delete reported: $uri"); 142 | } 143 | 144 | if (isset($this->expectedDeletedUris[$uri])) { 145 | Assert::assertFalse($this->expectedDeletedUris[$uri], "Delete reported multiple times: $uri"); 146 | } 147 | $this->expectedDeletedUris[$uri] = true; 148 | } 149 | 150 | /** @return array */ 151 | public function getExistingVCardETags(): array 152 | { 153 | return $this->cacheState; 154 | } 155 | 156 | public function finalizeSync(): void 157 | { 158 | $this->opSequence .= "F"; 159 | } 160 | 161 | /** @return array */ 162 | public function testVerify(): array 163 | { 164 | $numDel = '{' . count($this->expectedDeletedUris) . '}'; 165 | $numChgMin = count($this->expectedChangedCards); 166 | $numChgMax = $this->allowAdditionalChanges ? "" : $numChgMin; 167 | $numChg = '{' . $numChgMin . ',' . $numChgMax . '}'; 168 | 169 | Assert::assertMatchesRegularExpression( 170 | "/^D{$numDel}C{$numChg}F$/", 171 | $this->opSequence, 172 | "Delete must be reported before changes" 173 | ); 174 | 175 | foreach ($this->expectedDeletedUris as $uri => $seen) { 176 | Assert::assertTrue($seen, "Deleted card NOT reported as deleted: $uri"); 177 | } 178 | 179 | foreach ($this->expectedChangedCards as $uri => $attr) { 180 | Assert::assertArrayHasKey("seen", $attr, "Changed card NOT reported as changed: $uri"); 181 | Assert::assertTrue($attr["seen"] ?? false, "Changed card NOT reported as changed: $uri"); 182 | } 183 | 184 | return $this->cacheState; 185 | } 186 | } 187 | 188 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 189 | -------------------------------------------------------------------------------- /tests/Interop/phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | . 10 | 11 | 12 | 13 | 14 | 15 | ../../src 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /tests/TestInfrastructure.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\Tests\CardDavClient; 13 | 14 | use Psr\Log\LoggerInterface; 15 | use PHPUnit\Framework\TestCase; 16 | use Sabre\VObject; 17 | use Sabre\VObject\Component\VCard; 18 | use MStilkerich\CardDavClient\{Config,WebDavCollection}; 19 | 20 | final class TestInfrastructure 21 | { 22 | /** @var ?TestLogger Logger object used to store log messages produced during the tests */ 23 | private static $logger; 24 | 25 | public static function init(?LoggerInterface $httpLogger = null): void 26 | { 27 | if (!isset(self::$logger)) { 28 | self::$logger = new TestLogger(); 29 | } 30 | 31 | Config::init(self::$logger, $httpLogger); 32 | } 33 | 34 | public static function logger(): TestLogger 35 | { 36 | $logger = self::$logger; 37 | TestCase::assertNotNull($logger, "Call init() before asking for logger"); 38 | return $logger; 39 | } 40 | 41 | /** 42 | * Create a minimal VCard for test purposes. 43 | */ 44 | public static function createVCard(): VCard 45 | { 46 | $randnum = rand(); 47 | $vcard = new VCard([ 48 | 'FN' => "CardDavClient Test$randnum", 49 | 'N' => ["Test$randnum", 'CardDavClient', '', '', ''], 50 | ]); 51 | 52 | return $vcard; 53 | } 54 | 55 | /** 56 | * CardDAV servers change the VCards, so this comparison function must be tolerant when comparing data stored on a 57 | * CardDAV server to data retrieved back from the server. 58 | * 59 | * - Google: 60 | * - Omits TYPE attribute in results from addressbook-query 61 | * - Changes case of the type attribute (work -> WORK) 62 | * - Overrides the UID in new cards with a server-side-assigned UID 63 | */ 64 | public static function compareVCards(VCard $vcardExpected, VCard $vcardRoundcube, bool $isNew): void 65 | { 66 | // clone to make sure we don't modify the passed in object when deleting properties that should not be compared 67 | $vcardExpected = clone $vcardExpected; 68 | $vcardRoundcube = clone $vcardRoundcube; 69 | 70 | // These attributes are dynamically created / updated and therefore cannot be statically compared 71 | $noCompare = [ 'REV', 'PRODID', 'VERSION' ]; // different VERSION may imply differences in other properties 72 | 73 | if ($isNew) { 74 | // new VCard will have UID assigned by carddavclient lib on store 75 | $noCompare[] = 'UID'; 76 | } 77 | 78 | foreach ($noCompare as $property) { 79 | unset($vcardExpected->{$property}); 80 | unset($vcardRoundcube->{$property}); 81 | } 82 | 83 | /** @var VObject\Property[] */ 84 | $propsExp = $vcardExpected->children(); 85 | $propsExp = self::groupNodesByName($propsExp); 86 | /** @var VObject\Property[] */ 87 | $propsRC = $vcardRoundcube->children(); 88 | $propsRC = self::groupNodesByName($propsRC); 89 | 90 | // compare 91 | foreach ($propsExp as $name => $props) { 92 | TestCase::assertArrayHasKey($name, $propsRC, "Expected property $name missing from test vcard"); 93 | self::compareNodeList("Property $name", $props, $propsRC[$name]); 94 | 95 | for ($i = 0; $i < count($props); ++$i) { 96 | TestCase::assertEqualsIgnoringCase( 97 | $props[$i]->group, 98 | $propsRC[$name][$i]->group, 99 | "Property group name differs" 100 | ); 101 | /** @psalm-var VObject\Parameter[] */ 102 | $paramExp = $props[$i]->parameters(); 103 | $paramExp = self::groupNodesByName($paramExp); 104 | /** @psalm-var VObject\Parameter[] */ 105 | $paramRC = $propsRC[$name][$i]->parameters(); 106 | $paramRC = self::groupNodesByName($paramRC); 107 | foreach ($paramExp as $pname => $params) { 108 | self::compareNodeList("Parameter $name/$pname", $params, $paramRC[$pname]); 109 | unset($paramRC[$pname]); 110 | } 111 | TestCase::assertEmpty($paramRC, "Prop $name has extra params: " . implode(", ", array_keys($paramRC))); 112 | } 113 | unset($propsRC[$name]); 114 | } 115 | 116 | TestCase::assertEmpty($propsRC, "VCard has extra properties: " . implode(", ", array_keys($propsRC))); 117 | } 118 | 119 | /** 120 | * Groups a list of VObject\Node by node name. 121 | * 122 | * @template T of VObject\Property|VObject\Parameter 123 | * 124 | * @param T[] $nodes 125 | * @return array> Array with node names as keys, and arrays of nodes by that name as values. 126 | */ 127 | private static function groupNodesByName(array $nodes): array 128 | { 129 | $res = []; 130 | foreach ($nodes as $n) { 131 | $res[$n->name][] = $n; 132 | } 133 | 134 | return $res; 135 | } 136 | 137 | /** 138 | * Compares to lists of VObject nodes with the same name. 139 | * 140 | * This can be two lists of property instances (e.g. EMAIL, TEL) or two lists of parameters (e.g. TYPE). 141 | * 142 | * @param string $dbgid Some string to identify property/parameter for error messages 143 | * @param VObject\Property[]|VObject\Parameter[] $exp Expected list of nodes 144 | * @param VObject\Property[]|VObject\Parameter[] $rc List of nodes in the VCard produces by rcmcarddav 145 | */ 146 | private static function compareNodeList(string $dbgid, array $exp, array $rc): void 147 | { 148 | TestCase::assertCount(count($exp), $rc, "Different amount of $dbgid"); 149 | 150 | for ($i = 0; $i < count($exp); ++$i) { 151 | TestCase::assertEquals($exp[$i]->getValue(), $rc[$i]->getValue(), "Nodes $dbgid differ"); 152 | } 153 | } 154 | 155 | public static function normalizeUri(WebDavCollection $coll, string $uri): string 156 | { 157 | return \Sabre\Uri\normalize(\Sabre\Uri\resolve($coll->getUri(), $uri)); 158 | } 159 | 160 | public static function getUriPath(string $uri): string 161 | { 162 | $uricomp = \Sabre\Uri\parse($uri); 163 | return $uricomp["path"] ?? "/"; 164 | } 165 | } 166 | 167 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 168 | -------------------------------------------------------------------------------- /tests/TestLogger.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\Tests\CardDavClient; 13 | 14 | use Psr\Log\{AbstractLogger,LogLevel,LoggerInterface}; 15 | use PHPUnit\Framework\TestCase; 16 | 17 | class TestLogger extends AbstractLogger 18 | { 19 | /** 20 | * @var int[] Assigns each log level a numerical severity value. 21 | */ 22 | private const LOGLEVELS = [ 23 | LogLevel::DEBUG => 1, 24 | LogLevel::INFO => 2, 25 | LogLevel::NOTICE => 3, 26 | LogLevel::WARNING => 4, 27 | LogLevel::ERROR => 5, 28 | LogLevel::CRITICAL => 6, 29 | LogLevel::ALERT => 7, 30 | LogLevel::EMERGENCY => 8 31 | ]; 32 | 33 | /** 34 | * @var string[] Assigns each short name to each log level. 35 | */ 36 | private const LOGLEVELS_SHORT = [ 37 | LogLevel::DEBUG => "DBG", 38 | LogLevel::INFO => "NFO", 39 | LogLevel::NOTICE => "NTC", 40 | LogLevel::WARNING => "WRN", 41 | LogLevel::ERROR => "ERR", 42 | LogLevel::CRITICAL => "CRT", 43 | LogLevel::ALERT => "ALT", 44 | LogLevel::EMERGENCY => "EMG" 45 | ]; 46 | 47 | /** @var resource $logh File handle to the log file */ 48 | private $logh; 49 | 50 | /** @var string[][] In-Memory buffer of log messages to assert log messages */ 51 | private $logBuffer = []; 52 | 53 | public function __construct(string $logFileName = 'test.log') 54 | { 55 | $logfile = "testreports/{$GLOBALS['TEST_TESTRUN']}/$logFileName"; 56 | $logh = fopen($logfile, 'w'); 57 | 58 | if ($logh === false) { 59 | throw new \Exception("could not open log file: $logfile"); 60 | } 61 | 62 | $this->logh = $logh; 63 | } 64 | 65 | /** 66 | * At the time of destruction, there may be no unchecked log messages of warning or higher level. 67 | * 68 | * Tests should call reset() when done (in tearDown()), this is just a fallback to detect if a test did not and 69 | * there were errors. When the error is raised from the destructor, the relation to the test function that triggered 70 | * the leftover log messages is lost and PHPUnit may report the issue for an unrelated test function within the same 71 | * test case. 72 | */ 73 | public function __destruct() 74 | { 75 | $this->reset(); 76 | fclose($this->logh); 77 | } 78 | 79 | /** 80 | * Logs with an arbitrary level. 81 | * 82 | * @param mixed $level 83 | * @param string $message 84 | * @param array $context 85 | * @return void 86 | */ 87 | public function log($level, $message, array $context = array()): void 88 | { 89 | TestCase::assertIsString($level); 90 | TestCase::assertNotNull(self::LOGLEVELS[$level]); 91 | 92 | $levelNumeric = self::LOGLEVELS[$level]; 93 | $levelShort = self::LOGLEVELS_SHORT[$level]; 94 | 95 | // interpolation of context placeholders is not implemented 96 | fprintf($this->logh, "[%s]: %s\n", date('Y-m-d H:i:s'), "[$levelNumeric $levelShort] $message"); 97 | 98 | // only warnings or more critical messages are interesting for testing 99 | if (self::LOGLEVELS[$level] >= self::LOGLEVELS[LogLevel::WARNING]) { 100 | $this->logBuffer[] = [ $level, $message, 'UNCHECKED' ]; 101 | } 102 | } 103 | 104 | /** 105 | * Resets the in-memory buffer of critical log messages. 106 | */ 107 | public function reset(): void 108 | { 109 | $buffer = $this->logBuffer; 110 | 111 | // reset before doing the assertions - if there is a failure, it won't affect the following tests 112 | $this->logBuffer = []; 113 | 114 | foreach ($buffer as $recMsg) { 115 | [ $level, $msg, $checked ] = $recMsg; 116 | TestCase::assertSame('CHECKED', $checked, "Unchecked log message of level $level: $msg"); 117 | } 118 | } 119 | 120 | /** 121 | * Checks the in-memory buffer if a log message of the given log level was emitted. 122 | */ 123 | public function expectMessage(string $expLevel, string $expMsg): void 124 | { 125 | $found = false; 126 | 127 | foreach ($this->logBuffer as &$recMsg) { 128 | [ $level, $msg ] = $recMsg; 129 | if (($level == $expLevel) && strpos($msg, $expMsg) !== false && $recMsg[2] == "UNCHECKED") { 130 | $recMsg[2] = 'CHECKED'; 131 | $found = true; 132 | break; 133 | } 134 | } 135 | unset($recMsg); 136 | 137 | TestCase::assertTrue($found, "The expected log entry containing '$expMsg' with level $expLevel was not found"); 138 | } 139 | } 140 | 141 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120:ft=php 142 | -------------------------------------------------------------------------------- /tests/Unit/AccountTest.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\Tests\CardDavClient\Unit; 13 | 14 | use PHPUnit\Framework\TestCase; 15 | use MStilkerich\CardDavClient\{Account,AddressbookCollection}; 16 | use MStilkerich\Tests\CardDavClient\TestInfrastructure; 17 | 18 | final class AccountTest extends TestCase 19 | { 20 | public static function setUpBeforeClass(): void 21 | { 22 | TestInfrastructure::init(); 23 | } 24 | 25 | protected function setUp(): void 26 | { 27 | } 28 | 29 | protected function tearDown(): void 30 | { 31 | } 32 | 33 | public static function tearDownAfterClass(): void 34 | { 35 | } 36 | 37 | public function testCanBeCreatedFromValidData(): void 38 | { 39 | $account = new Account("example.com", "theUser", "thePassword"); 40 | 41 | $this->expectException(\Exception::class); 42 | $account->getUrl(); 43 | } 44 | 45 | public function testAccountToStringContainsEssentialData(): void 46 | { 47 | $account = new Account("example.com", "theUser", "thePassword", "https://carddav.example.com:443"); 48 | $s = (string) $account; 49 | 50 | $this->assertStringContainsString("example.com", $s); 51 | $this->assertStringContainsString("user: theUser", $s); 52 | $this->assertStringContainsString("CardDAV URI: https://carddav.example.com:443", $s); 53 | } 54 | 55 | public function testCanBeCreatedFromArray(): void 56 | { 57 | $account = new Account("example.com", "theUser", "thePassword", "https://carddav.example.com:443"); 58 | 59 | $accSerial = [ 60 | 'username' => 'theUser', 61 | 'password' => 'thePassword', 62 | 'baseUrl' => 'https://carddav.example.com:443', 63 | 'discoveryUri' => 'example.com' 64 | ]; 65 | $accountExp = Account::constructFromArray($accSerial); 66 | 67 | $this->assertEquals($accountExp, $account); 68 | } 69 | 70 | public function testCanBeCreatedFromArrayWithoutOptionalProps(): void 71 | { 72 | $account = new Account("example.com", "theUser", "thePassword"); 73 | 74 | $accSerial = [ 75 | 'username' => 'theUser', 76 | 'password' => 'thePassword', 77 | 'discoveryUri' => 'example.com' 78 | ]; 79 | $accountExp = Account::constructFromArray($accSerial); 80 | 81 | $this->assertEquals($accountExp, $account); 82 | } 83 | 84 | public function testCanBeCreatedFromArrayWithoutRequiredProps(): void 85 | { 86 | $accSerial = [ 87 | 'username' => 'theUser', 88 | 'password' => 'thePassword', 89 | 'baseUrl' => 'https://carddav.example.com:443', 90 | ]; 91 | $this->expectException(\Exception::class); 92 | $this->expectExceptionMessage("does not contain required property discoveryUri"); 93 | /** @psalm-suppress InvalidArgument intended invalid argument passed for test */ 94 | Account::constructFromArray($accSerial); 95 | } 96 | 97 | public function testCanBeSerializedToJson(): void 98 | { 99 | $account = new Account("example.com", "theUser", "thePassword", "https://carddav.example.com:443"); 100 | 101 | $accSerial = $account->jsonSerialize(); 102 | $accSerialExp = [ 103 | 'username' => 'theUser', 104 | 'password' => 'thePassword', 105 | 'baseUrl' => 'https://carddav.example.com:443', 106 | 'discoveryUri' => 'example.com' 107 | ]; 108 | 109 | $this->assertEquals($accSerialExp, $accSerial); 110 | } 111 | } 112 | 113 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 114 | -------------------------------------------------------------------------------- /tests/Unit/ConfigTest.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\Tests\CardDavClient\Unit; 13 | 14 | use PHPUnit\Framework\TestCase; 15 | use MStilkerich\CardDavClient\Config; 16 | use MStilkerich\Tests\CardDavClient\TestInfrastructure; 17 | use Psr\Log\{AbstractLogger, NullLogger}; 18 | 19 | final class ConfigTest extends TestCase 20 | { 21 | public static function setUpBeforeClass(): void 22 | { 23 | TestInfrastructure::init(); 24 | } 25 | 26 | protected function setUp(): void 27 | { 28 | } 29 | 30 | protected function tearDown(): void 31 | { 32 | } 33 | 34 | public static function tearDownAfterClass(): void 35 | { 36 | } 37 | 38 | public function testInitSetsLoggersCorrectly(): void 39 | { 40 | $l1 = $this->createStub(AbstractLogger::class); 41 | $l2 = $this->createStub(AbstractLogger::class); 42 | Config::init($l1, $l2); 43 | $this->assertSame($l1, Config::$logger); 44 | $this->assertSame($l2, Config::$httplogger); 45 | 46 | // test init without logger params sets default null loggers 47 | Config::init(); 48 | $this->assertInstanceOf(NullLogger::class, Config::$logger); 49 | $this->assertInstanceOf(NullLogger::class, Config::$httplogger); 50 | } 51 | 52 | public function testInitSetsOptionsCorrectly(): void 53 | { 54 | Config::init(null, null, [ 'guzzle_logformat' => Config::GUZZLE_LOGFMT_SHORT]); 55 | $this->assertSame(Config::GUZZLE_LOGFMT_SHORT, Config::$options['guzzle_logformat']); 56 | } 57 | } 58 | 59 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 60 | -------------------------------------------------------------------------------- /tests/Unit/HttpClientAdapterGuzzleTest.php: -------------------------------------------------------------------------------- 1 | 7 | * Licensed under the MIT license. See COPYING file in the project root for details. 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace MStilkerich\Tests\CardDavClient\Unit; 13 | 14 | use PHPUnit\Framework\TestCase; 15 | use donatj\MockWebServer\{MockWebServer,Response}; 16 | use MStilkerich\CardDavClient\{Account,WebDavResource}; 17 | use MStilkerich\Tests\CardDavClient\TestInfrastructure; 18 | 19 | /** 20 | * @psalm-import-type HttpOptions from Account 21 | */ 22 | final class HttpClientAdapterGuzzleTest extends TestCase 23 | { 24 | /** @var MockWebServer */ 25 | private static $server; 26 | 27 | public static function setUpBeforeClass(): void 28 | { 29 | self::$server = new MockWebServer(); 30 | self::$server->start(); 31 | TestInfrastructure::init(); 32 | } 33 | 34 | protected function setUp(): void 35 | { 36 | } 37 | 38 | protected function tearDown(): void 39 | { 40 | } 41 | 42 | public static function tearDownAfterClass(): void 43 | { 44 | // stopping the web server during tear down allows us to reuse the port for later tests 45 | self::$server->stop(); 46 | } 47 | 48 | public function testQueryStringIsAppendedToRequests(): void 49 | { 50 | $query = ['a' => 'hello', 'b' => 'world']; 51 | $json = $this->sendRequest(['query' => $query]); 52 | 53 | foreach ($query as $opt => $val) { 54 | $this->assertSame($val, $json['_GET'][$opt]); 55 | } 56 | } 57 | 58 | public function testHeadersAreAddedToRequest(): void 59 | { 60 | $hdr = ['User-Agent' => 'carddavclient/dev', 'X-FOO' => 'bar']; 61 | $json = $this->sendRequest(['headers' => $hdr]); 62 | 63 | foreach ($hdr as $h => $val) { 64 | $this->assertSame($val, $json['HEADERS'][$h]); 65 | } 66 | } 67 | 68 | public function testAuthHeaderSentPreemptively(): void 69 | { 70 | // First test that normally the Authorization is not sent 71 | $json = $this->sendRequest(); 72 | $this->assertArrayNotHasKey('Authorization', $json['HEADERS']); 73 | 74 | // Second do the same request with preemptive_basic_auth 75 | $json = $this->sendRequest(['preemptive_basic_auth' => true]); 76 | $hdrValue = base64_encode('user:pw'); 77 | $this->assertSame("Basic $hdrValue", $json['HEADERS']['Authorization']); 78 | 79 | // Third make sure that the internal Authorization header overrides a user-defined header if given 80 | $json = $this->sendRequest(['preemptive_basic_auth' => true, 'headers' => ['Authorization' => 'Foo']]); 81 | $this->assertSame("Basic $hdrValue", $json['HEADERS']['Authorization']); 82 | 83 | // Fourth check that a user-defined Authorization could be given if carddavclient does not create one 84 | $json = $this->sendRequest(['headers' => ['Authorization' => 'Foo']]); 85 | $this->assertSame("Foo", $json['HEADERS']['Authorization']); 86 | } 87 | 88 | /** 89 | * When the internal lib also produces headers, they should take precedence over the user-defined headers for the 90 | * account. To test this we need to use a request where the internal lib generates request-specific headers, we'll 91 | * do a PROPFIND. We also need to have the mock server return a valid result so the client does not err out. 92 | */ 93 | public function testHeadersCombinedProperly(): void 94 | { 95 | $xml = <<<'END' 96 | 97 | 100 | 101 | 102 | /coll/ 103 | 104 | 105 | 106 | http://sabre.io/ns/sync/1 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | Default Address Book 117 | 1 118 | 119 | 120 | 121 | 122 | 123 | Default Address Book 124 | 10000000 125 | 126 | HTTP/1.1 200 OK 127 | 128 | 129 | 130 | END; 131 | 132 | $url = self::$server->setResponseOfPath( 133 | '/coll/', 134 | new Response( 135 | $xml, 136 | [ 'Content-Type' => 'application/xml; charset=utf-8' ], 137 | 207 138 | ) 139 | ); 140 | 141 | $httpOptions = [ 142 | 'username' => 'user', 143 | 'password' => 'pw', 144 | 'headers' => [ 145 | 'Depth' => '5', 146 | 'Foo' => '5', 147 | ], 148 | ]; 149 | 150 | $baseUri = self::$server->getServerRoot(); 151 | $account = new Account($baseUri, $httpOptions, "", $baseUri); 152 | 153 | $res = new WebDavResource($url, $account); 154 | $res->refreshProperties(); 155 | 156 | $propfindReq = self::$server->getLastRequest(); 157 | $this->assertNotNull($propfindReq); 158 | 159 | $reqHeaders = $propfindReq->getHeaders(); 160 | $this->assertSame('0', $reqHeaders['Depth']); 161 | $this->assertSame('5', $reqHeaders['Foo']); 162 | } 163 | 164 | /** 165 | * @param HttpOptions $options 166 | * @return array{HEADERS: array, _GET: array, ...} 167 | */ 168 | private function sendRequest(array $options = []): array 169 | { 170 | $httpOptions = $options + [ 171 | 'username' => 'user', 172 | 'password' => 'pw', 173 | ]; 174 | $baseUri = self::$server->getServerRoot(); 175 | 176 | $account = new Account($baseUri, $httpOptions, "", $baseUri); 177 | $res = new WebDavResource($baseUri, $account); 178 | $resp = $res->downloadResource('/res'); 179 | $json = json_decode($resp['body'], true); 180 | /** 181 | '_GET' => array ( 'a' => 'hello', 'b' => 'world',), 182 | '_POST' => array (), 183 | '_FILES' => array (), 184 | '_COOKIE' => array (), 185 | 'HEADERS' => array ( 'Host' => '127.0.0.1:36143', 'User-Agent' => 'GuzzleHttp/7',), 186 | 'METHOD' => 'GET', 187 | 'INPUT' => '', 188 | 'PARSED_INPUT' => array (), 189 | 'REQUEST_URI' => '/res?a=hello&b=world', 190 | 'PARSED_REQUEST_URI' => array ( 'path' => '/res', 'query' => 'a=hello&b=world',), 191 | */ 192 | $this->assertIsArray($json); 193 | $this->assertIsArray($json['HEADERS']); 194 | $this->assertIsArray($json['_GET']); 195 | return $json; 196 | } 197 | } 198 | 199 | // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 200 | -------------------------------------------------------------------------------- /tests/Unit/phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | . 10 | 11 | 12 | 13 | 14 | 15 | ../../src 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | --------------------------------------------------------------------------------