Branch data Line data Source code
1 : : #include "CardDavClient.h"
2 : :
3 : : #include <QDomDocument>
4 : : #include <QLoggingCategory>
5 : : #include <QNetworkAccessManager>
6 : : #include <QNetworkReply>
7 : : #include <QNetworkRequest>
8 : : #include <QRegularExpression>
9 : :
10 : : #include "DavNetworkLimits.h"
11 : : #include "DavXmlHelper.h"
12 : :
13 [ + + + - : 75 : Q_LOGGING_CATEGORY(lcCardDav, "mailjd.carddav")
+ - - - ]
14 : :
15 : : using namespace DavXmlHelper;
16 : :
17 : 31 : CardDavClient::CardDavClient(const QString &serverUrl,
18 : : const QString &username,
19 : 31 : const QString &password, QObject *parent)
20 : 31 : : QObject(parent), m_serverUrl(serverUrl), m_username(username),
21 : 62 : m_password(password) {
22 [ + - + + ]: 31 : if (m_serverUrl.endsWith('/'))
23 [ + - ]: 2 : m_serverUrl.chop(1);
24 [ + - + - : 31 : m_nam = new QNetworkAccessManager(this);
- + - - ]
25 [ + - ]: 31 : m_nam->setRedirectPolicy(QNetworkRequest::SameOriginRedirectPolicy);
26 : 31 : }
27 : :
28 : 21 : void CardDavClient::setNetworkAccessManager(QNetworkAccessManager *nam) {
29 [ + + + - : 21 : if (m_nam && m_nam->parent() == this)
+ + ]
30 [ + - ]: 20 : delete m_nam;
31 : 21 : m_nam = nam;
32 [ + + ]: 21 : if (m_nam) {
33 : 20 : m_nam->setParent(this);
34 : 20 : m_nam->setRedirectPolicy(QNetworkRequest::SameOriginRedirectPolicy);
35 : : }
36 : 21 : }
37 : :
38 : 21 : QByteArray CardDavClient::authHeader() const {
39 : : // T-504: Refuse to send credentials over unencrypted connections
40 [ + - ]: 21 : QUrl serverUrl(m_serverUrl);
41 [ + - + - : 21 : if (serverUrl.scheme().toLower() == QLatin1String("http")) {
+ + ]
42 [ + - + - : 4 : qCWarning(lcCardDav) << "Refusing Basic Auth over HTTP — use HTTPS";
+ - + + ]
43 : 2 : return {};
44 : : }
45 : : return "Basic " +
46 : 0 : QByteArray(
47 [ + - + - : 57 : (m_username + QStringLiteral(":") + m_password).toUtf8())
+ - ]
48 [ + - + - ]: 38 : .toBase64();
49 : 21 : }
50 : :
51 : 32 : static int effectivePort(const QUrl &url) {
52 : 32 : const int port = url.port();
53 [ + + ]: 32 : if (port >= 0)
54 : 6 : return port;
55 [ + - ]: 78 : return url.scheme().compare(QStringLiteral("https"), Qt::CaseInsensitive) == 0
56 [ + + ]: 26 : ? 443
57 : 26 : : 80;
58 : : }
59 : :
60 : 20 : QString CardDavClient::resolveDavUrl(const QString &href) const {
61 [ + - ]: 20 : QUrl server(m_serverUrl);
62 : 20 : QUrl base = server;
63 [ + - + - : 20 : if (!base.path().endsWith(QLatin1Char('/')))
+ + ]
64 [ + - + - : 38 : base.setPath(base.path() + QLatin1Char('/'));
+ - ]
65 : :
66 [ + - ]: 20 : QUrl candidate(href);
67 [ + - + + : 20 : QUrl resolved = candidate.isRelative() ? base.resolved(candidate) : candidate;
+ - ]
68 [ + - ]: 19 : if (!resolved.isValid() ||
69 [ + - + - : 77 : resolved.scheme().compare(server.scheme(), Qt::CaseInsensitive) != 0 ||
+ + + + +
+ - - -
- ]
70 [ + - + - : 58 : resolved.host().compare(server.host(), Qt::CaseInsensitive) != 0 ||
+ + + + -
- - - ]
71 [ + - + - : 16 : effectivePort(resolved) != effectivePort(server) ||
+ + ]
72 [ + - + + : 53 : !resolved.userName().isEmpty() || !resolved.password().isEmpty()) {
+ - + + +
- + + + +
+ + + + -
- - - ]
73 [ + - + - : 14 : qCWarning(lcCardDav) << "Rejected cross-origin DAV URL:" << href;
+ - + - +
+ ]
74 : 7 : return {};
75 : : }
76 : :
77 [ + - ]: 13 : return resolved.toString();
78 : 20 : }
79 : :
80 : 9 : void CardDavClient::discoverAddressBooks() {
81 : : // T-505: URL-encode username to prevent path traversal
82 : 9 : QString url = m_serverUrl +
83 : 18 : QStringLiteral("/remote.php/dav/addressbooks/users/%1/")
84 [ + - + - : 9 : .arg(QString::fromUtf8(QUrl::toPercentEncoding(m_username)));
+ - + - ]
85 : :
86 [ + - + - ]: 9 : QNetworkRequest request{QUrl(url)};
87 [ + - + - : 9 : request.setRawHeader("Authorization", authHeader());
+ - ]
88 [ + - + - : 9 : request.setRawHeader("Depth", "1");
+ - ]
89 [ + - + - : 9 : request.setRawHeader("Content-Type", "application/xml; charset=utf-8");
+ - ]
90 : :
91 : : QByteArray body =
92 : : "<?xml version=\"1.0\" encoding=\"utf-8\" ?>"
93 : : "<d:propfind xmlns:d=\"DAV:\">"
94 : : " <d:prop><d:resourcetype/><d:displayname/></d:prop>"
95 [ + - ]: 9 : "</d:propfind>";
96 : :
97 [ + - + - ]: 9 : auto *reply = m_nam->sendCustomRequest(request, "PROPFIND", body);
98 [ + - ]: 9 : DavNetworkLimits::apply(reply);
99 [ + - ]: 9 : connect(reply, &QNetworkReply::finished, this, [this, reply]() {
100 : 9 : onDiscoverReply(reply);
101 : 9 : });
102 : 9 : }
103 : :
104 : 9 : void CardDavClient::onDiscoverReply(QNetworkReply *reply) {
105 [ + - ]: 9 : reply->deleteLater();
106 : :
107 [ + - ]: 9 : const QString limitError = DavNetworkLimits::failureReason(reply);
108 [ + + ]: 9 : if (!limitError.isEmpty()) {
109 [ + - + - : 2 : qCWarning(lcCardDav) << "PROPFIND failed:" << limitError;
+ - + - +
+ ]
110 [ + - ]: 1 : emit syncFailed(limitError);
111 : 1 : return;
112 : : }
113 : :
114 [ + - + + ]: 8 : if (reply->error() != QNetworkReply::NoError) {
115 [ + - + - : 4 : qCWarning(lcCardDav) << "PROPFIND failed:" << reply->errorString();
+ - + - +
- + + ]
116 [ + - + - ]: 2 : emit syncFailed(reply->errorString());
117 : 2 : return;
118 : : }
119 : :
120 [ + - ]: 6 : QByteArray data = reply->readAll();
121 [ + - ]: 6 : QDomDocument doc;
122 : : // T-511: Check XML parse result
123 [ + - + + ]: 6 : if (!doc.setContent(data)) {
124 [ + - + - : 2 : qCWarning(lcCardDav) << "Failed to parse XML response";
+ - + + ]
125 [ + - ]: 1 : emit syncFailed(QStringLiteral("Invalid XML response"));
126 : 1 : return;
127 : : }
128 : :
129 : 5 : QList<AddressBookInfo> books;
130 : : QDomNodeList responses = findElementsByLocalNameDoc(doc,
131 [ + - ]: 5 : QStringLiteral("response"));
132 : :
133 [ + - + + ]: 11 : for (int i = 0; i < responses.count(); ++i) {
134 [ + - + - ]: 6 : QDomElement resp = responses.at(i).toElement();
135 : :
136 : : // Check for addressbook resourcetype
137 : 6 : bool isAddressBook = false;
138 : :
139 : : QDomNodeList resourceTypes = findElementsByLocalName(resp,
140 [ + - ]: 6 : QStringLiteral("resourcetype"));
141 : :
142 [ + - + + ]: 8 : for (int j = 0; j < resourceTypes.count(); ++j) {
143 : 7 : QString rtXml;
144 [ + - ]: 7 : QTextStream ts(&rtXml);
145 [ + - + - ]: 7 : resourceTypes.at(j).save(ts, 0);
146 [ + - + + ]: 7 : if (rtXml.contains(QStringLiteral("addressbook"),
147 : : Qt::CaseInsensitive)) {
148 : 5 : isAddressBook = true;
149 : 5 : break;
150 : : }
151 [ + + + + ]: 12 : }
152 : :
153 [ + + ]: 6 : if (isAddressBook) {
154 : : // Extract href
155 : 5 : QString href;
156 : : QDomNodeList hrefs = findElementsByLocalName(resp,
157 [ + - ]: 5 : QStringLiteral("href"));
158 [ + - + + ]: 5 : if (!hrefs.isEmpty())
159 [ + - + - : 4 : href = hrefs.at(0).toElement().text();
+ - ]
160 : :
161 : : // Extract displayname
162 : 5 : QString displayName;
163 : : QDomNodeList nameNodes = findElementsByLocalName(resp,
164 [ + - ]: 5 : QStringLiteral("displayname"));
165 [ + - + + ]: 5 : if (!nameNodes.isEmpty())
166 [ + - + - : 4 : displayName = nameNodes.at(0).toElement().text().trimmed();
+ - + - ]
167 : :
168 : : // Fallback: extract name from path
169 [ + + ]: 5 : if (displayName.isEmpty()) {
170 : 2 : displayName = href;
171 [ + - - + ]: 2 : if (displayName.endsWith(QLatin1Char('/')))
172 [ # # ]: 0 : displayName.chop(1);
173 : : displayName =
174 [ + - ]: 2 : displayName.mid(displayName.lastIndexOf(QLatin1Char('/')) + 1);
175 : : }
176 : :
177 [ + + ]: 5 : if (!href.isEmpty()) {
178 : 4 : books.append({href, displayName});
179 [ + - + - : 8 : qCDebug(lcCardDav) << "Book:" << displayName << "at" << href;
+ - + - +
- + - +
+ ]
180 : : }
181 : 5 : }
182 : 6 : }
183 : :
184 [ + - + - : 10 : qCInfo(lcCardDav) << "Discovered" << books.size() << "address books";
+ - + - +
- + + ]
185 [ + - ]: 5 : emit addressBooksDiscovered(books);
186 [ + - + + : 15 : }
+ + + + -
- ]
187 : :
188 : 16 : void CardDavClient::syncAddressBook(const QString &bookPath) {
189 [ + - ]: 16 : QString url = resolveDavUrl(bookPath);
190 [ + + ]: 16 : if (url.isEmpty()) {
191 [ + - ]: 4 : emit syncFailed(QStringLiteral("Cross-origin URL rejected"));
192 : 4 : return;
193 : : }
194 : :
195 [ + - + - ]: 12 : QNetworkRequest request{QUrl(url)};
196 [ + - + - : 12 : request.setRawHeader("Authorization", authHeader());
+ - ]
197 [ + - + - : 12 : request.setRawHeader("Depth", "1");
+ - ]
198 [ + - + - : 12 : request.setRawHeader("Content-Type", "application/xml; charset=utf-8");
+ - ]
199 : :
200 : : // REPORT with addressbook-query to fetch all contacts
201 : : QByteArray body =
202 : : "<?xml version=\"1.0\" encoding=\"utf-8\" ?>"
203 : : "<card:addressbook-query xmlns:d=\"DAV:\" "
204 : : " xmlns:card=\"urn:ietf:params:xml:ns:carddav\">"
205 : : " <d:prop>"
206 : : " <d:getetag/>"
207 : : " <card:address-data/>"
208 : : " </d:prop>"
209 [ + - ]: 12 : "</card:addressbook-query>";
210 : :
211 [ + - + - ]: 12 : auto *reply = m_nam->sendCustomRequest(request, "REPORT", body);
212 [ + - ]: 12 : DavNetworkLimits::apply(reply);
213 [ + - ]: 12 : connect(reply, &QNetworkReply::finished, this, [this, reply]() {
214 : 12 : onSyncReply(reply);
215 : 12 : });
216 [ + + ]: 16 : }
217 : :
218 : 12 : void CardDavClient::onSyncReply(QNetworkReply *reply) {
219 [ + - ]: 12 : reply->deleteLater();
220 : :
221 [ + - ]: 12 : const QString limitError = DavNetworkLimits::failureReason(reply);
222 [ + + ]: 12 : if (!limitError.isEmpty()) {
223 [ + - + - : 4 : qCWarning(lcCardDav) << "REPORT failed:" << limitError;
+ - + - +
+ ]
224 [ + - ]: 2 : emit syncFailed(limitError);
225 : 2 : return;
226 : : }
227 : :
228 [ + - + + ]: 10 : if (reply->error() != QNetworkReply::NoError) {
229 [ + - + - : 4 : qCWarning(lcCardDav) << "REPORT failed:" << reply->errorString();
+ - + - +
- + + ]
230 [ + - + - ]: 2 : emit syncFailed(reply->errorString());
231 : 2 : return;
232 : : }
233 : :
234 [ + - ]: 8 : QByteArray data = reply->readAll();
235 [ + - + - : 16 : qCDebug(lcCardDav) << "REPORT response:" << data.size() << "bytes";
+ - + - +
- + + ]
236 : :
237 : 8 : int skipped = 0;
238 : 8 : QList<Contact> contacts;
239 : 8 : QString parseError;
240 [ + - + + ]: 8 : if (!parseVCards(data, &contacts, &skipped, &parseError)) {
241 [ + - + - : 2 : qCWarning(lcCardDav) << "REPORT parse failed:" << parseError;
+ - + - +
+ ]
242 [ + - ]: 1 : emit syncFailed(parseError);
243 : 1 : return;
244 : : }
245 : :
246 [ + - + - : 14 : qCInfo(lcCardDav) << "Synced" << contacts.size() << "contacts,"
+ - + - +
- + + ]
247 [ + - + - ]: 7 : << skipped << "skipped (no email)";
248 [ + - ]: 7 : emit contactsSynced(contacts, skipped);
249 [ + + + + : 15 : }
+ + + + ]
250 : :
251 : 7 : QList<Contact> CardDavClient::parseVCards(const QByteArray &data,
252 : : int *skippedOut) {
253 : 7 : QList<Contact> contacts;
254 : 7 : QString error;
255 [ + - ]: 7 : parseVCards(data, &contacts, skippedOut, &error);
256 : 7 : return contacts;
257 : 7 : }
258 : :
259 : 16 : bool CardDavClient::parseVCards(const QByteArray &data,
260 : : QList<Contact> *contacts,
261 : : int *skippedOut,
262 : : QString *error) {
263 [ + - ]: 16 : if (contacts)
264 [ + - ]: 16 : contacts->clear();
265 [ - + ]: 16 : if (!contacts) {
266 [ # # ]: 0 : if (error)
267 : 0 : *error = QStringLiteral("Invalid parser output");
268 : 0 : return false;
269 : : }
270 [ + + ]: 16 : if (skippedOut)
271 : 12 : *skippedOut = 0;
272 : :
273 : : // Parse the multistatus XML to extract vCard data and etags
274 [ + - ]: 16 : QDomDocument doc;
275 : : // T-511: Check XML parse result
276 [ + - + + ]: 16 : if (!doc.setContent(data)) {
277 [ + - + - : 4 : qCWarning(lcCardDav) << "Failed to parse XML response";
+ - + + ]
278 [ + - ]: 2 : if (error)
279 : 2 : *error = QStringLiteral("Invalid XML response");
280 : 2 : return false;
281 : : }
282 : :
283 : : QDomNodeList responses = findElementsByLocalNameDoc(doc,
284 [ + - ]: 14 : QStringLiteral("response"));
285 : :
286 [ + - + - : 28 : qCDebug(lcCardDav) << "Found" << responses.count()
+ - + - +
- + + ]
287 [ + - ]: 14 : << "response elements in XML";
288 : :
289 : : // Regex for unfolding vCard lines (RFC 6350 §3.2: CRLF + space/tab)
290 : : static QRegularExpression foldingRegex(
291 [ + + + - : 17 : QStringLiteral("\\r?\\n[ \\t]"));
+ - - - ]
292 : :
293 : : // Match EMAIL with optional item/group prefix:
294 : : // "EMAIL;TYPE=...:addr"
295 : : // "item1.EMAIL;TYPE=...:addr"
296 : : // "X-GOOGLE-PROPERTY.EMAIL;...:addr"
297 : : // etc.
298 : : static QRegularExpression emailRegex(
299 : 6 : QStringLiteral(R"(^(?:[\w-]+\.)?EMAIL[^:]*:(.+)$)"),
300 : : QRegularExpression::MultilineOption |
301 [ + + + - : 17 : QRegularExpression::CaseInsensitiveOption);
+ - - - ]
302 : :
303 : : static QRegularExpression uidRegex(
304 : 6 : QStringLiteral(R"(^UID[^:]*:(.+)$)"),
305 [ + + + - : 20 : QRegularExpression::MultilineOption);
+ - - - ]
306 : : static QRegularExpression fnRegex(
307 : 6 : QStringLiteral(R"(^(?:[\w-]+\.)?FN[^:]*:(.+)$)"),
308 [ + + + - : 20 : QRegularExpression::MultilineOption);
+ - - - ]
309 : :
310 : 14 : int skippedNoVcard = 0;
311 : 14 : int skippedNoEmail = 0;
312 : :
313 [ + - + + ]: 29 : for (int i = 0; i < responses.count(); ++i) {
314 [ + - + - ]: 15 : QDomElement resp = responses.at(i).toElement();
315 : :
316 : : // Get etag
317 : 15 : QString etag;
318 : : QDomNodeList etagNodes = findElementsByLocalName(resp,
319 [ + - ]: 15 : QStringLiteral("getetag"));
320 [ + - + + ]: 15 : if (!etagNodes.isEmpty())
321 [ + - + - : 11 : etag = etagNodes.at(0).toElement().text();
+ - ]
322 : :
323 : : // Get address-data (vCard content)
324 : 15 : QString vcard;
325 : : QDomNodeList addrNodes = findElementsByLocalName(resp,
326 [ + - ]: 15 : QStringLiteral("address-data"));
327 [ + - + + ]: 15 : if (addrNodes.isEmpty()) {
328 : 1 : ++skippedNoVcard;
329 : 1 : continue;
330 : : }
331 [ + - + - : 14 : vcard = addrNodes.at(0).toElement().text();
+ - ]
332 : :
333 [ + + ]: 14 : if (vcard.isEmpty()) {
334 : 1 : ++skippedNoVcard;
335 : 1 : continue;
336 : : }
337 : :
338 : : // Unfold vCard lines (continuation lines start with space/tab)
339 [ + - ]: 13 : vcard.replace(foldingRegex, QString());
340 : :
341 : : // Normalize CRLF to LF for consistent regex matching
342 [ + - ]: 26 : vcard.replace(QStringLiteral("\r\n"), QStringLiteral("\n"));
343 [ + - ]: 13 : vcard.replace(QLatin1Char('\r'), QLatin1Char('\n'));
344 : :
345 : : // Parse the vCard
346 [ + - ]: 13 : auto uidMatch = uidRegex.match(vcard);
347 [ + - + - : 34 : QString uid = uidMatch.hasMatch() ? uidMatch.captured(1).trimmed()
+ + - - ]
348 [ + + + - ]: 21 : : QString();
349 : :
350 [ + - ]: 13 : auto fnMatch = fnRegex.match(vcard);
351 : : QString fn =
352 [ + - + + : 13 : fnMatch.hasMatch() ? fnMatch.captured(1).trimmed() : QString();
+ - + - +
+ - - ]
353 : :
354 : : // Collect all EMAIL entries
355 [ + - ]: 13 : auto emailIt = emailRegex.globalMatch(vcard);
356 : 13 : bool hasEmail = false;
357 [ + - + + ]: 26 : while (emailIt.hasNext()) {
358 [ + - ]: 13 : auto match = emailIt.next();
359 [ + - + - : 13 : QString email = match.captured(1).trimmed().toLower();
+ - ]
360 [ + + ]: 13 : if (email.isEmpty())
361 : 1 : continue;
362 : :
363 : 12 : Contact c;
364 [ + + ]: 12 : c.displayName = fn.isEmpty() ? email : fn;
365 : 12 : c.email = email;
366 : 12 : c.source = QStringLiteral("carddav");
367 : 12 : c.cardDavUid = uid;
368 : 12 : c.cardDavEtag = etag;
369 [ + - ]: 12 : contacts->append(c);
370 : 12 : hasEmail = true;
371 [ + + + + ]: 14 : }
372 : :
373 [ + + ]: 13 : if (!hasEmail) {
374 : 3 : ++skippedNoEmail;
375 [ + - + - : 6 : qCDebug(lcCardDav) << "Skipped (no email):" << uid << fn;
+ - + - +
- + + ]
376 : : }
377 [ + + + + : 23 : }
+ + + + +
+ ]
378 : :
379 [ + - + - : 28 : qCDebug(lcCardDav) << "Parse results: contacts=" << contacts->size()
+ - + - +
+ ]
380 [ + - + - ]: 14 : << "skippedNoVcard=" << skippedNoVcard
381 [ + - + - ]: 14 : << "skippedNoEmail=" << skippedNoEmail
382 [ + - + - : 14 : << "totalResponses=" << responses.count();
+ - ]
383 : :
384 [ + + ]: 14 : if (skippedOut)
385 : 10 : *skippedOut = skippedNoEmail;
386 : :
387 : 14 : return true;
388 : 16 : }
|