├── README.md ├── carddav-util.py └── carddav.py /README.md: -------------------------------------------------------------------------------- 1 | carddav-util 2 | ============ 3 | 4 | *carddav-util* is a python script capable of processing contact information 5 | at CardDAV servers. In particular it can move contact information between 6 | a CardDAV server and a local file; it dumps entire addressbooks to a vCard 7 | file and then may be used re-upload them to another CardDAV server. 8 | 9 | Requirements 10 | ------------ 11 | 12 | The following python libraries are required: 13 | 14 | * requests 15 | * vobject 16 | * lxml 17 | 18 | On ubuntu: 19 | 20 | sudo apt-get install python-requests python-vobject python-lxml 21 | 22 | Usage 23 | ----- 24 | 25 | ownCloud: 26 | 27 | ./carddav-util.py \ 28 | --download \ 29 | --user=username \ 30 | --file=test.vcf \ 31 | --url=https://domain.com/owncloud/remote.php/carddav/addressbooks/username/contacts 32 | 33 | Baïkal: 34 | 35 | ./carddav-util.py \ 36 | --download \ 37 | --user=username \ 38 | --file=test.vcf \ 39 | --url=https://domain.com/baikal/card.php/addressbooks/username/bookid 40 | 41 | Where: 42 | * username is your username - the one that you specified on the commandline 43 | * bookid is the identifier of your addressbook 44 | 45 | Authentication: 46 | * If your server uses digest authentication, you need to add *--digest*. 47 | 48 | Regenerating the formated name (FN) tag: 49 | 50 | ./carddav-util.py \ 51 | --fixfn \ 52 | --user=username \ 53 | --file=dummy \ 54 | --url=https://domain.com/baikal/card.php/addressbooks/username/bookid 55 | 56 | This overwrites the formated name tag with a string created from the following 57 | name fields in the following order: *prefix first_name middle_name family_name 58 | suffix* 59 | 60 | License 61 | ------- 62 | 63 | Copyright (c) 2012 by Lukasz Janyst <ljanyst@buggybrain.net> 64 | 65 | Permission to use, copy, modify, and/or distribute this software for any 66 | purpose with or without fee is hereby granted, provided that the above 67 | copyright notice and this permission notice appear in all copies. 68 | 69 | THE SOFTWARE IS PROVIDED 'AS IS' AND THE AUTHOR DISCLAIMS ALL WARRANTIES 70 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 71 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 72 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 73 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 74 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 75 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 76 | -------------------------------------------------------------------------------- /carddav-util.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | #------------------------------------------------------------------------------- 3 | # Copyright (c) 2013, 2022 by Lukasz Janyst 4 | # 5 | # Permission to use, copy, modify, and/or distribute this software for any 6 | # purpose with or without fee is hereby granted, provided that the above 7 | # copyright notice and this permission notice appear in all copies. 8 | # 9 | # THE SOFTWARE IS PROVIDED 'AS IS' AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | #------------------------------------------------------------------------------- 17 | 18 | import sys 19 | import uuid 20 | import getopt 21 | import getpass 22 | import carddav 23 | import vobject 24 | 25 | #------------------------------------------------------------------------------- 26 | # Fix FN 27 | #------------------------------------------------------------------------------- 28 | def fixFN( url, filename, user, passwd, auth, verify ): 29 | print( '[i] Editing at', url, '...' ) 30 | print( '[i] Listing the addressbook...' ) 31 | dav = carddav.PyCardDAV( url, user=user, passwd=passwd, auth=auth, 32 | write_support=True, verify=verify ) 33 | abook = dav.get_abook() 34 | nCards = len( abook.keys() ) 35 | print( '[i] Found', nCards, 'cards.' ) 36 | 37 | curr = 1 38 | for href, etag in abook.items(): 39 | print( "\r[i] Processing", curr, "of", nCards, ) 40 | sys.stdout.flush() 41 | curr += 1 42 | card = dav.get_vcard( href ) 43 | card = card.split( '\r\n' ) 44 | 45 | cardFixed = [] 46 | for l in card: 47 | if not l.startswith( 'FN:' ): 48 | cardFixed.append( l ) 49 | cardFixed = '\r\n'.join( cardFixed ) 50 | 51 | c = vobject.readOne( cardFixed ) 52 | 53 | n = [c.n.value.prefix, c.n.value.given, c.n.value.additional, 54 | c.n.value.family, c.n.value.suffix] 55 | name = '' 56 | for part in n: 57 | if part: 58 | name += part + ' ' 59 | name = name.strip() 60 | 61 | if not hasattr( c, 'fn' ): 62 | c.add('fn') 63 | c.fn.value = name 64 | 65 | try: 66 | dav.update_vcard( c.serialize(), href, etag ) 67 | except Exception as e: 68 | print( '' ) 69 | raise 70 | print( '' ) 71 | print( '[i] All updated' ) 72 | 73 | #------------------------------------------------------------------------------- 74 | # Download 75 | #------------------------------------------------------------------------------- 76 | def download( url, filename, user, passwd, auth, verify ): 77 | print( '[i] Downloading from', url, 'to', filename, '...' ) 78 | print( '[i] Downloading the addressbook...' ) 79 | dav = carddav.PyCardDAV( url, user=user, passwd=passwd, auth=auth, 80 | verify=verify ) 81 | abook = dav.get_abook() 82 | nCards = len( abook.keys() ) 83 | print( '[i] Found', nCards, 'cards.' ) 84 | 85 | f = open( filename, 'w' ) 86 | 87 | curr = 1 88 | for href, etag in abook.items(): 89 | print( '\r[i] Fetching', curr, 'of', nCards, ) 90 | sys.stdout.flush() 91 | curr += 1 92 | card = dav.get_vcard( href ) 93 | f.write( card.decode('utf-8') + '\n' ) 94 | print( '' ) 95 | f.close() 96 | print( '[i] All saved to:', filename ) 97 | 98 | #------------------------------------------------------------------------------- 99 | # Upload 100 | #------------------------------------------------------------------------------- 101 | def upload( url, filename, user, passwd, auth, verify ): 102 | if not url.endswith( '/' ): 103 | url += '/' 104 | 105 | print( '[i] Uploading from', filename, 'to', url, '...' ) 106 | 107 | print( '[i] Processing cards in', filename, '...' ) 108 | f = open( filename, 'r' ) 109 | cards = [] 110 | for card in vobject.readComponents( f, validate=True ): 111 | cards.append( card ) 112 | nCards = len(cards) 113 | print( '[i] Successfuly read and validated', nCards, 'entries' ) 114 | 115 | print( '[i] Connecting to', url, '...' ) 116 | dav = carddav.PyCardDAV( url, user=user, passwd=passwd, auth=auth, 117 | write_support=True, verify=verify ) 118 | 119 | curr = 1 120 | for card in cards: 121 | print( '\r[i] Uploading', curr, 'of', nCards, ) 122 | sys.stdout.flush() 123 | curr += 1 124 | 125 | if hasattr( card, 'prodid' ): 126 | del card.prodid 127 | 128 | if not hasattr( card, 'uid' ): 129 | card.add('uid') 130 | card.uid.value = str( uuid.uuid4() ) 131 | try: 132 | dav.upload_new_card( card.serialize() ) 133 | except Exception as e: 134 | print( '' ) 135 | raise 136 | print( '' ) 137 | f.close() 138 | print( '[i] All done' ) 139 | 140 | #------------------------------------------------------------------------------- 141 | # Print help 142 | #------------------------------------------------------------------------------- 143 | def printHelp(): 144 | print( 'carddav-util.py [options]' ) 145 | print( ' --url=http://your.addressbook.com CardDAV addressbook ' ) 146 | print( ' --file=local.vcf local vCard file ' ) 147 | print( ' --user=username username ' ) 148 | print( ' --passwd=password password, if absent will ' ) 149 | print( ' prompt for it in the console ' ) 150 | print( ' --download copy server -> file ' ) 151 | print( ' --upload copy file -> server ' ) 152 | print( ' --fixfn regenerate the FN tag ' ) 153 | print( ' --digest use digest authentication ' ) 154 | print( ' --no-cert-verify skip certificate verification ' ) 155 | print( ' --help this help message ' ) 156 | 157 | #------------------------------------------------------------------------------- 158 | # Run the show 159 | #------------------------------------------------------------------------------- 160 | def main(): 161 | try: 162 | params = ['url=', 'file=', 'download', 'upload', 'help', 163 | 'user=', 'passwd=', 'digest', 'no-cert-verify', 'fixfn'] 164 | optlist, args = getopt.getopt( sys.argv[1:], '', params ) 165 | except getopt.GetoptError as e: 166 | print( '[!]', e ) 167 | return 1 168 | 169 | opts = dict(optlist) 170 | if '--help' in opts or not opts: 171 | printHelp() 172 | return 0 173 | 174 | if '--upload' in opts and '--download' in opts and '--fixfn' in opts: 175 | print( '[!] You can only choose one action at a time' ) 176 | return 2 177 | 178 | if '--url' not in opts or '--file' not in opts: 179 | print( '[!] You must specify both the filename and the url' ) 180 | return 3 181 | 182 | url = opts['--url'] 183 | filename = opts['--file'] 184 | 185 | user = None 186 | passwd = None 187 | auth = 'basic' 188 | verify = True 189 | 190 | if '--digest' in opts: 191 | auth = 'digest' 192 | 193 | if '--no-cert-verify' in opts: 194 | verify = False 195 | 196 | if '--user' in opts: 197 | user = opts['--user'] 198 | if '--passwd' in opts: 199 | passwd = opts['--passwd'] 200 | else: 201 | passwd = getpass.getpass( user+'\'s password (won\'t be echoed): ') 202 | 203 | commandMap = {'--upload': upload, '--download': download, '--fixfn': fixFN} 204 | for command in commandMap: 205 | if command in opts: 206 | i = 0 207 | try: 208 | i = commandMap[command]( url, filename, user, passwd, auth, verify ) 209 | except Exception as e: 210 | print( '[!]', e ) 211 | 212 | if __name__ == '__main__': 213 | sys.exit(main()) 214 | -------------------------------------------------------------------------------- /carddav.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # vim: set ts=4 sw=4 expandtab sts=4: 3 | # Copyright (c) 2011-2013 Christian Geier & contributors 4 | # Copyright (c) 2013, 2022 by Lukasz Janyst 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining 7 | # a copy of this software and associated documentation files (the 8 | # "Software"), to deal in the Software without restriction, including 9 | # without limitation the rights to use, copy, modify, merge, publish, 10 | # distribute, sublicense, and/or sell copies of the Software, and to 11 | # permit persons to whom the Software is furnished to do so, subject to 12 | # the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be 15 | # included in all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 19 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 21 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 22 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 23 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 24 | 25 | #------------------------------------------------------------------------------- 26 | # Lukasz Janyst: 27 | # 28 | # Update to python3: 29 | # 30 | # requests-0.8.2: 31 | # * Remove the verify ssl flag - caused exception 32 | # * Add own raise_for_status for more meaningful error messages 33 | # * Fix digest auth 34 | #------------------------------------------------------------------------------- 35 | 36 | """ 37 | contains the class PyCardDAV and some associated functions and definitions 38 | """ 39 | 40 | from collections import namedtuple 41 | from urllib.parse import urlparse 42 | import requests 43 | import sys 44 | import logging 45 | import lxml.etree as ET 46 | import string 47 | 48 | def raise_for_status( resp ): 49 | if 400 <= resp.status_code < 500 or 500 <= resp.status_code < 600: 50 | msg = 'Error code: ' + str(resp.status_code) + '\n' 51 | msg += str(resp.content) 52 | raise requests.exceptions.HTTPError( msg ) 53 | 54 | def get_random_href(): 55 | """returns a random href""" 56 | import random 57 | tmp_list = list() 58 | for _ in range(3): 59 | rand_number = random.randint(0, 0x100000000) 60 | tmp_list.append("{0:x}".format(rand_number)) 61 | return "-".join(tmp_list).upper() 62 | 63 | 64 | DAVICAL = 'davical' 65 | SABREDAV = 'sabredav' 66 | UNKNOWN = 'unknown server' 67 | 68 | 69 | class UploadFailed(Exception): 70 | """uploading the card failed""" 71 | pass 72 | 73 | 74 | class PyCardDAV(object): 75 | """class for interacting with a CardDAV server 76 | 77 | Since PyCardDAV relies heavily on Requests [1] its SSL verification is also 78 | shared by PyCardDAV [2]. For now, only the *verify* keyword is exposed 79 | through PyCardDAV. 80 | 81 | [1] http://docs.python-requests.org/ 82 | [2] http://docs.python-requests.org/en/latest/user/advanced/ 83 | 84 | raises: 85 | requests.exceptions.SSLError 86 | requests.exceptions.ConnectionError 87 | more requests.exceptions depending on the actual error 88 | Exception (shame on me) 89 | 90 | """ 91 | 92 | def __init__(self, resource, debug='', user='', passwd='', 93 | verify=True, write_support=False, auth='basic'): 94 | #shutup url3 95 | urllog = logging.getLogger('requests.packages.urllib3.connectionpool') 96 | urllog.setLevel(logging.CRITICAL) 97 | 98 | split_url = urlparse(resource) 99 | url_tuple = namedtuple('url', 'resource base path') 100 | self.url = url_tuple(resource, 101 | split_url.scheme + '://' + split_url.netloc, 102 | split_url.path) 103 | 104 | self.debug = debug 105 | self.session = requests.session() 106 | self.write_support = write_support 107 | self._settings = {'verify': verify} 108 | if auth == 'basic': 109 | self._settings['auth'] = (user, passwd,) 110 | if auth == 'digest': 111 | from requests.auth import HTTPDigestAuth 112 | self._settings['auth'] = HTTPDigestAuth(user, passwd) 113 | self._default_headers = {"User-Agent": "pyCardDAV"} 114 | response = self.session.request('PROPFIND', resource, 115 | headers=self.headers, 116 | **self._settings) 117 | raise_for_status( response ) #raises error on not 2XX HTTP status code 118 | 119 | 120 | @property 121 | def verify(self): 122 | """gets verify from settings dict""" 123 | return self._settings['verify'] 124 | 125 | @verify.setter 126 | def verify(self, verify): 127 | """set verify""" 128 | self._settings['verify'] = verify 129 | 130 | @property 131 | def headers(self): 132 | return dict(self._default_headers) 133 | 134 | def _check_write_support(self): 135 | """checks if user really wants his data destroyed""" 136 | if not self.write_support: 137 | sys.stderr.write("Sorry, no write support for you. Please check " 138 | "the documentation.\n") 139 | sys.exit(1) 140 | 141 | def _detect_server(self): 142 | """detects CardDAV server type 143 | 144 | currently supports davical and sabredav (same as owncloud) 145 | :rtype: string "davical" or "sabredav" 146 | """ 147 | response = requests.request('OPTIONS', 148 | self.url.base, 149 | headers=self.header) 150 | if "X-Sabre-Version" in response.headers: 151 | server = SABREDAV 152 | elif "X-DAViCal-Version" in response.headers: 153 | server = DAVICAL 154 | else: 155 | server = UNKNOWN 156 | logging.info(server + " detected") 157 | return server 158 | 159 | def get_abook(self): 160 | """does the propfind and processes what it returns 161 | 162 | :rtype: list of hrefs to vcards 163 | """ 164 | xml = self._get_xml_props() 165 | abook = self._process_xml_props(xml) 166 | return abook 167 | 168 | def get_vcard(self, href): 169 | """ 170 | pulls vcard from server 171 | 172 | :returns: vcard 173 | :rtype: string 174 | """ 175 | response = self.session.get(self.url.base + href, 176 | headers=self.headers, 177 | **self._settings) 178 | raise_for_status( response ) 179 | return response.content 180 | 181 | def update_vcard(self, card, href, etag): 182 | """ 183 | pushes changed vcard to the server 184 | card: vcard as unicode string 185 | etag: str or None, if this is set to a string, card is only updated if 186 | remote etag matches. If etag = None the update is forced anyway 187 | """ 188 | # TODO what happens if etag does not match? 189 | self._check_write_support() 190 | remotepath = str(self.url.base + href) 191 | headers = self.headers 192 | headers['content-type'] = 'text/vcard' 193 | if etag is not None: 194 | headers['If-Match'] = etag 195 | self.session.put(remotepath, data=card.encode('utf-8'), headers=headers, 196 | **self._settings) 197 | 198 | def delete_vcard(self, href, etag): 199 | """deletes vcard from server 200 | 201 | deletes the resource at href if etag matches, 202 | if etag=None delete anyway 203 | :param href: href of card to be deleted 204 | :type href: str() 205 | :param etag: etag of that card, if None card is always deleted 206 | :type href: str() 207 | :returns: nothing 208 | """ 209 | # TODO: what happens if etag does not match, url does not exist etc ? 210 | self._check_write_support() 211 | remotepath = str(self.url.base + href) 212 | headers = self.headers 213 | headers['content-type'] = 'text/vcard' 214 | if etag is not None: 215 | headers['If-Match'] = etag 216 | result = self.session.delete(remotepath, 217 | headers=headers, 218 | **self._settings) 219 | raise_for_status( response ) 220 | 221 | def upload_new_card(self, card): 222 | """ 223 | upload new card to the server 224 | 225 | :param card: vcard to be uploaded 226 | :type card: unicode 227 | :rtype: tuple of string (path of the vcard on the server) and etag of 228 | new card (string or None) 229 | """ 230 | self._check_write_support() 231 | card = card.encode('utf-8') 232 | for _ in range(0, 5): 233 | rand_string = get_random_href() 234 | remotepath = str(self.url.resource + rand_string + ".vcf") 235 | headers = self.headers 236 | headers['content-type'] = 'text/vcard' 237 | headers['If-None-Match'] = '*' 238 | response = requests.put(remotepath, data=card, headers=headers, 239 | **self._settings) 240 | if response.ok: 241 | parsed_url = urlparse(remotepath) 242 | 243 | if 'etag' not in response.headers: 244 | etag = '' 245 | else: 246 | etag = response.headers['etag'] 247 | 248 | return (parsed_url.path, etag) 249 | raise_for_status( response ) 250 | 251 | def _get_xml_props(self): 252 | """PROPFIND method 253 | 254 | gets the xml file with all vcard hrefs 255 | 256 | :rtype: str() (an xml file) 257 | """ 258 | headers = self.headers 259 | headers['Depth'] = '1' 260 | response = self.session.request('PROPFIND', 261 | self.url.resource, 262 | headers=headers, 263 | **self._settings) 264 | raise_for_status( response ) 265 | if response.headers['DAV'].count('addressbook') == 0: 266 | raise Exception("URL is not a CardDAV resource") 267 | 268 | return response.content 269 | 270 | @classmethod 271 | def _process_xml_props(cls, xml): 272 | """processes the xml from PROPFIND, listing all vcard hrefs 273 | 274 | :param xml: the xml file 275 | :type xml: str() 276 | :rtype: dict() key: href, value: etag 277 | """ 278 | namespace = "{DAV:}" 279 | 280 | element = ET.XML(xml) 281 | abook = dict() 282 | for response in element.iterchildren(): 283 | if (response.tag == namespace + "response"): 284 | href = "" 285 | etag = "" 286 | insert = False 287 | for refprop in response.iterchildren(): 288 | if (refprop.tag == namespace + "href"): 289 | href = refprop.text 290 | for prop in refprop.iterchildren(): 291 | for props in prop.iterchildren(): 292 | if (props.tag == namespace + "getcontenttype" and 293 | (props.text == "text/vcard" or 294 | props.text == "text/vcard; charset=utf-8" or 295 | props.text == "text/x-vcard" or 296 | props.text == "text/x-vcard; charset=utf-8")): 297 | insert = True 298 | if (props.tag == namespace + "getetag"): 299 | etag = props.text 300 | if insert: 301 | abook[href] = etag 302 | return abook 303 | --------------------------------------------------------------------------------