├── .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 | 
3 | [](https://codecov.io/gh/mstilkerich/carddavclient/branch/master)
4 | [](https://shepherd.dev/github/mstilkerich/carddavclient)
5 | [](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 | 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
--------------------------------------------------------------------------------
/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 | 
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