384 lines
14 KiB
Python
384 lines
14 KiB
Python
# /****************************************************************************
|
|
# * <Novell-copyright>
|
|
# * Copyright (c) 2001 Novell, Inc. All Rights Reserved.
|
|
# *
|
|
# * This program is free software; you can redistribute it and/or
|
|
# * modify it under the terms of version 2 of the GNU General Public License
|
|
# * as published by the Free Software Foundation.
|
|
# *
|
|
# * This program is distributed in the hope that it will be useful,
|
|
# * but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# * GNU General Public License for more details.
|
|
# *
|
|
# * You should have received a copy of the GNU General Public License
|
|
# * along with this program; if not, contact Novell, Inc.
|
|
# *
|
|
# * To contact Novell about this file by physical or electronic mail, you
|
|
# * may find current contact information at www.novell.com.
|
|
# * </Novell-copyright>
|
|
# ****************************************************************************/
|
|
|
|
"""CardDAV-to-Bongo Store mapping and lossless vCard storage."""
|
|
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import uuid
|
|
from datetime import date
|
|
from urllib.parse import quote, unquote
|
|
|
|
|
|
CARD_NAME = re.compile(r"^[A-Za-z0-9._~-]{1,240}\.vcf$")
|
|
CONTACT_DOCUMENT_TYPE = 0x0004
|
|
RAW_VCARD_KEY = "bongo.carddav.vcard"
|
|
|
|
|
|
class CardPathError(ValueError):
|
|
pass
|
|
|
|
|
|
class CardNotFound(LookupError):
|
|
pass
|
|
|
|
|
|
class CardPreconditionFailed(RuntimeError):
|
|
pass
|
|
|
|
|
|
def parse_card_path(path, authenticated_user):
|
|
parts = path.strip("/").split("/")
|
|
if len(parts) not in (3, 4) or parts[0] != "addressbooks":
|
|
raise CardPathError("not an address-book resource")
|
|
user = unquote(parts[1])
|
|
if user != authenticated_user:
|
|
raise PermissionError("address-book owner differs from authenticated user")
|
|
if unquote(parts[2]) != "personal":
|
|
raise CardNotFound("address book does not exist")
|
|
if len(parts) == 3:
|
|
return {"kind": "addressbook", "user": user,
|
|
"addressbook": "personal"}
|
|
card = unquote(parts[3])
|
|
if not CARD_NAME.fullmatch(card):
|
|
raise CardPathError("invalid card resource name")
|
|
return {"kind": "card", "user": user, "addressbook": "personal",
|
|
"card": card, "store_path": "/addressbook/personal/" + card[:-4]}
|
|
|
|
|
|
def card_href(user, card):
|
|
return "/addressbooks/{}/personal/{}".format(
|
|
quote(user, safe="@.-_~"), quote(card, safe="._~-"))
|
|
|
|
|
|
def _vobject():
|
|
try:
|
|
import vobject
|
|
except ImportError as error:
|
|
raise RuntimeError("the system vobject package is required") from error
|
|
return vobject
|
|
|
|
|
|
def vcard_to_json(payload):
|
|
if len(payload) > 1048576:
|
|
raise ValueError("vCard is too large")
|
|
text = payload.decode("utf-8", "strict")
|
|
upper = text.upper()
|
|
if (upper.count("BEGIN:VCARD") != 1
|
|
or upper.count("END:VCARD") != 1
|
|
or "\nUID" not in upper.replace("\r\n", "\n")
|
|
or "\nFN" not in upper.replace("\r\n", "\n")):
|
|
raise ValueError("exactly one vCard with UID and FN is required")
|
|
card = _vobject().readOne(text)
|
|
if card.name.upper() != "VCARD" or not card.validate():
|
|
raise ValueError("invalid vCard")
|
|
data = {RAW_VCARD_KEY: card.serialize(), "fn": card.fn.value,
|
|
"uid": card.uid.value}
|
|
emails = []
|
|
for line in card.contents.get("email", []):
|
|
entry = {"value": str(line.value)}
|
|
for key, values in line.params.items():
|
|
entry[key.lower()] = list(values)
|
|
emails.append(entry)
|
|
if emails:
|
|
data["email"] = emails
|
|
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
|
|
|
|
|
def json_to_vcard(document, fallback_uid="contact"):
|
|
data = json.loads(document.strip())
|
|
raw = data.get(RAW_VCARD_KEY)
|
|
if raw:
|
|
return raw.encode("utf-8")
|
|
vobject = _vobject()
|
|
card = vobject.vCard()
|
|
card.add("uid").value = str(data.get("uid", fallback_uid))
|
|
card.add("fn").value = str(data.get("fn") or data.get("fullName")
|
|
or fallback_uid)
|
|
for entry in data.get("email", []):
|
|
value = entry.get("value") or entry.get("address")
|
|
if not value:
|
|
continue
|
|
line = card.add("email")
|
|
line.value = str(value)
|
|
for key, values in entry.items():
|
|
if key not in ("value", "address"):
|
|
line.params[key.upper()] = (values if isinstance(values, list)
|
|
else [str(values)])
|
|
return card.serialize().encode("utf-8")
|
|
|
|
|
|
def make_etag(payload):
|
|
return '"{}"'.format(hashlib.sha256(payload).hexdigest())
|
|
|
|
|
|
def open_store(user, store_cookie):
|
|
from bongo.store.StoreClient import StoreClient
|
|
return StoreClient(user, user, authCookie=store_cookie)
|
|
|
|
|
|
def etag_matches(condition, current):
|
|
if not condition:
|
|
return False
|
|
return condition.strip() == "*" or current in {
|
|
item.strip() for item in condition.split(",")
|
|
}
|
|
|
|
|
|
def _close(client):
|
|
if client is not None:
|
|
try:
|
|
client.Quit()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def card_to_dict(item):
|
|
card = _vobject().readOne(item["data"].decode("utf-8", "strict"))
|
|
emails = [str(line.value) for line in card.contents.get("email", [])]
|
|
phones = [str(line.value) for line in card.contents.get("tel", [])]
|
|
organization = ""
|
|
if card.contents.get("org"):
|
|
value = card.contents["org"][0].value
|
|
organization = " ".join(str(part) for part in value) \
|
|
if isinstance(value, (list, tuple)) else str(value)
|
|
name = card.contents.get("n", [None])[0]
|
|
name_value = name.value if name is not None else None
|
|
address = card.contents.get("adr", [None])[0]
|
|
address_value = address.value if address is not None else None
|
|
|
|
def component(value, field):
|
|
return str(getattr(value, field, "") or "") if value is not None else ""
|
|
|
|
def first_value(field):
|
|
lines = card.contents.get(field, [])
|
|
return str(lines[0].value) if lines else ""
|
|
|
|
return {
|
|
"resource": item["href"].rsplit("/", 1)[-1],
|
|
"etag": item["etag"],
|
|
"uid": str(card.uid.value),
|
|
"name": str(card.fn.value),
|
|
"email": emails[0] if emails else "",
|
|
"emails": emails,
|
|
"phone": phones[0] if phones else "",
|
|
"phones": phones,
|
|
"honorific_prefix": component(name_value, "prefix"),
|
|
"given_name": component(name_value, "given"),
|
|
"additional_name": component(name_value, "additional"),
|
|
"family_name": component(name_value, "family"),
|
|
"honorific_suffix": component(name_value, "suffix"),
|
|
"nickname": first_value("nickname"),
|
|
"birthday": first_value("bday"),
|
|
"anniversary": first_value("anniversary"),
|
|
"organization": organization,
|
|
"title": first_value("title"),
|
|
"role": first_value("role"),
|
|
"url": first_value("url"),
|
|
"po_box": component(address_value, "box"),
|
|
"extended_address": component(address_value, "extended"),
|
|
"street": component(address_value, "street"),
|
|
"city": component(address_value, "city"),
|
|
"region": component(address_value, "region"),
|
|
"postal_code": component(address_value, "code"),
|
|
"country": component(address_value, "country"),
|
|
"note": first_value("note"),
|
|
}
|
|
|
|
|
|
def _text(data, field, maximum):
|
|
value = str(data.get(field, "")).strip()
|
|
if len(value) > maximum or "\0" in value:
|
|
raise ValueError("contact field is too long")
|
|
return value
|
|
|
|
|
|
def _lines(data, plural, singular, maximum):
|
|
values = data.get(plural)
|
|
if isinstance(values, str):
|
|
values = values.splitlines()
|
|
elif not isinstance(values, list):
|
|
values = [data.get(singular, "")]
|
|
output = []
|
|
for value in values:
|
|
value = str(value).strip()
|
|
if not value:
|
|
continue
|
|
if len(value) > maximum or any(char in value for char in "\r\n\0"):
|
|
raise ValueError("invalid contact field")
|
|
output.append(value)
|
|
if len(output) > 32:
|
|
raise ValueError("too many contact fields")
|
|
return output
|
|
|
|
|
|
def make_card(data, existing=None):
|
|
name = str(data.get("name", "")).strip()
|
|
if not name or len(name) > 512:
|
|
raise ValueError("invalid contact name")
|
|
vobject = _vobject()
|
|
card = (vobject.readOne(existing.decode("utf-8", "strict"))
|
|
if existing else vobject.vCard())
|
|
for component in ("fn", "n", "nickname", "bday", "anniversary",
|
|
"email", "tel", "org", "title", "role", "url",
|
|
"adr", "note"):
|
|
card.contents.pop(component, None)
|
|
if not card.contents.get("uid"):
|
|
card.add("uid").value = str(data.get("uid") or uuid.uuid4())
|
|
card.add("fn").value = name
|
|
structured_name = {
|
|
"family": _text(data, "family_name", 256),
|
|
"given": _text(data, "given_name", 256),
|
|
"additional": _text(data, "additional_name", 256),
|
|
"prefix": _text(data, "honorific_prefix", 128),
|
|
"suffix": _text(data, "honorific_suffix", 128),
|
|
}
|
|
if any(structured_name.values()):
|
|
card.add("n").value = vobject.vcard.Name(**structured_name)
|
|
for field, component, maximum in (("nickname", "nickname", 256),
|
|
("organization", "org", 512),
|
|
("title", "title", 256),
|
|
("role", "role", 256),
|
|
("url", "url", 2048),
|
|
("note", "note", 65536)):
|
|
value = _text(data, field, maximum)
|
|
if value:
|
|
line = card.add(component)
|
|
line.value = [value] if component == "org" else value
|
|
for field in ("birthday", "anniversary"):
|
|
value = _text(data, field, 32)
|
|
if value:
|
|
try:
|
|
parsed = date.fromisoformat(value)
|
|
except ValueError as error:
|
|
raise ValueError("invalid contact date") from error
|
|
card.add("bday" if field == "birthday" else field).value = parsed.isoformat()
|
|
for value in _lines(data, "emails", "email", 320):
|
|
card.add("email").value = value
|
|
for value in _lines(data, "phones", "phone", 128):
|
|
card.add("tel").value = value
|
|
address = {
|
|
"box": _text(data, "po_box", 256),
|
|
"extended": _text(data, "extended_address", 512),
|
|
"street": _text(data, "street", 512),
|
|
"city": _text(data, "city", 256),
|
|
"region": _text(data, "region", 256),
|
|
"code": _text(data, "postal_code", 64),
|
|
"country": _text(data, "country", 256),
|
|
}
|
|
if any(address.values()):
|
|
card.add("adr").value = vobject.vcard.Address(**address)
|
|
return card.serialize().encode("utf-8")
|
|
|
|
|
|
def read_card(user, store_cookie, card, client_factory=open_store,
|
|
converter=json_to_vcard):
|
|
client = None
|
|
try:
|
|
client = client_factory(user, store_cookie)
|
|
path = "/addressbook/personal/" + card[:-4]
|
|
if client.Info(path) is None:
|
|
raise CardNotFound("card not found")
|
|
payload = converter(client.Read(path), card[:-4])
|
|
return payload, make_etag(payload)
|
|
except CardNotFound:
|
|
raise
|
|
except Exception as error:
|
|
raise RuntimeError("could not read card") from error
|
|
finally:
|
|
_close(client)
|
|
|
|
|
|
def write_card(user, store_cookie, card, payload, if_match=None,
|
|
if_none_match=None, client_factory=open_store,
|
|
to_json=vcard_to_json, to_vcard=json_to_vcard):
|
|
document = to_json(payload)
|
|
path = "/addressbook/personal/" + card[:-4]
|
|
client = None
|
|
try:
|
|
client = client_factory(user, store_cookie)
|
|
exists = client.Info(path) is not None
|
|
current_etag = None
|
|
if exists and (if_match or if_none_match):
|
|
current_etag = make_etag(to_vcard(client.Read(path), card[:-4]))
|
|
if if_none_match and exists and etag_matches(if_none_match, current_etag):
|
|
raise CardPreconditionFailed("card already exists")
|
|
if if_match and (not exists or not etag_matches(if_match, current_etag)):
|
|
raise CardPreconditionFailed("card changed")
|
|
if exists:
|
|
client.Replace(path, document)
|
|
else:
|
|
client.Write("/addressbook/personal", CONTACT_DOCUMENT_TYPE,
|
|
document, filename=card[:-4])
|
|
return not exists, make_etag(to_vcard(document, card[:-4]))
|
|
except (CardPreconditionFailed, ValueError):
|
|
raise
|
|
except Exception as error:
|
|
raise RuntimeError("could not store card") from error
|
|
finally:
|
|
_close(client)
|
|
|
|
|
|
def delete_card(user, store_cookie, card, if_match=None,
|
|
client_factory=open_store, converter=json_to_vcard):
|
|
path = "/addressbook/personal/" + card[:-4]
|
|
client = None
|
|
try:
|
|
client = client_factory(user, store_cookie)
|
|
if client.Info(path) is None:
|
|
raise CardNotFound("card not found")
|
|
if if_match:
|
|
current = make_etag(converter(client.Read(path), card[:-4]))
|
|
if not etag_matches(if_match, current):
|
|
raise CardPreconditionFailed("card changed")
|
|
client.Delete(path)
|
|
except (CardNotFound, CardPreconditionFailed):
|
|
raise
|
|
except Exception as error:
|
|
raise RuntimeError("could not delete card") from error
|
|
finally:
|
|
_close(client)
|
|
|
|
|
|
def list_cards(user, store_cookie, client_factory=open_store,
|
|
converter=json_to_vcard):
|
|
client = None
|
|
try:
|
|
client = client_factory(user, store_cookie)
|
|
output = []
|
|
for item in client.List("/addressbook/personal", ["nmap.document"]):
|
|
document = item.props.get("nmap.document")
|
|
if document is None:
|
|
continue
|
|
filename = item.filename.rsplit("/", 1)[-1]
|
|
card = filename if filename.endswith(".vcf") else filename + ".vcf"
|
|
if not CARD_NAME.fullmatch(card):
|
|
continue
|
|
payload = converter(document, filename)
|
|
output.append({"href": card_href(user, card),
|
|
"etag": make_etag(payload), "data": payload})
|
|
return output
|
|
except Exception as error:
|
|
raise RuntimeError("could not list cards") from error
|
|
finally:
|
|
_close(client)
|