Skip to content

Commit b9e4623

Browse files
committed
feat(vcard): implement vCard round-trip hash verification
1 parent d61f225 commit b9e4623

12 files changed

Lines changed: 623 additions & 10 deletions

File tree

backend/app/api/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
tags,
2727
users,
2828
utils,
29+
vcard_conflicts,
2930
webhooks,
3031
)
3132
from app.core.config import settings
@@ -57,6 +58,7 @@
5758
api_router.include_router(import_export.router)
5859
api_router.include_router(webhooks.router)
5960
api_router.include_router(activity_logs.router)
61+
api_router.include_router(vcard_conflicts.router)
6062
api_router.include_router(calendar.router)
6163

6264

backend/app/api/routes/contacts.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
OverdueContactsPublic,
2929
User,
3030
)
31+
from app.vcard import compute_vcard_hash
3132

3233

3334
class BulkContactFilter(BaseModel):
@@ -641,6 +642,9 @@ def update_contact(
641642
group_ids = update_data.pop("group_ids", None)
642643

643644
contact.sqlmodel_update(update_data)
645+
# Compute vcard_sha256 if vcard_raw was updated
646+
if "vcard_raw" in update_data and contact.vcard_raw:
647+
contact.vcard_sha256 = compute_vcard_hash(contact.vcard_raw)
644648
session.add(contact)
645649

646650
# Update tag associations if provided
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""API routes for vCard conflict management."""
2+
3+
import uuid
4+
from datetime import datetime, timezone
5+
6+
from fastapi import APIRouter, HTTPException
7+
from sqlmodel import select
8+
9+
from app.api.deps import CurrentUser, SessionDep
10+
from app.models import Contact
11+
from app.models_vcard_conflict import (
12+
VCardConflict,
13+
VCardConflictPublic,
14+
VCardConflictsPublic,
15+
)
16+
17+
router = APIRouter(prefix="/vcard-conflicts", tags=["vCard Conflicts"])
18+
19+
20+
@router.get("/", response_model=VCardConflictsPublic)
21+
def list_vcard_conflicts(
22+
session: SessionDep,
23+
current_user: CurrentUser,
24+
skip: int = 0,
25+
limit: int = 100,
26+
) -> VCardConflictsPublic:
27+
"""List all unresolved vCard conflicts for the current user."""
28+
# Join with Contact to ensure we only return conflicts for this user's contacts
29+
stmt = (
30+
select(VCardConflict)
31+
.join(Contact, VCardConflict.contact_id == Contact.id)
32+
.where(
33+
Contact.owner_id == current_user.id,
34+
VCardConflict.resolved_at.is_(None),
35+
)
36+
.order_by(VCardConflict.created_at.desc())
37+
.offset(skip)
38+
.limit(limit)
39+
)
40+
conflicts = session.exec(stmt).all()
41+
42+
# Also get the local vcard_raw for each conflict
43+
result = []
44+
for conflict in conflicts:
45+
conflict_public = VCardConflictPublic(
46+
id=conflict.id,
47+
contact_id=conflict.contact_id,
48+
incoming_vcard_raw=conflict.incoming_vcard_raw,
49+
incoming_hash=conflict.incoming_hash,
50+
local_hash=conflict.local_hash,
51+
resolved_at=conflict.resolved_at,
52+
resolution_type=conflict.resolution_type,
53+
created_at=conflict.created_at,
54+
local_vcard_raw=conflict.local_vcard_raw,
55+
)
56+
result.append(conflict_public)
57+
58+
count_stmt = (
59+
select(VCardConflict)
60+
.join(Contact, VCardConflict.contact_id == Contact.id)
61+
.where(
62+
Contact.owner_id == current_user.id,
63+
VCardConflict.resolved_at.is_(None),
64+
)
65+
)
66+
count = len(session.exec(count_stmt).all())
67+
68+
return VCardConflictsPublic(data=result, count=count)
69+
70+
71+
@router.post("/{conflict_id}/resolve", response_model=VCardConflictPublic)
72+
def resolve_vcard_conflict(
73+
session: SessionDep,
74+
current_user: CurrentUser,
75+
conflict_id: uuid.UUID,
76+
resolution_type: str,
77+
) -> VCardConflictPublic:
78+
"""Resolve a vCard conflict by accepting remote or keeping local.
79+
80+
resolution_type must be one of: 'keep_local', 'accept_remote'
81+
"""
82+
if resolution_type not in ("keep_local", "accept_remote"):
83+
raise HTTPException(
84+
status_code=400,
85+
detail="resolution_type must be 'keep_local' or 'accept_remote'",
86+
)
87+
88+
# Get the conflict and verify ownership
89+
stmt = (
90+
select(VCardConflict)
91+
.join(Contact, VCardConflict.contact_id == Contact.id)
92+
.where(
93+
VCardConflict.id == conflict_id,
94+
Contact.owner_id == current_user.id,
95+
)
96+
)
97+
conflict = session.exec(stmt).first()
98+
if not conflict:
99+
raise HTTPException(status_code=404, detail="Conflict not found")
100+
101+
if conflict.resolved_at is not None:
102+
raise HTTPException(status_code=400, detail="Conflict already resolved")
103+
104+
# Get the contact
105+
contact = session.get(Contact, conflict.contact_id)
106+
if not contact:
107+
raise HTTPException(status_code=404, detail="Contact not found")
108+
109+
if resolution_type == "accept_remote":
110+
# Update contact with incoming vCard data
111+
from app.vcard import vcard_to_contact_data
112+
113+
parsed = vcard_to_contact_data(conflict.incoming_vcard_raw)
114+
contact_data = parsed["contact"]
115+
for key, value in contact_data.items():
116+
if hasattr(contact, key):
117+
setattr(contact, key, value)
118+
contact.vcard_raw = conflict.incoming_vcard_raw
119+
contact.vcard_sha256 = conflict.incoming_hash
120+
session.add(contact)
121+
122+
# Mark conflict as resolved
123+
conflict.resolved_at = datetime.now(timezone.utc)
124+
conflict.resolution_type = resolution_type
125+
session.add(conflict)
126+
session.commit()
127+
session.refresh(conflict)
128+
129+
return VCardConflictPublic(
130+
id=conflict.id,
131+
contact_id=conflict.contact_id,
132+
incoming_vcard_raw=conflict.incoming_vcard_raw,
133+
incoming_hash=conflict.incoming_hash,
134+
local_hash=conflict.local_hash,
135+
resolved_at=conflict.resolved_at,
136+
resolution_type=conflict.resolution_type,
137+
created_at=conflict.created_at,
138+
local_vcard_raw=conflict.local_vcard_raw,
139+
)
140+
141+
142+
@router.delete("/{conflict_id}", status_code=204)
143+
def delete_vcard_conflict(
144+
session: SessionDep,
145+
current_user: CurrentUser,
146+
conflict_id: uuid.UUID,
147+
) -> None:
148+
"""Delete a vCard conflict (dismiss without action)."""
149+
stmt = (
150+
select(VCardConflict)
151+
.join(Contact, VCardConflict.contact_id == Contact.id)
152+
.where(
153+
VCardConflict.id == conflict_id,
154+
Contact.owner_id == current_user.id,
155+
)
156+
)
157+
conflict = session.exec(stmt).first()
158+
if not conflict:
159+
raise HTTPException(status_code=404, detail="Conflict not found")
160+
161+
session.delete(conflict)
162+
session.commit()

backend/app/carddav/storage.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
from sqlmodel import Session, create_engine, select
1818

1919
from app.core.config import settings
20-
from app.models import Contact, User
20+
from app.models import Contact, User, VCardConflict
21+
from app.vcard import compute_vcard_hash, normalize_vcard_for_hash
2122

2223

2324
def _http_datetime(dt: datetime) -> str:
@@ -137,24 +138,42 @@ def upload(
137138
)
138139
# Update fields from parsed vCard
139140
contact_data = parsed["contact"]
141+
142+
# vCard hash verification for conflict detection
143+
incoming_hash = compute_vcard_hash(vcard_text)
144+
if existing.vcard_sha256 and existing.vcard_sha256 != incoming_hash:
145+
# Hash mismatch - potential conflict
146+
# Check if it's just whitespace/formatting drift
147+
if existing.vcard_raw:
148+
local_normalized = normalize_vcard_for_hash(existing.vcard_raw)
149+
incoming_normalized = normalize_vcard_for_hash(vcard_text)
150+
if local_normalized != incoming_normalized:
151+
# Real conflict - store for user review
152+
conflict = VCardConflict(
153+
contact_id=existing.id,
154+
incoming_vcard_raw=vcard_text,
155+
incoming_hash=incoming_hash,
156+
local_hash=existing.vcard_sha256,
157+
local_vcard_raw=existing.vcard_raw,
158+
)
159+
session.add(conflict)
160+
140161
for key, value in contact_data.items():
141162
if hasattr(existing, key):
142163
setattr(existing, key, value)
143164
existing.vcard_raw = vcard_text
144165
existing.vcard_etag = item.etag
145-
session.add(existing)
166+
existing.vcard_sha256 = incoming_hash
146167
else:
147168
# Create new contact
148169
contact_data = parsed["contact"]
149-
new_contact = Contact(
170+
Contact(
150171
owner_id=user.id,
151172
vcard_raw=vcard_text,
152173
vcard_etag=item.etag,
174+
vcard_sha256=compute_vcard_hash(vcard_text),
153175
**contact_data,
154176
)
155-
if parsed.get("uid"):
156-
new_contact.id = parsed["uid"]
157-
session.add(new_contact)
158177

159178
session.commit()
160179

backend/app/crud.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
UserCreate,
5656
UserUpdate,
5757
)
58+
from app.vcard import compute_vcard_hash
5859

5960

6061
def create_user(*, session: Session, user_create: UserCreate) -> User:
@@ -117,6 +118,9 @@ def create_contact(
117118
*, session: Session, contact_in: ContactCreate, owner_id: uuid.UUID
118119
) -> Contact:
119120
db_obj = Contact.model_validate(contact_in, update={"owner_id": owner_id})
121+
# Compute vcard_sha256 if vcard_raw is present
122+
if db_obj.vcard_raw:
123+
db_obj.vcard_sha256 = compute_vcard_hash(db_obj.vcard_raw)
120124
session.add(db_obj)
121125
session.commit()
122126
session.refresh(db_obj)

backend/app/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from sqlalchemy import DateTime
88
from sqlmodel import Field, Relationship, SQLModel
99

10+
from app.models_vcard_conflict import VCardConflict # noqa: F401
11+
1012

1113
def get_datetime_utc() -> datetime:
1214
return datetime.now(timezone.utc)

backend/app/vcard.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
Preserves unknown vCard properties through round-trips by storing raw vCard text.
55
"""
66

7+
import hashlib
78
import uuid
89

910
import vobject
@@ -17,9 +18,6 @@
1718
ContactFieldType,
1819
)
1920

20-
import hashlib
21-
import re
22-
2321

2422
def normalize_vcard_for_hash(vcard_text: str) -> str:
2523
"""Normalize vCard text before hashing to ensure stable hashes.
@@ -103,6 +101,8 @@ def compute_vcard_hash(vcard_text: str) -> str:
103101
"""
104102
normalized = normalize_vcard_for_hash(vcard_text)
105103
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
104+
105+
106106
def contact_to_vcard(
107107
contact: Contact,
108108
fields: list[ContactField],
@@ -229,6 +229,7 @@ def contact_to_vcard(
229229
vcard_text = card.serialize()
230230
return vcard_text, compute_vcard_hash(vcard_text)
231231

232+
232233
def vcard_to_contact_data(vcard_text: str) -> dict:
233234
"""Parse a vCard string and return a dict of Contact fields + related data.
234235

0 commit comments

Comments
 (0)