diff --git a/backend/app/api/contacts.py b/backend/app/api/contacts.py index 22313da..a3f0e61 100644 --- a/backend/app/api/contacts.py +++ b/backend/app/api/contacts.py @@ -1,13 +1,32 @@ +import json +import re import uuid from datetime import datetime, timezone -from flask import request, jsonify +from flask import request, jsonify, Response from app.api import api_bp from app.api.auth import token_required from app.extensions import db from app.models.contact import AddressBook, Contact, AddressBookShare from app.models.user import User +from app.services.events import broadcaster + + +def _notify_addressbook(owner_id: int, book_id: int, change: str, shared_with=()): + """SSE event for a vcard or share change. Re-uses the calendar event + infrastructure with a separate 'addressbook' type.""" + recipients = [owner_id, *shared_with] + broadcaster.publish(recipients, { + 'type': 'addressbook', + 'change': change, + 'address_book_id': book_id, + }) + + +def _book_recipients(book: AddressBook): + return [s.shared_with_id for s in + AddressBookShare.query.filter_by(address_book_id=book.id).all()] def _get_addressbook_or_err(book_id, user, need_write=False): @@ -26,7 +45,224 @@ def _get_addressbook_or_err(book_id, user, need_write=False): return book, None -# --- Address Books --- +# --------------------------------------------------------------------------- +# vCard helpers +# --------------------------------------------------------------------------- + +def _escape(s): + if s is None: + return '' + return str(s).replace('\\', '\\\\').replace(',', '\\,').replace(';', '\\;').replace('\n', '\\n') + + +def _unescape(s): + if not s: + return '' + return s.replace('\\n', '\n').replace('\\;', ';').replace('\\,', ',').replace('\\\\', '\\') + + +def _apply_fields_to_contact(contact: Contact, data: dict): + """Copy fields from a JSON request into a Contact model instance.""" + for field in ('prefix', 'first_name', 'middle_name', 'last_name', 'suffix', + 'nickname', 'organization', 'department', 'job_title', + 'notes', 'photo', 'birthday', 'anniversary'): + if field in data: + value = data[field] + setattr(contact, field, (value.strip() if isinstance(value, str) else value) or None) + + if 'display_name' in data: + contact.display_name = (data['display_name'] or '').strip() or None + + for jsonfield in ('emails', 'phones', 'addresses', 'websites', 'impp', 'categories'): + if jsonfield in data: + value = data[jsonfield] or [] + setattr(contact, jsonfield, json.dumps(value) if value else None) + + # Denormalised primary fields for list display + emails = data.get('emails') if 'emails' in data else json.loads(contact.emails) if contact.emails else [] + phones = data.get('phones') if 'phones' in data else json.loads(contact.phones) if contact.phones else [] + contact.primary_email = (emails[0]['value'] if emails else None) + contact.primary_phone = (phones[0]['value'] if phones else None) + # Legacy columns + contact.email = contact.primary_email + contact.phone = contact.primary_phone + + # Compose display name if not provided + if not contact.display_name: + parts = [contact.prefix, contact.first_name, contact.middle_name, + contact.last_name, contact.suffix] + contact.display_name = ' '.join(p for p in parts if p) or contact.organization or None + + +def _build_vcard(contact: Contact) -> str: + """Render a Contact into vCard 3.0 text.""" + lines = ['BEGIN:VCARD', 'VERSION:3.0', f'UID:{contact.uid}'] + + if contact.display_name: + lines.append(f'FN:{_escape(contact.display_name)}') + + # N: lastname;firstname;middle;prefix;suffix + n_parts = [_escape(contact.last_name), _escape(contact.first_name), + _escape(contact.middle_name), _escape(contact.prefix), + _escape(contact.suffix)] + if any(n_parts): + lines.append('N:' + ';'.join(n_parts)) + + if contact.nickname: + lines.append(f'NICKNAME:{_escape(contact.nickname)}') + + if contact.organization or contact.department: + lines.append(f'ORG:{_escape(contact.organization or "")};{_escape(contact.department or "")}') + if contact.job_title: + lines.append(f'TITLE:{_escape(contact.job_title)}') + + for e in (json.loads(contact.emails) if contact.emails else []): + typ = (e.get('type') or 'home').upper() + lines.append(f'EMAIL;TYPE={typ}:{_escape(e.get("value", ""))}') + for p in (json.loads(contact.phones) if contact.phones else []): + typ = (p.get('type') or 'cell').upper() + lines.append(f'TEL;TYPE={typ}:{_escape(p.get("value", ""))}') + for a in (json.loads(contact.addresses) if contact.addresses else []): + typ = (a.get('type') or 'home').upper() + # ADR: po_box;extended;street;city;region;postal_code;country + parts = [_escape(a.get('po_box', '')), '', _escape(a.get('street', '')), + _escape(a.get('city', '')), _escape(a.get('region', '')), + _escape(a.get('postal_code', '')), _escape(a.get('country', ''))] + lines.append(f'ADR;TYPE={typ}:' + ';'.join(parts)) + for w in (json.loads(contact.websites) if contact.websites else []): + typ = (w.get('type') or '').upper() + tag = f'URL;TYPE={typ}' if typ else 'URL' + lines.append(f'{tag}:{_escape(w.get("value", ""))}') + for i in (json.loads(contact.impp) if contact.impp else []): + proto = (i.get('protocol') or 'xmpp').lower() + lines.append(f'IMPP:{proto}:{_escape(i.get("value", ""))}') + + if contact.birthday: + lines.append(f'BDAY:{contact.birthday}') + if contact.anniversary: + lines.append(f'ANNIVERSARY:{contact.anniversary}') + + cats = json.loads(contact.categories) if contact.categories else [] + if cats: + lines.append('CATEGORIES:' + ','.join(_escape(c) for c in cats)) + + if contact.notes: + lines.append(f'NOTE:{_escape(contact.notes)}') + + if contact.photo: + # Photo can be a data: URL or http URL. In vCard 3.0 we use PHOTO;VALUE=uri. + lines.append(f'PHOTO;VALUE=uri:{contact.photo}') + + lines.append(f'REV:{datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")}') + lines.append('END:VCARD') + return '\r\n'.join(lines) + + +def _unfold_vcard(raw: str): + """Undo RFC 6350 line folding (continuation lines start with space/tab).""" + lines = [] + for line in raw.replace('\r\n', '\n').split('\n'): + if line.startswith((' ', '\t')) and lines: + lines[-1] += line[1:] + else: + lines.append(line) + return lines + + +def parse_vcard(raw: str) -> dict: + """Parse a VCARD text into a dict of fields usable by _apply_fields_to_contact. + Returns dict with keys matching contact fields + 'uid'.""" + result = { + 'emails': [], 'phones': [], 'addresses': [], + 'websites': [], 'impp': [], 'categories': [], + } + for line in _unfold_vcard(raw): + if ':' not in line: + continue + key, _, value = line.partition(':') + parts = key.split(';') + name = parts[0].upper() + params = {} + for p in parts[1:]: + if '=' in p: + k, v = p.split('=', 1) + params[k.upper()] = v.upper() + + if name == 'UID': + result['uid'] = value.strip() + elif name == 'FN': + result['display_name'] = _unescape(value) + elif name == 'N': + fields = value.split(';') + if len(fields) >= 5: + result['last_name'] = _unescape(fields[0]) or None + result['first_name'] = _unescape(fields[1]) or None + result['middle_name'] = _unescape(fields[2]) or None + result['prefix'] = _unescape(fields[3]) or None + result['suffix'] = _unescape(fields[4]) or None + elif name == 'NICKNAME': + result['nickname'] = _unescape(value) + elif name == 'ORG': + fields = value.split(';') + result['organization'] = _unescape(fields[0]) if fields else None + if len(fields) > 1: + result['department'] = _unescape(fields[1]) or None + elif name == 'TITLE': + result['job_title'] = _unescape(value) + elif name == 'EMAIL': + result['emails'].append({ + 'type': (params.get('TYPE') or 'home').lower(), + 'value': _unescape(value), + }) + elif name == 'TEL': + result['phones'].append({ + 'type': (params.get('TYPE') or 'cell').lower(), + 'value': _unescape(value), + }) + elif name == 'ADR': + fields = value.split(';') + pad = fields + [''] * (7 - len(fields)) + result['addresses'].append({ + 'type': (params.get('TYPE') or 'home').lower(), + 'po_box': _unescape(pad[0]), + 'street': _unescape(pad[2]), + 'city': _unescape(pad[3]), + 'region': _unescape(pad[4]), + 'postal_code': _unescape(pad[5]), + 'country': _unescape(pad[6]), + }) + elif name == 'URL': + result['websites'].append({ + 'type': (params.get('TYPE') or '').lower(), + 'value': _unescape(value), + }) + elif name == 'IMPP': + proto, _, addr = value.partition(':') + result['impp'].append({'protocol': proto.lower(), 'value': _unescape(addr or value)}) + elif name == 'CATEGORIES': + result['categories'] = [_unescape(c).strip() for c in value.split(',') if c.strip()] + elif name == 'BDAY': + result['birthday'] = _normalise_date(value) + elif name == 'ANNIVERSARY': + result['anniversary'] = _normalise_date(value) + elif name == 'NOTE': + result['notes'] = _unescape(value) + elif name == 'PHOTO': + result['photo'] = value.strip() or None + return result + + +def _normalise_date(s: str): + s = s.strip() + m = re.match(r'^(\d{4})-?(\d{2})-?(\d{2})$', s[:10]) + if m: + return f'{m.group(1)}-{m.group(2)}-{m.group(3)}' + return None + + +# --------------------------------------------------------------------------- +# Address books +# --------------------------------------------------------------------------- @api_bp.route('/addressbooks', methods=['GET']) @token_required @@ -49,6 +285,9 @@ def list_addressbooks(): address_book_id=b.id, shared_with_id=user.id ).first() d['permission'] = share.permission if share else 'read' + d['owner_color'] = d.get('color') + if share and share.color: + d['color'] = share.color d['owner_name'] = b.owner.username d['contact_count'] = b.contacts.count() result.append(d) @@ -61,13 +300,19 @@ def list_addressbooks(): def create_addressbook(): user = request.current_user data = request.get_json() - name = data.get('name', '').strip() + name = (data.get('name') or '').strip() if not name: return jsonify({'error': 'Name erforderlich'}), 400 - book = AddressBook(owner_id=user.id, name=name, description=data.get('description', '')) + book = AddressBook( + owner_id=user.id, + name=name, + color=data.get('color', '#3788d8'), + description=data.get('description') or None, + ) db.session.add(book) db.session.commit() + _notify_addressbook(user.id, book.id, 'created') return jsonify(book.to_dict()), 201 @@ -77,31 +322,66 @@ def update_addressbook(book_id): user = request.current_user book = db.session.get(AddressBook, book_id) if not book or book.owner_id != user.id: - return jsonify({'error': 'Nicht gefunden'}), 404 + return jsonify({'error': 'Nicht gefunden oder keine Berechtigung'}), 404 data = request.get_json() if 'name' in data: book.name = data['name'].strip() if 'description' in data: - book.description = data['description'] + book.description = data['description'] or None + if 'color' in data: + book.color = data['color'] db.session.commit() + _notify_addressbook(book.owner_id, book.id, 'updated', + shared_with=_book_recipients(book)) return jsonify(book.to_dict()), 200 +@api_bp.route('/addressbooks//my-color', methods=['PUT']) +@token_required +def set_my_addressbook_color(book_id): + user = request.current_user + book = db.session.get(AddressBook, book_id) + if not book: + return jsonify({'error': 'Nicht gefunden'}), 404 + color = ((request.get_json() or {}).get('color') or '').strip() + + if book.owner_id == user.id: + if color: + book.color = color + db.session.commit() + return jsonify({'color': book.color}), 200 + + share = AddressBookShare.query.filter_by( + address_book_id=book_id, shared_with_id=user.id + ).first() + if not share: + return jsonify({'error': 'Kein Zugriff'}), 403 + share.color = color or None + db.session.commit() + return jsonify({'color': share.color or book.color}), 200 + + @api_bp.route('/addressbooks/', methods=['DELETE']) @token_required def delete_addressbook(book_id): user = request.current_user book = db.session.get(AddressBook, book_id) if not book or book.owner_id != user.id: - return jsonify({'error': 'Nicht gefunden'}), 404 + return jsonify({'error': 'Nicht gefunden oder keine Berechtigung'}), 404 + recipients = _book_recipients(book) + owner_id = book.owner_id + bid = book.id db.session.delete(book) db.session.commit() + _notify_addressbook(owner_id, bid, 'deleted', shared_with=recipients) return jsonify({'message': 'Adressbuch geloescht'}), 200 -# --- Contacts --- +# --------------------------------------------------------------------------- +# Contacts +# --------------------------------------------------------------------------- @api_bp.route('/addressbooks//contacts', methods=['GET']) @token_required @@ -111,11 +391,16 @@ def list_contacts(book_id): if err: return err - search = request.args.get('search', '').strip() - query = Contact.query.filter_by(address_book_id=book_id) + search = (request.args.get('q') or '').strip() + q = Contact.query.filter_by(address_book_id=book_id) if search: - query = query.filter(Contact.display_name.ilike(f'%{search}%')) - contacts = query.order_by(Contact.display_name).all() + like = f'%{search}%' + q = q.filter( + (Contact.display_name.ilike(like)) | + (Contact.primary_email.ilike(like)) | + (Contact.organization.ilike(like)) + ) + contacts = q.order_by(Contact.display_name).all() return jsonify([c.to_dict() for c in contacts]), 200 @@ -127,29 +412,16 @@ def create_contact(book_id): if err: return err - data = request.get_json() - display_name = data.get('display_name', '').strip() - if not display_name: - return jsonify({'error': 'Name erforderlich'}), 400 - - contact_uid = str(uuid.uuid4()) - email = data.get('email', '') - phone = data.get('phone', '') - org = data.get('organization', '') - notes = data.get('notes', '') - - vcard = _build_vcard(contact_uid, display_name, email, phone, org, notes) - - contact = Contact( - address_book_id=book_id, - uid=contact_uid, - vcard_data=vcard, - display_name=display_name, - email=email or None, - phone=phone or None, - ) + data = request.get_json() or {} + contact = Contact(address_book_id=book_id, uid=str(uuid.uuid4()), vcard_data='') + _apply_fields_to_contact(contact, data) + if not contact.display_name: + return jsonify({'error': 'Name oder Firma erforderlich'}), 400 + contact.vcard_data = _build_vcard(contact) db.session.add(contact) db.session.commit() + _notify_addressbook(book.owner_id, book.id, 'contact', + shared_with=_book_recipients(book)) return jsonify(contact.to_dict()), 201 @@ -160,11 +432,9 @@ def get_contact(contact_id): contact = db.session.get(Contact, contact_id) if not contact: return jsonify({'error': 'Kontakt nicht gefunden'}), 404 - book, err = _get_addressbook_or_err(contact.address_book_id, user) if err: return err - result = contact.to_dict() result['vcard_data'] = contact.vcard_data return jsonify(result), 200 @@ -177,29 +447,17 @@ def update_contact(contact_id): contact = db.session.get(Contact, contact_id) if not contact: return jsonify({'error': 'Kontakt nicht gefunden'}), 404 - book, err = _get_addressbook_or_err(contact.address_book_id, user, need_write=True) if err: return err - data = request.get_json() - if 'display_name' in data: - contact.display_name = data['display_name'].strip() - if 'email' in data: - contact.email = data['email'] or None - if 'phone' in data: - contact.phone = data['phone'] or None - - contact.vcard_data = _build_vcard( - contact.uid, - contact.display_name, - data.get('email', contact.email or ''), - data.get('phone', contact.phone or ''), - data.get('organization', ''), - data.get('notes', ''), - ) + data = request.get_json() or {} + _apply_fields_to_contact(contact, data) + contact.vcard_data = _build_vcard(contact) contact.updated_at = datetime.now(timezone.utc) db.session.commit() + _notify_addressbook(book.owner_id, book.id, 'contact', + shared_with=_book_recipients(book)) return jsonify(contact.to_dict()), 200 @@ -210,17 +468,19 @@ def delete_contact(contact_id): contact = db.session.get(Contact, contact_id) if not contact: return jsonify({'error': 'Kontakt nicht gefunden'}), 404 - book, err = _get_addressbook_or_err(contact.address_book_id, user, need_write=True) if err: return err - db.session.delete(contact) db.session.commit() + _notify_addressbook(book.owner_id, book.id, 'contact', + shared_with=_book_recipients(book)) return jsonify({'message': 'Kontakt geloescht'}), 200 -# --- Sharing --- +# --------------------------------------------------------------------------- +# Sharing +# --------------------------------------------------------------------------- @api_bp.route('/addressbooks//share', methods=['POST']) @token_required @@ -230,10 +490,9 @@ def share_addressbook(book_id): if not book or book.owner_id != user.id: return jsonify({'error': 'Nur der Eigentuemer kann teilen'}), 403 - data = request.get_json() - username = data.get('username', '').strip() + data = request.get_json() or {} + username = (data.get('username') or '').strip() permission = data.get('permission', 'read') - if permission not in ('read', 'readwrite'): return jsonify({'error': 'Ungueltige Berechtigung'}), 400 @@ -246,7 +505,6 @@ def share_addressbook(book_id): existing = AddressBookShare.query.filter_by( address_book_id=book_id, shared_with_id=target.id ).first() - is_new = not existing if existing: existing.permission = permission else: @@ -254,16 +512,9 @@ def share_addressbook(book_id): address_book_id=book_id, shared_with_id=target.id, permission=permission ) db.session.add(share) - db.session.commit() - - if is_new: - try: - from app.services.system_mail import notify_contacts_shared - notify_contacts_shared(book.name, user.username, target, permission) - except Exception: - pass - + _notify_addressbook(book.owner_id, book.id, 'share', + shared_with=[target.id, *_book_recipients(book)]) return jsonify({'message': f'Adressbuch mit {username} geteilt'}), 200 @@ -274,7 +525,6 @@ def list_addressbook_shares(book_id): book = db.session.get(AddressBook, book_id) if not book or book.owner_id != user.id: return jsonify({'error': 'Nicht gefunden'}), 404 - shares = AddressBookShare.query.filter_by(address_book_id=book_id).all() return jsonify([{ 'id': s.id, @@ -291,17 +541,20 @@ def remove_addressbook_share(book_id, share_id): book = db.session.get(AddressBook, book_id) if not book or book.owner_id != user.id: return jsonify({'error': 'Nicht gefunden'}), 404 - share = db.session.get(AddressBookShare, share_id) if not share or share.address_book_id != book_id: return jsonify({'error': 'Freigabe nicht gefunden'}), 404 - + target_id = share.shared_with_id db.session.delete(share) db.session.commit() + _notify_addressbook(book.owner_id, book.id, 'share', + shared_with=[target_id, *_book_recipients(book)]) return jsonify({'message': 'Freigabe entfernt'}), 200 -# --- Import/Export --- +# --------------------------------------------------------------------------- +# vCard export (all contacts of a book) +# --------------------------------------------------------------------------- @api_bp.route('/addressbooks//export', methods=['GET']) @token_required @@ -310,40 +563,9 @@ def export_contacts(book_id): book, err = _get_addressbook_or_err(book_id, user) if err: return err - - contacts = Contact.query.filter_by(address_book_id=book_id).all() - vcards = '\r\n'.join(c.vcard_data for c in contacts) - - from flask import Response + parts = [c.vcard_data for c in book.contacts] return Response( - vcards, - mimetype='text/vcard', + '\r\n'.join(parts), + mimetype='text/vcard; charset=utf-8', headers={'Content-Disposition': f'attachment; filename="{book.name}.vcf"'}, ) - - -# --- Helpers --- - -def _build_vcard(uid, display_name, email='', phone='', org='', notes=''): - parts = display_name.split(' ', 1) - first = parts[0] - last = parts[1] if len(parts) > 1 else '' - - lines = [ - 'BEGIN:VCARD', - 'VERSION:3.0', - f'UID:{uid}', - f'FN:{display_name}', - f'N:{last};{first};;;', - ] - if email: - lines.append(f'EMAIL:{email}') - if phone: - lines.append(f'TEL:{phone}') - if org: - lines.append(f'ORG:{org}') - if notes: - lines.append(f'NOTE:{notes}') - lines.append(f'REV:{datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")}') - lines.append('END:VCARD') - return '\r\n'.join(lines) diff --git a/backend/app/dav/__init__.py b/backend/app/dav/__init__.py index 757c662..c2b7122 100644 --- a/backend/app/dav/__init__.py +++ b/backend/app/dav/__init__.py @@ -2,4 +2,5 @@ from flask import Blueprint dav_bp = Blueprint('dav', __name__, url_prefix='/dav') -from . import caldav # noqa: F401,E402 +from . import caldav # noqa: F401,E402 +from . import carddav # noqa: F401,E402 diff --git a/backend/app/dav/caldav.py b/backend/app/dav/caldav.py index e79d185..431809d 100644 --- a/backend/app/dav/caldav.py +++ b/backend/app/dav/caldav.py @@ -180,6 +180,10 @@ def _principal_response(user: User) -> ET.Element: ET.SubElement(pu, _qn('d', 'href')).text = href home = ET.SubElement(prop, _qn('c', 'calendar-home-set')) ET.SubElement(home, _qn('d', 'href')).text = href + # CardDAV address-book home set - same principal URL, addressbook + # collections live next to calendars under /dav// + ab_home = ET.SubElement(prop, '{urn:ietf:params:xml:ns:carddav}addressbook-home-set') + ET.SubElement(ab_home, _qn('d', 'href')).text = href return _make_response(href, populate) @@ -265,7 +269,7 @@ def propfind(subpath=''): multistatus.append(_principal_response(user)) return _xml_response(multistatus) - # /dav// : principal + list calendars + # /dav// : principal + list calendars AND addressbooks if len(parts) == 1: if parts[0] != user.username: return Response('', 403) @@ -273,6 +277,11 @@ def propfind(subpath=''): if depth != '0': for cal in _user_calendars(user): multistatus.append(_calendar_response(user, cal)) + # Addressbooks live next to calendars. Import here to avoid a + # circular import at module load time. + from .carddav import _addressbook_response, _user_addressbooks + for ab in _user_addressbooks(user): + multistatus.append(_addressbook_response(user, ab)) return _xml_response(multistatus) # /dav//cal-/ : calendar + events diff --git a/backend/app/dav/carddav.py b/backend/app/dav/carddav.py new file mode 100644 index 0000000..88fdb74 --- /dev/null +++ b/backend/app/dav/carddav.py @@ -0,0 +1,351 @@ +"""Minimal CardDAV server (RFC 6352 subset). + +Mirror structure of caldav.py - adds addressbook collections under + /dav//ab-/ +and serves vCard 3.0 resources via GET/PUT/DELETE plus addressbook-query +and addressbook-multiget REPORTs. + +Reuses the auth + XML helpers from caldav.py to stay consistent. +""" +from __future__ import annotations + +import re +import uuid +import xml.etree.ElementTree as ET +from datetime import datetime, timezone + +from flask import Response, request + +from app.extensions import db +from app.models.contact import AddressBook, Contact, AddressBookShare +from app.models.user import User +from app.api.contacts import _apply_fields_to_contact, _build_vcard, parse_vcard + +from . import dav_bp +from .caldav import ( + NS, _qn, _xml_response, basic_auth, _make_response, + _principal_response, # reused - we extend below +) + + +# --------------------------------------------------------------------------- +# URL helpers +# --------------------------------------------------------------------------- + +def _href_addressbook(username: str, book_id: int) -> str: + return f'/dav/{username}/ab-{book_id}/' + + +def _href_vcard(username: str, book_id: int, uid: str) -> str: + return f'/dav/{username}/ab-{book_id}/{uid}.vcf' + + +def _parse_addressbook_path(part: str): + m = re.match(r'ab-(\d+)$', part) + return int(m.group(1)) if m else None + + +def _user_addressbooks(user: User): + return AddressBook.query.filter_by(owner_id=user.id).all() + + +def _addressbook_for(user: User, book_id: int): + book = db.session.get(AddressBook, book_id) + if not book or book.owner_id != user.id: + return None + return book + + +# --------------------------------------------------------------------------- +# Property responses +# --------------------------------------------------------------------------- + +def _addressbook_ctag(book: AddressBook) -> str: + last = db.session.query(db.func.max(Contact.updated_at)).filter_by(address_book_id=book.id).scalar() + ts = int((last or book.updated_at or datetime.now(timezone.utc)).timestamp()) + return f'"ab{book.id}-{ts}"' + + +def _addressbook_response(user: User, book: AddressBook) -> ET.Element: + href = _href_addressbook(user.username, book.id) + + def populate(prop): + rt = ET.SubElement(prop, _qn('d', 'resourcetype')) + ET.SubElement(rt, _qn('d', 'collection')) + # urn:ietf:params:xml:ns:carddav addressbook element + ab = ET.SubElement(rt, '{urn:ietf:params:xml:ns:carddav}addressbook') # noqa: F841 + ET.SubElement(prop, _qn('d', 'displayname')).text = book.name + ET.SubElement(prop, '{urn:ietf:params:xml:ns:carddav}addressbook-description').text = book.description or '' + srs = ET.SubElement(prop, _qn('d', 'supported-report-set')) + for r in ('addressbook-query', 'addressbook-multiget'): + sup = ET.SubElement(srs, _qn('d', 'supported-report')) + rep = ET.SubElement(sup, _qn('d', 'report')) + ET.SubElement(rep, '{urn:ietf:params:xml:ns:carddav}' + r) + ET.SubElement(prop, _qn('ic', 'calendar-color')).text = book.color or '#3788d8' + ET.SubElement(prop, _qn('cs', 'getctag')).text = _addressbook_ctag(book) + cups = ET.SubElement(prop, _qn('d', 'current-user-privilege-set')) + for priv in ('read', 'write', 'write-properties', 'write-content', 'bind', 'unbind'): + p = ET.SubElement(cups, _qn('d', 'privilege')) + ET.SubElement(p, _qn('d', priv)) + return _make_response(href, populate) + + +def _vcard_response(user: User, book: AddressBook, contact: Contact, include_data: bool = False) -> ET.Element: + href = _href_vcard(user.username, book.id, contact.uid) + + def populate(prop): + ts = int((contact.updated_at or datetime.now(timezone.utc)).timestamp() * 1000) + ET.SubElement(prop, _qn('d', 'getetag')).text = f'"{contact.id}-{ts}"' + ET.SubElement(prop, _qn('d', 'getcontenttype')).text = 'text/vcard; charset=utf-8' + ET.SubElement(prop, _qn('d', 'resourcetype')) + if include_data: + ET.SubElement(prop, '{urn:ietf:params:xml:ns:carddav}address-data').text = \ + contact.vcard_data or _build_vcard(contact) + return _make_response(href, populate) + + +def _etag_for_contact(contact: Contact) -> str: + ts = int((contact.updated_at or contact.created_at or datetime.now(timezone.utc)).timestamp() * 1000) + return f'"{contact.id}-{ts}"' + + +# --------------------------------------------------------------------------- +# Extend the principal response from caldav.py to include addressbook-home-set +# This is done by wrapping the existing helper and appending the extra prop. +# --------------------------------------------------------------------------- + +# We import caldav.propfind and add a separate URL-rule set here. For the +# principal, caldav._principal_response already emits calendar-home-set; we +# leave the combined principal to that function. CardDAV clients that check +# addressbook-home-set via PROPFIND get it via our own route below, because +# the URL `/dav//` is handled by caldav's propfind. To also return +# addressbook-home-set there we monkey-patch the principal populate. + +# Simpler approach: re-implement the principal for our own URL-space by +# hooking into the propfind dispatcher's principal branch. + +# Since caldav.propfind already builds the principal response, we inject the +# addressbook-home-set via a wrapper. Let's override by providing our own +# handler in the blueprint that augments the response. + + +# --------------------------------------------------------------------------- +# OPTIONS / PROPFIND / REPORT / GET / PUT / DELETE for /dav//ab-/... +# --------------------------------------------------------------------------- + +_DAV_HEADERS = {'DAV': '1, 2, 3, addressbook'} + + +@dav_bp.route('///', methods=['OPTIONS']) +@dav_bp.route('//', methods=['OPTIONS']) +def ab_options(username, ab_part): + if not ab_part.startswith('ab-'): + return Response('', 404) + return Response('', 200, { + 'DAV': '1, 2, 3, addressbook', + 'Allow': 'OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, PROPPATCH, MKCOL', + }) + + +@dav_bp.route('///', methods=['PROPFIND']) +@dav_bp.route('//', methods=['PROPFIND']) +@basic_auth +def ab_propfind(username, ab_part): + if not ab_part.startswith('ab-'): + return Response('Not found', 404) + user: User = request.dav_user + if username != user.username: + return Response('', 403) + book_id = _parse_addressbook_path(ab_part) + book = _addressbook_for(user, book_id) if book_id else None + if not book: + return Response('Not found', 404) + + depth = request.headers.get('Depth', '0') + multistatus = ET.Element(_qn('d', 'multistatus')) + multistatus.append(_addressbook_response(user, book)) + if depth != '0': + for c in book.contacts.all(): + multistatus.append(_vcard_response(user, book, c)) + return _xml_response(multistatus) + + +@dav_bp.route('///', methods=['PROPFIND']) +@basic_auth +def ab_contact_propfind(username, ab_part, filename): + user: User = request.dav_user + if username != user.username: + return Response('', 403) + book_id = _parse_addressbook_path(ab_part) + book = _addressbook_for(user, book_id) if book_id else None + if not book: + return Response('Not found', 404) + uid = filename.removesuffix('.vcf') + contact = Contact.query.filter_by(address_book_id=book.id, uid=uid).first() + if not contact: + return Response('Not found', 404) + multistatus = ET.Element(_qn('d', 'multistatus')) + multistatus.append(_vcard_response(user, book, contact, include_data=True)) + return _xml_response(multistatus) + + +@dav_bp.route('///', methods=['REPORT']) +@dav_bp.route('//', methods=['REPORT']) +@basic_auth +def ab_report(username, ab_part): + if not ab_part.startswith('ab-'): + return Response('Not found', 404) + user: User = request.dav_user + if username != user.username: + return Response('', 403) + book_id = _parse_addressbook_path(ab_part) + book = _addressbook_for(user, book_id) if book_id else None + if not book: + return Response('Not found', 404) + + try: + root = ET.fromstring(request.data or b'') + except ET.ParseError: + return Response('Malformed XML', 400) + + wants_data = root.find(f".//{{urn:ietf:params:xml:ns:carddav}}address-data") is not None + multistatus = ET.Element(_qn('d', 'multistatus')) + + if root.tag == '{urn:ietf:params:xml:ns:carddav}addressbook-multiget': + hrefs = [h.text for h in root.findall(_qn('d', 'href')) if h.text] + for href in hrefs: + uid = href.rsplit('/', 1)[-1].removesuffix('.vcf') + contact = Contact.query.filter_by(address_book_id=book.id, uid=uid).first() + if contact: + multistatus.append(_vcard_response(user, book, contact, include_data=True)) + return _xml_response(multistatus) + + if root.tag == '{urn:ietf:params:xml:ns:carddav}addressbook-query': + # No filter implementation yet - return all + for contact in book.contacts.all(): + multistatus.append(_vcard_response(user, book, contact, include_data=wants_data)) + return _xml_response(multistatus) + + return _xml_response(multistatus) + + +@dav_bp.route('///', methods=['GET', 'HEAD']) +@basic_auth +def ab_get(username, ab_part, filename): + user: User = request.dav_user + if username != user.username: + return Response('', 403) + book_id = _parse_addressbook_path(ab_part) + book = _addressbook_for(user, book_id) if book_id else None + if not book: + return Response('Not found', 404) + uid = filename.removesuffix('.vcf') + contact = Contact.query.filter_by(address_book_id=book.id, uid=uid).first() + if not contact: + return Response('Not found', 404) + return Response( + contact.vcard_data or _build_vcard(contact), + mimetype='text/vcard; charset=utf-8', + headers={'ETag': _etag_for_contact(contact)}, + ) + + +@dav_bp.route('///', methods=['PUT']) +@basic_auth +def ab_put(username, ab_part, filename): + user: User = request.dav_user + if username != user.username: + return Response('', 403) + book_id = _parse_addressbook_path(ab_part) + book = _addressbook_for(user, book_id) if book_id else None + if not book: + return Response('Not found', 404) + + uid = filename.removesuffix('.vcf') + raw = request.get_data(as_text=True) or '' + parsed = parse_vcard(raw) + body_uid = parsed.get('uid') or uid + + existing = Contact.query.filter_by(address_book_id=book.id, uid=body_uid).first() + if_match = request.headers.get('If-Match') + if_none_match = request.headers.get('If-None-Match') + if existing and if_none_match == '*': + return Response('', 412) + if if_match and existing and if_match.strip() != _etag_for_contact(existing): + return Response('', 412) + + if not existing: + existing = Contact(address_book_id=book.id, uid=body_uid, vcard_data=raw) + db.session.add(existing) + + _apply_fields_to_contact(existing, parsed) + # Keep the original raw VCARD so round-tripping is faithful - but also + # record the server-rebuilt version for web UI consumers. We prefer the + # raw source of truth here. + existing.vcard_data = raw.strip() or _build_vcard(existing) + existing.updated_at = datetime.now(timezone.utc) + db.session.commit() + status = 201 if not if_match else 204 + return Response('', status, {'ETag': _etag_for_contact(existing)}) + + +@dav_bp.route('///', methods=['DELETE']) +@basic_auth +def ab_delete(username, ab_part, filename): + user: User = request.dav_user + if username != user.username: + return Response('', 403) + book_id = _parse_addressbook_path(ab_part) + book = _addressbook_for(user, book_id) if book_id else None + if not book: + return Response('Not found', 404) + uid = filename.removesuffix('.vcf') + contact = Contact.query.filter_by(address_book_id=book.id, uid=uid).first() + if not contact: + return Response('', 404) + db.session.delete(contact) + db.session.commit() + return Response('', 204) + + +@dav_bp.route('///', methods=['DELETE']) +@dav_bp.route('//', methods=['DELETE']) +@basic_auth +def ab_delete_collection(username, ab_part): + if not ab_part.startswith('ab-'): + return Response('', 404) + user: User = request.dav_user + if username != user.username: + return Response('', 403) + book_id = _parse_addressbook_path(ab_part) + book = _addressbook_for(user, book_id) if book_id else None + if not book: + return Response('', 404) + db.session.delete(book) + db.session.commit() + return Response('', 204) + + +@dav_bp.route('///', methods=['MKCOL']) +@dav_bp.route('//', methods=['MKCOL']) +@basic_auth +def ab_mkcol(username, ab_part): + """Create a new addressbook collection via MKCOL (RFC 5689 extended). + Some CardDAV clients (Apple) use this instead of MKCALENDAR.""" + user: User = request.dav_user + if username != user.username: + return Response('', 403) + name = 'Neues Adressbuch' + try: + body = request.get_data() + if body: + root = ET.fromstring(body) + dn = root.find(f".//{_qn('d', 'displayname')}") + if dn is not None and dn.text: + name = dn.text + except ET.ParseError: + pass + book = AddressBook(owner_id=user.id, name=name) + db.session.add(book) + db.session.commit() + return Response('', 201, {'Location': _href_addressbook(user.username, book.id)}) diff --git a/backend/app/models/contact.py b/backend/app/models/contact.py index fcab880..f6470d1 100644 --- a/backend/app/models/contact.py +++ b/backend/app/models/contact.py @@ -10,6 +10,7 @@ class AddressBook(db.Model): owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) name = db.Column(db.String(255), nullable=False) description = db.Column(db.Text, nullable=True) + color = db.Column(db.String(7), default='#3788d8') created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) @@ -18,6 +19,7 @@ class AddressBook(db.Model): cascade='all, delete-orphan') shares = db.relationship('AddressBookShare', backref='address_book', lazy='dynamic', cascade='all, delete-orphan') + # `owner` wird automatisch durch User.address_books backref erzeugt def to_dict(self): return { @@ -25,6 +27,7 @@ class AddressBook(db.Model): 'owner_id': self.owner_id, 'name': self.name, 'description': self.description, + 'color': self.color, 'created_at': self.created_at.isoformat() if self.created_at else None, } @@ -36,22 +39,92 @@ class Contact(db.Model): address_book_id = db.Column(db.Integer, db.ForeignKey('address_books.id'), nullable=False, index=True) uid = db.Column(db.String(255), unique=True, nullable=False) - vcard_data = db.Column(db.Text, nullable=False) # Full VCARD + vcard_data = db.Column(db.Text, nullable=False) + + # Structured name fields + prefix = db.Column(db.String(64), nullable=True) + first_name = db.Column(db.String(128), nullable=True) + middle_name = db.Column(db.String(128), nullable=True) + last_name = db.Column(db.String(128), nullable=True, index=True) + suffix = db.Column(db.String(64), nullable=True) display_name = db.Column(db.String(255), nullable=True, index=True) + nickname = db.Column(db.String(128), nullable=True) + + # Organisation + organization = db.Column(db.String(255), nullable=True) + department = db.Column(db.String(255), nullable=True) + job_title = db.Column(db.String(255), nullable=True) + + # Primary fields for quick listing (denormalised) + primary_email = db.Column(db.String(255), nullable=True, index=True) + primary_phone = db.Column(db.String(50), nullable=True) + + # JSON-encoded multi-valued fields + # Each list entry: {"type": "home|work|other|mobile|fax|pager|...", "value": "..."} + emails = db.Column(db.Text, nullable=True) + phones = db.Column(db.Text, nullable=True) + # address: {"type": ..., "street": ..., "po_box": ..., "city": ..., + # "region": ..., "postal_code": ..., "country": ...} + addresses = db.Column(db.Text, nullable=True) + websites = db.Column(db.Text, nullable=True) + impp = db.Column(db.Text, nullable=True) # {"protocol": "skype", "value": "..."} + categories = db.Column(db.Text, nullable=True) # ["family", "work", ...] + + # Dates + birthday = db.Column(db.String(10), nullable=True) # YYYY-MM-DD + anniversary = db.Column(db.String(10), nullable=True) + + # Free text + notes = db.Column(db.Text, nullable=True) + + # Photo: data URL (data:image/jpeg;base64,...) oder http(s):// + photo = db.Column(db.Text, nullable=True) + + # Legacy column kept for old clients / migrations email = db.Column(db.String(255), nullable=True) phone = db.Column(db.String(50), nullable=True) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) def to_dict(self): + import json + + def _loads(s, default): + if not s: + return default + try: + return json.loads(s) + except (ValueError, TypeError): + return default + return { 'id': self.id, 'address_book_id': self.address_book_id, 'uid': self.uid, + 'prefix': self.prefix, + 'first_name': self.first_name, + 'middle_name': self.middle_name, + 'last_name': self.last_name, + 'suffix': self.suffix, 'display_name': self.display_name, - 'email': self.email, - 'phone': self.phone, + 'nickname': self.nickname, + 'organization': self.organization, + 'department': self.department, + 'job_title': self.job_title, + 'emails': _loads(self.emails, []), + 'phones': _loads(self.phones, []), + 'addresses': _loads(self.addresses, []), + 'websites': _loads(self.websites, []), + 'impp': _loads(self.impp, []), + 'categories': _loads(self.categories, []), + 'birthday': self.birthday, + 'anniversary': self.anniversary, + 'notes': self.notes, + 'photo': self.photo, + 'primary_email': self.primary_email or self.email, + 'primary_phone': self.primary_phone or self.phone, 'created_at': self.created_at.isoformat() if self.created_at else None, 'updated_at': self.updated_at.isoformat() if self.updated_at else None, } @@ -65,6 +138,7 @@ class AddressBookShare(db.Model): nullable=False, index=True) shared_with_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) permission = db.Column(db.String(20), nullable=False, default='read') + color = db.Column(db.String(7), nullable=True) # personal display color shared_with = db.relationship('User', backref='shared_address_books') diff --git a/frontend/src/views/ContactsView.vue b/frontend/src/views/ContactsView.vue index d16859e..a6c3aba 100644 --- a/frontend/src/views/ContactsView.vue +++ b/frontend/src/views/ContactsView.vue @@ -10,25 +10,43 @@
- + - - - + + + + +