Files
bongo/src/www/bongo_web/card_store.py
T
Mario Fetka 39406774bb
Debian Trixie package bundle / packages (push) Successful in 21m22s
Complete classic webmail organizer views
2026-07-22 04:36:40 +02:00

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)