Branch data Line data Source code
1 : : #include "CalDavClient.h"
2 : :
3 : : #include <QDomDocument>
4 : : #include <QLoggingCategory>
5 : : #include <QNetworkAccessManager>
6 : : #include <QNetworkReply>
7 : : #include <QNetworkRequest>
8 : : #include <QRegularExpression>
9 : : #include <QTimeZone>
10 : : #include <QTimer>
11 : :
12 : : #include "DavNetworkLimits.h"
13 : : #include "DavXmlHelper.h"
14 : :
15 [ + + + - : 140 : Q_LOGGING_CATEGORY(lcCalDav, "mailjd.caldav")
+ - - - ]
16 : :
17 : : using namespace DavXmlHelper;
18 : :
19 : 69 : CalDavClient::CalDavClient(const QString &serverUrl, const QString &username,
20 : 69 : const QString &password, QObject *parent)
21 : 69 : : QObject(parent), m_serverUrl(serverUrl), m_username(username),
22 : 138 : m_password(password) {
23 [ + - + + ]: 69 : if (m_serverUrl.endsWith('/'))
24 [ + - ]: 5 : m_serverUrl.chop(1);
25 [ + - + - : 69 : m_nam = new QNetworkAccessManager(this);
- + - - ]
26 [ + - ]: 69 : m_nam->setRedirectPolicy(QNetworkRequest::SameOriginRedirectPolicy);
27 : :
28 : : // T-79.F1/H8: inactivity watchdog. Started at construction so an idle
29 : : // client (caller never issued a request) still releases itself; rearmed
30 : : // on every request and every finished reply.
31 [ + - + - : 69 : m_inactivityTimer = new QTimer(this);
- + - - ]
32 [ + - ]: 69 : m_inactivityTimer->setSingleShot(true);
33 [ + - ]: 69 : m_inactivityTimer->setInterval(kInactivityTimeoutMs);
34 : 69 : connect(m_inactivityTimer, &QTimer::timeout, this,
35 [ + - ]: 69 : &CalDavClient::onInactivityTimeout);
36 [ + - ]: 69 : m_inactivityTimer->start();
37 : 69 : }
38 : :
39 : : // T-79.F1/H8: register a dispatched reply for lifetime tracking. The
40 : : // decrement runs as a QUEUED handler so it always executes after the
41 : : // reply's direct business handler — result signals (e.g.
42 : : // calendarsDiscovered) synchronously start follow-up requests, which must
43 : : // be counted before the zero check runs.
44 : 86 : void CalDavClient::trackReply(QNetworkReply *reply) {
45 : 86 : ++m_pendingReplies;
46 [ + - ]: 86 : m_trackedReplies.insert(reply);
47 : 86 : m_inactivityTimer->start();
48 : 86 : connect(
49 : : reply, &QNetworkReply::finished, this,
50 [ + - ]: 86 : [this, reply]() {
51 : 35 : m_trackedReplies.remove(reply);
52 : 35 : m_inactivityTimer->start();
53 [ + + ]: 35 : if (--m_pendingReplies == 0) {
54 : 3 : m_inactivityTimer->stop();
55 : 3 : emit allRequestsFinished();
56 : : }
57 : 35 : },
58 : : Qt::QueuedConnection);
59 : 86 : }
60 : :
61 : 1 : void CalDavClient::onInactivityTimeout() {
62 [ - + ]: 1 : if (m_pendingReplies <= 0) {
63 : : // Idle client that never got (or has finished) its work — release it.
64 [ # # ]: 0 : emit allRequestsFinished();
65 : 0 : return;
66 : : }
67 [ + - + - : 2 : qCWarning(lcCalDav) << "CalDAV inactivity timeout with" << m_pendingReplies
+ - + - +
+ ]
68 [ + - ]: 1 : << "outstanding replies — aborting";
69 : 1 : m_pendingReplies = 0;
70 : 1 : const auto replies = m_trackedReplies;
71 : 1 : m_trackedReplies.clear();
72 [ + + ]: 2 : for (QNetworkReply *reply : replies) {
73 [ + - ]: 1 : reply->disconnect(this); // no late business/tracking callbacks
74 [ + - ]: 1 : reply->abort();
75 [ + - ]: 1 : reply->deleteLater();
76 : : }
77 : : // Both signals so sync callers surface the error and write callers can
78 : : // roll back their optimistic local change.
79 [ + - ]: 1 : emit syncFailed(QStringLiteral("CalDAV request timed out"));
80 [ + - ]: 1 : emit writeFailed(QStringLiteral("CalDAV request timed out"));
81 [ + - ]: 1 : emit allRequestsFinished();
82 : 1 : }
83 : :
84 : 46 : void CalDavClient::setNetworkAccessManager(QNetworkAccessManager *nam) {
85 [ + + + - : 46 : if (m_nam && m_nam->parent() == this)
+ + ]
86 [ + - ]: 45 : delete m_nam;
87 : 46 : m_nam = nam;
88 [ + + ]: 46 : if (m_nam) {
89 : 45 : m_nam->setParent(this);
90 : 45 : m_nam->setRedirectPolicy(QNetworkRequest::SameOriginRedirectPolicy);
91 : : }
92 : 46 : }
93 : :
94 : 92 : QByteArray CalDavClient::authHeader() const {
95 : : // No credentials → no auth header (e.g. testing with auth-free server)
96 [ + + ]: 92 : if (m_username.isEmpty())
97 : 6 : return {};
98 : : // T-504: Refuse to send credentials over unencrypted connections
99 [ + - ]: 86 : QUrl serverUrl(m_serverUrl);
100 [ + - + - : 86 : if (serverUrl.scheme().toLower() == QLatin1String("http")) {
+ + ]
101 [ + - + - : 22 : qCWarning(lcCalDav) << "Refusing Basic Auth over HTTP — use HTTPS";
+ - + + ]
102 : 11 : return {};
103 : : }
104 : : return "Basic " +
105 : 0 : QByteArray(
106 [ + - + - : 225 : (m_username + QStringLiteral(":") + m_password).toUtf8())
+ - ]
107 [ + - + - ]: 150 : .toBase64();
108 : 86 : }
109 : :
110 : 38 : bool CalDavClient::isValidEtag(const QString &etag) {
111 [ + - ]: 38 : const QByteArray bytes = etag.toUtf8();
112 : 38 : qsizetype offset = 0;
113 : : // If-Match uses strong comparison, so weak entity-tags can never match.
114 [ + + ]: 38 : if (bytes.startsWith("W/"))
115 : 1 : return false;
116 [ + + + + : 66 : if (bytes.size() < offset + 2 || bytes.at(offset) != '"' ||
+ + ]
117 [ + - + + ]: 29 : bytes.back() != '"') {
118 : 10 : return false;
119 : : }
120 : :
121 : : // RFC 9110 section 8.8.3: entity-tag = [ weak ] opaque-tag,
122 : : // etagc = %x21 / %x23-7E / obs-text. Validate the encoded field value,
123 : : // because that is what QNetworkRequest receives.
124 [ + + ]: 134 : for (qsizetype i = offset + 1; i < bytes.size() - 1; ++i) {
125 : 110 : const auto value = static_cast<unsigned char>(bytes.at(i));
126 [ + + + + : 110 : if (value != 0x21 && !(value >= 0x23 && value <= 0x7E) && value < 0x80)
+ + + + ]
127 : 3 : return false;
128 : : }
129 : 24 : return true;
130 : 38 : }
131 : :
132 : 208 : static int effectivePort(const QUrl &url) {
133 : 208 : const int port = url.port();
134 [ + + ]: 208 : if (port >= 0)
135 : 25 : return port;
136 [ + - ]: 549 : return url.scheme().compare(QStringLiteral("https"), Qt::CaseInsensitive) == 0
137 [ + + ]: 183 : ? 443
138 : 183 : : 80;
139 : : }
140 : :
141 : 123 : QString CalDavClient::resolveDavUrl(const QString &href) const {
142 [ + - ]: 123 : QUrl server(m_serverUrl);
143 : 123 : QUrl base = server;
144 [ + - + - : 123 : if (!base.path().endsWith(QLatin1Char('/')))
+ + ]
145 [ + - + - : 244 : base.setPath(base.path() + QLatin1Char('/'));
+ - ]
146 : :
147 [ + - ]: 123 : QUrl candidate(href);
148 [ + - + + : 123 : QUrl resolved = candidate.isRelative() ? base.resolved(candidate) : candidate;
+ - ]
149 [ + + ]: 122 : if (!resolved.isValid() ||
150 [ + - + - : 485 : resolved.scheme().compare(server.scheme(), Qt::CaseInsensitive) != 0 ||
+ + + + +
+ - - -
- ]
151 [ + - + - : 363 : resolved.host().compare(server.host(), Qt::CaseInsensitive) != 0 ||
+ + + + -
- - - ]
152 [ + - + - : 104 : effectivePort(resolved) != effectivePort(server) ||
+ + ]
153 [ + - + + : 346 : !resolved.userName().isEmpty() || !resolved.password().isEmpty()) {
+ - + + +
- + + + +
+ + + + -
- - - ]
154 [ + - + - : 46 : qCWarning(lcCalDav) << "Rejected cross-origin DAV URL:" << href;
+ - + - +
+ ]
155 : 23 : return {};
156 : : }
157 : :
158 [ + - ]: 100 : return resolved.toString();
159 : 123 : }
160 : :
161 : : // ═══════════════════════════════════════════════════════
162 : : // Calendar Discovery (PROPFIND)
163 : : // ═══════════════════════════════════════════════════════
164 : :
165 : 16 : void CalDavClient::discoverCalendars() {
166 : : // T-505: URL-encode username to prevent path traversal
167 : : QString url =
168 : 16 : m_serverUrl +
169 : 32 : QStringLiteral("/remote.php/dav/calendars/%1/")
170 [ + - + - : 16 : .arg(QString::fromUtf8(QUrl::toPercentEncoding(m_username)));
+ - + - ]
171 : :
172 [ + - + - ]: 16 : QNetworkRequest request{QUrl(url)};
173 [ + - + - : 16 : request.setRawHeader("Authorization", authHeader());
+ - ]
174 [ + - + - : 16 : request.setRawHeader("Depth", "1");
+ - ]
175 [ + - + - : 16 : request.setRawHeader("Content-Type", "application/xml; charset=utf-8");
+ - ]
176 : :
177 : : QByteArray body =
178 : : "<?xml version=\"1.0\" encoding=\"utf-8\" ?>"
179 : : "<d:propfind xmlns:d=\"DAV:\" "
180 : : " xmlns:cs=\"http://calendarserver.org/ns/\" "
181 : : " xmlns:apple=\"http://apple.com/ns/ical/\">"
182 : : " <d:prop>"
183 : : " <d:displayname/>"
184 : : " <d:resourcetype/>"
185 : : " <cs:getctag/>"
186 : : " <apple:calendar-color/>"
187 : : " </d:prop>"
188 [ + - ]: 16 : "</d:propfind>";
189 : :
190 [ + - + - ]: 16 : auto *reply = m_nam->sendCustomRequest(request, "PROPFIND", body);
191 [ + - ]: 16 : DavNetworkLimits::apply(reply);
192 [ + - ]: 16 : trackReply(reply); // T-79.F1: lifetime follows request completion
193 [ + - ]: 16 : connect(reply, &QNetworkReply::finished, this, [this, reply]() {
194 : 12 : onDiscoverReply(reply);
195 : 12 : });
196 : 16 : }
197 : :
198 : 12 : void CalDavClient::onDiscoverReply(QNetworkReply *reply) {
199 [ + - ]: 12 : reply->deleteLater();
200 : :
201 [ + - ]: 12 : const QString limitError = DavNetworkLimits::failureReason(reply);
202 [ + + ]: 12 : if (!limitError.isEmpty()) {
203 [ + - + - : 2 : qCWarning(lcCalDav) << "PROPFIND failed:" << limitError;
+ - + - +
+ ]
204 [ + - ]: 1 : emit syncFailed(limitError);
205 : 1 : return;
206 : : }
207 : :
208 [ + - + + ]: 11 : if (reply->error() != QNetworkReply::NoError) {
209 [ + - + - : 4 : qCWarning(lcCalDav) << "PROPFIND failed:" << reply->errorString();
+ - + - +
- + + ]
210 [ + - + - ]: 2 : emit syncFailed(reply->errorString());
211 : 2 : return;
212 : : }
213 : :
214 [ + - ]: 9 : QByteArray data = reply->readAll();
215 [ + - + - : 18 : qCDebug(lcCalDav) << "PROPFIND response:" << data.left(2000);
+ - + - +
- + + ]
216 [ + - ]: 9 : QDomDocument doc;
217 : : // T-511: Check XML parse result
218 [ + - + + ]: 9 : if (!doc.setContent(data)) {
219 [ + - + - : 2 : qCWarning(lcCalDav) << "Failed to parse XML response";
+ - + + ]
220 [ + - ]: 1 : emit syncFailed(QStringLiteral("Invalid XML response"));
221 : 1 : return;
222 : : }
223 : :
224 : 8 : QList<CalendarInfo> calendars;
225 : : QDomNodeList responses =
226 [ + - ]: 8 : findElementsByLocalNameDoc(doc, QStringLiteral("response"));
227 : :
228 [ + - + + ]: 18 : for (int i = 0; i < responses.count(); ++i) {
229 [ + - + - ]: 10 : QDomElement resp = responses.at(i).toElement();
230 : :
231 : : // Check for calendar resourcetype
232 : 10 : bool isCalendar = false;
233 : : QDomNodeList resourceTypes =
234 [ + - ]: 10 : findElementsByLocalName(resp, QStringLiteral("resourcetype"));
235 [ + - + + ]: 13 : for (int j = 0; j < resourceTypes.count(); ++j) {
236 : 10 : QString rtXml;
237 [ + - ]: 10 : QTextStream ts(&rtXml);
238 [ + - + - ]: 10 : resourceTypes.at(j).save(ts, 0);
239 [ + - + + ]: 10 : if (rtXml.contains(QStringLiteral("calendar"),
240 : : Qt::CaseInsensitive)) {
241 : 7 : isCalendar = true;
242 : 7 : break;
243 : : }
244 [ + + + + ]: 17 : }
245 : :
246 [ + + ]: 10 : if (!isCalendar)
247 : 3 : continue;
248 : :
249 : : // Extract href
250 : 7 : QString href;
251 : : QDomNodeList hrefs =
252 [ + - ]: 7 : findElementsByLocalName(resp, QStringLiteral("href"));
253 [ + - + + ]: 7 : if (!hrefs.isEmpty())
254 [ + - + - : 6 : href = hrefs.at(0).toElement().text();
+ - ]
255 : :
256 : : // Extract displayname
257 : 7 : QString displayName;
258 : : QDomNodeList nameNodes =
259 [ + - ]: 7 : findElementsByLocalName(resp, QStringLiteral("displayname"));
260 [ + - + + ]: 7 : if (!nameNodes.isEmpty())
261 [ + - + - : 4 : displayName = nameNodes.at(0).toElement().text().trimmed();
+ - + - ]
262 : :
263 : : // Fallback: name from path
264 [ + + ]: 7 : if (displayName.isEmpty()) {
265 : 4 : displayName = href;
266 [ + - + + ]: 4 : if (displayName.endsWith(QLatin1Char('/')))
267 [ + - ]: 2 : displayName.chop(1);
268 : : displayName =
269 [ + - ]: 4 : displayName.mid(displayName.lastIndexOf(QLatin1Char('/')) + 1);
270 : : }
271 : :
272 : : // Extract calendar-color (namespace-agnostic via localName())
273 : 7 : QString color;
274 : : QDomElement colorEl =
275 [ + - ]: 7 : findFirstElementByLocalName(resp, QStringLiteral("calendar-color"));
276 [ + - + + ]: 7 : if (!colorEl.isNull()) {
277 [ + - + - ]: 4 : color = colorEl.text().trimmed();
278 : : // Nextcloud sometimes returns #RRGGBBAA — strip alpha
279 [ + + + - : 4 : if (color.length() == 9 && color.startsWith('#'))
+ - + + ]
280 [ + - ]: 1 : color = color.left(7);
281 : : }
282 : :
283 : : // Extract CTag
284 : 7 : QString ctag;
285 : : QDomNodeList ctagNodes =
286 [ + - ]: 7 : findElementsByLocalName(resp, QStringLiteral("getctag"));
287 [ + - + + ]: 7 : if (!ctagNodes.isEmpty())
288 [ + - + - : 3 : ctag = ctagNodes.at(0).toElement().text().trimmed();
+ - + - ]
289 : :
290 [ + + ]: 7 : if (!href.isEmpty()) {
291 : 6 : CalendarInfo cal;
292 : 6 : cal.path = href;
293 : 6 : cal.displayName = displayName;
294 : 6 : cal.color = color;
295 : 6 : cal.ctag = ctag;
296 [ + - ]: 6 : calendars.append(cal);
297 [ + - + - : 12 : qCDebug(lcCalDav) << "Calendar:" << displayName << "at" << href
+ - + - +
- + - +
+ ]
298 [ + - + - ]: 6 : << "color:" << color;
299 : 6 : }
300 [ + + + + ]: 13 : }
301 : :
302 [ + - + - : 16 : qCInfo(lcCalDav) << "Discovered" << calendars.size() << "calendars";
+ - + - +
- + + ]
303 [ + - ]: 8 : emit calendarsDiscovered(calendars);
304 [ + + + + : 14 : }
+ + ]
305 : :
306 : : // ═══════════════════════════════════════════════════════
307 : : // Event Sync (REPORT calendar-query VEVENT)
308 : : // ═══════════════════════════════════════════════════════
309 : :
310 : 24 : void CalDavClient::syncCalendar(const QString &calendarPath) {
311 [ + - ]: 24 : QString url = resolveDavUrl(calendarPath);
312 [ + + ]: 24 : if (url.isEmpty()) {
313 [ + - ]: 6 : emit syncFailed(QStringLiteral("Cross-origin URL rejected"));
314 : 6 : return;
315 : : }
316 : :
317 [ + - + - ]: 18 : QNetworkRequest request{QUrl(url)};
318 [ + - + - : 18 : request.setRawHeader("Authorization", authHeader());
+ - ]
319 [ + - + - : 18 : request.setRawHeader("Depth", "1");
+ - ]
320 [ + - + - : 18 : request.setRawHeader("Content-Type", "application/xml; charset=utf-8");
+ - ]
321 : :
322 : : QByteArray body =
323 : : "<?xml version=\"1.0\" encoding=\"utf-8\" ?>"
324 : : "<c:calendar-query xmlns:d=\"DAV:\" "
325 : : " xmlns:c=\"urn:ietf:params:xml:ns:caldav\">"
326 : : " <d:prop>"
327 : : " <d:getetag/>"
328 : : " <c:calendar-data/>"
329 : : " </d:prop>"
330 : : " <c:filter>"
331 : : " <c:comp-filter name=\"VCALENDAR\">"
332 : : " <c:comp-filter name=\"VEVENT\"/>"
333 : : " </c:comp-filter>"
334 : : " </c:filter>"
335 [ + - ]: 18 : "</c:calendar-query>";
336 : :
337 [ + - + - ]: 18 : auto *reply = m_nam->sendCustomRequest(request, "REPORT", body);
338 [ + - ]: 18 : DavNetworkLimits::apply(reply);
339 [ + - ]: 18 : trackReply(reply); // T-79.F1: lifetime follows request completion
340 [ + - ]: 18 : connect(reply, &QNetworkReply::finished, this,
341 : 36 : [this, reply, calendarPath]() {
342 : 16 : onSyncEventsReply(reply, calendarPath);
343 : 16 : });
344 [ + + ]: 24 : }
345 : :
346 : 16 : void CalDavClient::onSyncEventsReply(QNetworkReply *reply,
347 : : const QString &calendarPath) {
348 [ + - ]: 16 : reply->deleteLater();
349 : :
350 [ + - ]: 16 : const QString limitError = DavNetworkLimits::failureReason(reply);
351 [ + + ]: 16 : if (!limitError.isEmpty()) {
352 [ + - + - : 2 : qCWarning(lcCalDav) << "REPORT (events) failed:" << limitError;
+ - + - +
+ ]
353 [ + - ]: 1 : emit syncFailed(limitError);
354 : 1 : return;
355 : : }
356 : :
357 [ + - + + ]: 15 : if (reply->error() != QNetworkReply::NoError) {
358 [ + - + - : 2 : qCWarning(lcCalDav) << "REPORT (events) failed:"
+ - + + ]
359 [ + - + - ]: 1 : << reply->errorString();
360 [ + - + - ]: 1 : emit syncFailed(reply->errorString());
361 : 1 : return;
362 : : }
363 : :
364 [ + - ]: 14 : QByteArray data = reply->readAll();
365 [ + - + - : 28 : qCDebug(lcCalDav) << "REPORT (events) response:" << data.size()
+ - + - +
+ ]
366 [ + - ]: 14 : << "bytes";
367 : :
368 : 14 : QList<CalendarEvent> events;
369 : 14 : QString parseError;
370 [ + - + + ]: 14 : if (!parseICalEvents(data, &events, &parseError)) {
371 [ + - + - : 6 : qCWarning(lcCalDav) << "REPORT (events) parse failed:" << parseError;
+ - + - +
+ ]
372 [ + - ]: 3 : emit syncFailed(parseError);
373 : 3 : return;
374 : : }
375 : :
376 : : // Assign calendarPath to each event
377 [ + - + - : 21 : for (auto &ev : events) {
+ + ]
378 : 11 : ev.calendarPath = calendarPath;
379 [ + + ]: 11 : if (!ev.resourceHref.isEmpty()) {
380 [ + - ]: 10 : const QString resolvedHref = resolveDavUrl(ev.resourceHref);
381 [ + + ]: 10 : if (resolvedHref.isEmpty()) {
382 [ + - ]: 1 : emit syncFailed(QStringLiteral("Cross-origin resource href rejected"));
383 : 1 : return;
384 : : }
385 : 9 : ev.resourceHref = resolvedHref;
386 [ + + ]: 10 : }
387 : : }
388 : :
389 [ + - + - : 20 : qCInfo(lcCalDav) << "Synced" << events.size() << "events from"
+ - + - +
- + + ]
390 [ + - ]: 10 : << calendarPath;
391 [ + - ]: 10 : emit eventsSynced(calendarPath, events);
392 [ + + + + : 28 : }
+ + + + ]
393 : :
394 : : // ═══════════════════════════════════════════════════════
395 : : // Task Sync (REPORT calendar-query VTODO)
396 : : // ═══════════════════════════════════════════════════════
397 : :
398 : 14 : void CalDavClient::syncTasks(const QString &calendarPath) {
399 [ + - ]: 14 : QString url = resolveDavUrl(calendarPath);
400 [ + + ]: 14 : if (url.isEmpty()) {
401 [ + - ]: 2 : emit syncFailed(QStringLiteral("Cross-origin URL rejected"));
402 : 2 : return;
403 : : }
404 : :
405 [ + - + - ]: 12 : QNetworkRequest request{QUrl(url)};
406 [ + - + - : 12 : request.setRawHeader("Authorization", authHeader());
+ - ]
407 [ + - + - : 12 : request.setRawHeader("Depth", "1");
+ - ]
408 [ + - + - : 12 : request.setRawHeader("Content-Type", "application/xml; charset=utf-8");
+ - ]
409 : :
410 : : QByteArray body =
411 : : "<?xml version=\"1.0\" encoding=\"utf-8\" ?>"
412 : : "<c:calendar-query xmlns:d=\"DAV:\" "
413 : : " xmlns:c=\"urn:ietf:params:xml:ns:caldav\">"
414 : : " <d:prop>"
415 : : " <d:getetag/>"
416 : : " <c:calendar-data/>"
417 : : " </d:prop>"
418 : : " <c:filter>"
419 : : " <c:comp-filter name=\"VCALENDAR\">"
420 : : " <c:comp-filter name=\"VTODO\"/>"
421 : : " </c:comp-filter>"
422 : : " </c:filter>"
423 [ + - ]: 12 : "</c:calendar-query>";
424 : :
425 [ + - + - ]: 12 : auto *reply = m_nam->sendCustomRequest(request, "REPORT", body);
426 [ + - ]: 12 : DavNetworkLimits::apply(reply);
427 [ + - ]: 12 : trackReply(reply); // T-79.F1: lifetime follows request completion
428 [ + - ]: 12 : connect(reply, &QNetworkReply::finished, this,
429 : 24 : [this, reply, calendarPath]() {
430 : 11 : onSyncTasksReply(reply, calendarPath);
431 : 11 : });
432 [ + + ]: 14 : }
433 : :
434 : 11 : void CalDavClient::onSyncTasksReply(QNetworkReply *reply,
435 : : const QString &calendarPath) {
436 [ + - ]: 11 : reply->deleteLater();
437 : :
438 [ + - ]: 11 : const QString limitError = DavNetworkLimits::failureReason(reply);
439 [ + + ]: 11 : if (!limitError.isEmpty()) {
440 [ + - + - : 2 : qCWarning(lcCalDav) << "REPORT (tasks) failed:" << limitError;
+ - + - +
+ ]
441 [ + - ]: 1 : emit syncFailed(limitError);
442 : 1 : return;
443 : : }
444 : :
445 [ + - + + ]: 10 : if (reply->error() != QNetworkReply::NoError) {
446 [ + - + - : 2 : qCWarning(lcCalDav) << "REPORT (tasks) failed:"
+ - + + ]
447 [ + - + - ]: 1 : << reply->errorString();
448 [ + - + - ]: 1 : emit syncFailed(reply->errorString());
449 : 1 : return;
450 : : }
451 : :
452 [ + - ]: 9 : QByteArray data = reply->readAll();
453 : 9 : QList<CalendarTask> tasks;
454 : 9 : QString parseError;
455 [ + - + + ]: 9 : if (!parseICalTasks(data, &tasks, &parseError)) {
456 [ + - + - : 4 : qCWarning(lcCalDav) << "REPORT (tasks) parse failed:" << parseError;
+ - + - +
+ ]
457 [ + - ]: 2 : emit syncFailed(parseError);
458 : 2 : return;
459 : : }
460 : :
461 [ + - + - : 16 : for (auto &t : tasks) {
+ + ]
462 : 10 : t.calendarPath = calendarPath;
463 [ + + ]: 10 : if (!t.resourceHref.isEmpty()) {
464 [ + - ]: 9 : const QString resolvedHref = resolveDavUrl(t.resourceHref);
465 [ + + ]: 9 : if (resolvedHref.isEmpty()) {
466 [ + - ]: 1 : emit syncFailed(QStringLiteral("Cross-origin resource href rejected"));
467 : 1 : return;
468 : : }
469 : 8 : t.resourceHref = resolvedHref;
470 [ + + ]: 9 : }
471 : : }
472 : :
473 [ + - + - : 12 : qCInfo(lcCalDav) << "Synced" << tasks.size() << "tasks from"
+ - + - +
- + + ]
474 [ + - ]: 6 : << calendarPath;
475 [ + - ]: 6 : emit tasksSynced(calendarPath, tasks);
476 [ + + + + : 20 : }
+ + + + ]
477 : :
478 : : // ═══════════════════════════════════════════════════════
479 : : // iCal Parsing (RFC 5545)
480 : : // ═══════════════════════════════════════════════════════
481 : :
482 : : // T-79.C1/H5: single inverse of escapeICalText() (RFC 5545 §3.3.11).
483 : : // One pass so "\\n" yields a literal backslash + n, not a newline.
484 : 127 : static QString unescapeICalText(const QString &text) {
485 : 127 : QString result;
486 [ + - ]: 127 : result.reserve(text.size());
487 [ + + ]: 723 : for (int i = 0; i < text.size(); ++i) {
488 : 596 : const QChar c = text.at(i);
489 [ + + + - : 596 : if (c == QLatin1Char('\\') && i + 1 < text.size()) {
+ + ]
490 : 17 : const QChar next = text.at(i + 1);
491 [ + + - + : 17 : if (next == QLatin1Char('n') || next == QLatin1Char('N')) {
+ + ]
492 [ + - ]: 4 : result.append(QLatin1Char('\n'));
493 : 4 : ++i;
494 : 17 : continue;
495 : : }
496 [ + + + + : 17 : if (next == QLatin1Char('\\') || next == QLatin1Char(',') ||
+ - ]
497 [ + - ]: 17 : next == QLatin1Char(';')) {
498 [ + - ]: 13 : result.append(next);
499 : 13 : ++i;
500 : 13 : continue;
501 : : }
502 : : }
503 [ + - ]: 579 : result.append(c);
504 : : }
505 : 127 : return result;
506 : 0 : }
507 : :
508 : : // Helper: parse a single iCal component block into key-value pairs
509 : : static QMap<QString, QString>
510 : 48 : parseICalBlock(const QString &block) {
511 : 48 : QMap<QString, QString> props;
512 : :
513 : : // Unfold continuation lines (RFC 5545 §3.1)
514 : : static QRegularExpression foldingRegex(
515 [ + + + - : 54 : QStringLiteral("\\r?\\n[ \\t]"));
+ - - - ]
516 : 48 : QString unfolded = block;
517 [ + - ]: 48 : unfolded.replace(foldingRegex, QString());
518 : :
519 : : // Normalize line endings
520 [ + - ]: 96 : unfolded.replace(QStringLiteral("\r\n"), QStringLiteral("\n"));
521 [ + - ]: 48 : unfolded.replace(QLatin1Char('\r'), QLatin1Char('\n'));
522 : :
523 [ + - ]: 48 : const auto lines = unfolded.split(QLatin1Char('\n'));
524 : : // T-79.C1/H4: BEGIN:/END: nesting depth. Lines of nested sub-components
525 : : // (e.g. VALARM) must be ignored — a DISPLAY alarm's DESCRIPTION would
526 : : // otherwise overwrite the event's own description.
527 : 48 : int nestedDepth = 0;
528 [ + + ]: 292 : for (const QString &line : lines) {
529 [ + + ]: 244 : if (line.isEmpty())
530 : 58 : continue;
531 : 195 : int colonPos = line.indexOf(QLatin1Char(':'));
532 [ + + ]: 195 : if (colonPos < 0)
533 : 1 : continue;
534 : :
535 [ + - ]: 194 : QString rawKey = line.left(colonPos);
536 [ + - ]: 194 : QString key = rawKey.toUpper();
537 [ + - ]: 194 : QString value = line.mid(colonPos + 1);
538 : :
539 : : // Strip parameters from key (e.g. "DTSTART;VALUE=DATE" → "DTSTART")
540 : : // But preserve the full key for VALUE=DATE detection
541 : 194 : QString baseKey = key;
542 : 194 : int semiPos = baseKey.indexOf(QLatin1Char(';'));
543 [ + + ]: 194 : if (semiPos > 0)
544 [ + - ]: 21 : baseKey = baseKey.left(semiPos);
545 : :
546 [ + + ]: 194 : if (baseKey == QStringLiteral("BEGIN")) {
547 : 1 : ++nestedDepth;
548 : 1 : continue;
549 : : }
550 [ + + ]: 193 : if (baseKey == QStringLiteral("END")) {
551 [ + - ]: 1 : if (nestedDepth > 0)
552 : 1 : --nestedDepth;
553 : 1 : continue;
554 : : }
555 [ + + ]: 192 : if (nestedDepth > 0)
556 : 4 : continue; // property belongs to a nested sub-component
557 : :
558 : : // T-79.C1/H3: EXDATE may appear on multiple lines — accumulate the
559 : : // (already comma-separated) values instead of overwriting.
560 [ + + ]: 188 : if (baseKey == QStringLiteral("EXDATE")) {
561 [ + - ]: 2 : const QString existing = props.value(baseKey);
562 [ + - ]: 4 : props.insert(baseKey, existing.isEmpty()
563 [ + + + - ]: 5 : ? value
564 [ + - + + : 3 : : existing + QLatin1Char(',') + value);
- - ]
565 : 2 : continue;
566 : 2 : }
567 : :
568 : : // For date properties and ORGANIZER, store both the base key and params
569 [ + + + - : 523 : if (baseKey == QStringLiteral("DTSTART") ||
+ + ]
570 [ + + + + : 472 : baseKey == QStringLiteral("DTEND") ||
+ - ]
571 [ + + + + : 453 : baseKey == QStringLiteral("DUE") ||
+ + ]
572 [ + + + + : 448 : baseKey == QStringLiteral("COMPLETED") ||
+ + ]
573 [ + + + + : 839 : baseKey == QStringLiteral("LAST-MODIFIED") ||
+ + + + ]
574 [ + + + + : 312 : baseKey == QStringLiteral("ORGANIZER")) {
+ + ]
575 [ + - ]: 65 : props.insert(baseKey, value);
576 [ + + ]: 65 : if (key != baseKey)
577 [ + - + - ]: 20 : props.insert(baseKey + QStringLiteral("_PARAMS"),
578 [ + - ]: 40 : rawKey.mid(semiPos + 1));
579 : : } else {
580 [ + - ]: 121 : props.insert(baseKey, value);
581 : : }
582 [ + + + + : 218 : }
+ + + + ]
583 : 48 : return props;
584 : 48 : }
585 : :
586 : 7 : static QString iCalParamValue(const QString ¶ms, const QString &name) {
587 : 14 : for (const auto ¶m : params.split(QLatin1Char(';'),
588 [ + - + - : 16 : Qt::SkipEmptyParts)) {
+ - + - ]
589 : 9 : const int eq = param.indexOf(QLatin1Char('='));
590 [ + + ]: 9 : if (eq <= 0)
591 : 1 : continue;
592 [ + - + - : 8 : if (param.left(eq).trimmed().compare(name, Qt::CaseInsensitive) == 0)
+ + ]
593 [ + - + - ]: 7 : return param.mid(eq + 1).trimmed();
594 [ - + ]: 7 : }
595 : 0 : return {};
596 : : }
597 : :
598 : : // Helper: parse iCal date/time string
599 : 63 : static QDateTime parseICalDateTime(const QString &value,
600 : : const QString ¶ms) {
601 : : // Check for VALUE=DATE (all-day event)
602 : : // The caller should set allDay based on the presence of VALUE=DATE
603 [ + + ]: 63 : if (value.length() == 8) {
604 : : // Pure date: 20260301
605 [ + - ]: 11 : QDate d = QDate::fromString(value, QStringLiteral("yyyyMMdd"));
606 [ + - + - : 32 : return d.isValid() ? QDateTime(d, QTime(0, 0), QTimeZone::utc())
- - ]
607 [ + + + - : 21 : : QDateTime();
+ - + + ]
608 : : }
609 : :
610 : : // DateTime with Z suffix: 20260301T100000Z
611 [ + - + + ]: 52 : if (value.endsWith(QLatin1Char('Z'))) {
612 [ + - ]: 45 : QString stripped = value.left(value.length() - 1);
613 : : // T-401/Bug 8: tag the wall-clock time as UTC directly — NOT .toUTC()
614 : : // which would double-shift (fromString creates LocalTime, toUTC shifts
615 : : // it again). setTimeZone(QTimeZone::utc()) replaces the deprecated
616 : : // setTimeSpec(Qt::UTC) with the identical reinterpretation semantics.
617 : : QDateTime dt =
618 [ + - ]: 45 : QDateTime::fromString(stripped, QStringLiteral("yyyyMMddTHHmmss"));
619 [ + - + - ]: 45 : dt.setTimeZone(QTimeZone::utc());
620 : 45 : return dt;
621 : 45 : }
622 : :
623 : : // DateTime without Z (local time): 20260301T100000
624 : : QDateTime dt =
625 [ + - ]: 7 : QDateTime::fromString(value, QStringLiteral("yyyyMMddTHHmmss"));
626 : :
627 : : // Check TZID in params
628 [ + - ]: 7 : const QString tzid = iCalParamValue(params, QStringLiteral("TZID"));
629 [ + - ]: 7 : if (!tzid.isEmpty()) {
630 [ + - + - ]: 7 : QTimeZone zone(tzid.toUtf8());
631 [ + - + + ]: 7 : if (zone.isValid())
632 [ + - + - : 5 : return QDateTime(dt.date(), dt.time(), zone);
+ - ]
633 [ + - + - : 4 : qCWarning(lcCalDav) << "Unknown TZID in iCalendar response:" << tzid;
+ - + - +
+ ]
634 [ + + ]: 7 : }
635 : :
636 : 2 : return dt;
637 : 7 : }
638 : :
639 : 46 : static bool validateICalendarText(const QString &icalText,
640 : : const QString &componentName,
641 : : QString *error) {
642 [ + - ]: 46 : const QString trimmed = icalText.trimmed();
643 [ - + ]: 46 : if (trimmed.isEmpty())
644 : 0 : return true;
645 : :
646 [ + - + + : 182 : if (!trimmed.contains(QStringLiteral("BEGIN:VCALENDAR")) ||
+ - + + -
- - - ]
647 [ + - + + : 90 : !trimmed.contains(QStringLiteral("END:VCALENDAR"))) {
+ + + + +
- - - -
- ]
648 [ + - ]: 3 : if (error)
649 : 3 : *error = QStringLiteral("Invalid iCalendar response");
650 : 3 : return false;
651 : : }
652 : :
653 [ + - ]: 86 : const QString beginComponent = QStringLiteral("BEGIN:%1").arg(componentName);
654 [ + - ]: 86 : const QString endComponent = QStringLiteral("END:%1").arg(componentName);
655 [ + - + + : 43 : if (trimmed.contains(beginComponent) && !trimmed.contains(endComponent)) {
+ - + + +
+ ]
656 [ + - ]: 2 : if (error)
657 : 4 : *error = QStringLiteral("Invalid iCalendar %1 component")
658 [ + - ]: 4 : .arg(componentName);
659 : 2 : return false;
660 : : }
661 : :
662 : 41 : return true;
663 : 46 : }
664 : :
665 : : QList<CalendarEvent>
666 : 18 : CalDavClient::parseICalEvents(const QByteArray &xmlData) {
667 : 18 : QList<CalendarEvent> events;
668 : 18 : QString error;
669 [ + - ]: 18 : parseICalEvents(xmlData, &events, &error);
670 : 18 : return events;
671 : 18 : }
672 : :
673 : 37 : bool CalDavClient::parseICalEvents(const QByteArray &xmlData,
674 : : QList<CalendarEvent> *events,
675 : : QString *error) {
676 [ + - ]: 37 : if (events)
677 [ + - ]: 37 : events->clear();
678 [ - + ]: 37 : if (!events) {
679 [ # # ]: 0 : if (error)
680 : 0 : *error = QStringLiteral("Invalid parser output");
681 : 0 : return false;
682 : : }
683 : :
684 : : // Parse the multistatus XML to extract calendar-data
685 [ + - ]: 37 : QDomDocument doc;
686 : : // T-511: Check XML parse result
687 [ + - + + ]: 37 : if (!doc.setContent(xmlData)) {
688 [ + - + - : 4 : qCWarning(lcCalDav) << "Failed to parse XML response";
+ - + + ]
689 [ + - ]: 2 : if (error)
690 : 2 : *error = QStringLiteral("Invalid XML response");
691 : 2 : return false;
692 : : }
693 : :
694 : : QDomNodeList responses =
695 [ + - ]: 35 : findElementsByLocalNameDoc(doc, QStringLiteral("response"));
696 : :
697 [ + - + + ]: 69 : for (int i = 0; i < responses.count(); ++i) {
698 [ + - + - ]: 37 : QDomElement resp = responses.at(i).toElement();
699 : :
700 : 37 : QString href;
701 : : QDomNodeList hrefNodes =
702 [ + - ]: 37 : findElementsByLocalName(resp, QStringLiteral("href"));
703 [ + - + + ]: 37 : if (!hrefNodes.isEmpty())
704 [ + - + - : 33 : href = hrefNodes.at(0).toElement().text().trimmed();
+ - + - ]
705 : :
706 : : // Get etag
707 : 37 : QString etag;
708 : : QDomNodeList etagNodes =
709 [ + - ]: 37 : findElementsByLocalName(resp, QStringLiteral("getetag"));
710 [ + - + + ]: 37 : if (!etagNodes.isEmpty())
711 [ + - + - : 34 : etag = etagNodes.at(0).toElement().text();
+ - ]
712 : :
713 : : // Get calendar-data
714 : 37 : QString icalText;
715 : : QDomNodeList dataNodes =
716 [ + - ]: 37 : findElementsByLocalName(resp, QStringLiteral("calendar-data"));
717 [ + - + + ]: 37 : if (dataNodes.isEmpty())
718 : 1 : continue;
719 [ + - + - : 36 : icalText = dataNodes.at(0).toElement().text();
+ - ]
720 [ + + ]: 36 : if (icalText.isEmpty())
721 : 2 : continue;
722 [ + - + + ]: 34 : if (!validateICalendarText(icalText, QStringLiteral("VEVENT"), error))
723 : 3 : return false;
724 : :
725 : : // Find VEVENT blocks
726 : : static QRegularExpression veventRegex(
727 : 12 : QStringLiteral("BEGIN:VEVENT\\s*\\n(.*?)END:VEVENT"),
728 [ + + + - : 43 : QRegularExpression::DotMatchesEverythingOption);
+ - - - ]
729 : :
730 [ + - ]: 31 : auto it = veventRegex.globalMatch(icalText);
731 [ + - + + ]: 65 : while (it.hasNext()) {
732 [ + - ]: 34 : auto match = it.next();
733 [ + - ]: 34 : QString block = match.captured(1);
734 [ + - ]: 34 : auto props = parseICalBlock(block);
735 : :
736 : : // T-79.C1/H3: VEVENT blocks carrying RECURRENCE-ID are exceptions of
737 : : // a recurring series. Upserting them would clobber the master row
738 : : // (UNIQUE(calendar_id, uid), last block wins) — skip them and keep
739 : : // the master series intact.
740 [ + - + + ]: 34 : if (props.contains(QStringLiteral("RECURRENCE-ID"))) {
741 [ + - + - : 2 : qCInfo(lcCalDav) << "Skipping RECURRENCE-ID exception VEVENT for UID"
+ - + + ]
742 [ + - + - : 2 : << props.value(QStringLiteral("UID")).trimmed();
+ - ]
743 : 1 : continue;
744 : 1 : }
745 : :
746 : 33 : CalendarEvent ev;
747 [ + - + - ]: 66 : ev.uid = props.value(QStringLiteral("UID")).trimmed();
748 : : // T-79.C1/H5: unescape TEXT values once, centrally (display sites
749 : : // must not unescape again).
750 : : ev.summary =
751 [ + - + - : 66 : unescapeICalText(props.value(QStringLiteral("SUMMARY")).trimmed());
+ - ]
752 [ + - ]: 66 : ev.description = unescapeICalText(
753 [ + - + - ]: 132 : props.value(QStringLiteral("DESCRIPTION")).trimmed());
754 : : ev.location =
755 [ + - + - : 66 : unescapeICalText(props.value(QStringLiteral("LOCATION")).trimmed());
+ - ]
756 [ + - + - ]: 66 : ev.rrule = props.value(QStringLiteral("RRULE")).trimmed();
757 : 33 : ev.etag = etag;
758 : 33 : ev.resourceHref = href;
759 : :
760 : : // T-79.C1/H3: EXDATE — occurrences deleted from the series.
761 : : // parseICalBlock accumulates multiple lines comma-separated.
762 [ + - ]: 66 : const QString exdateVal = props.value(QStringLiteral("EXDATE"));
763 : 33 : for (const QString &part :
764 [ + - + - : 69 : exdateVal.split(QLatin1Char(','), Qt::SkipEmptyParts)) {
+ - + + ]
765 [ + - + - ]: 3 : QDateTime exdate = parseICalDateTime(part.trimmed(), QString());
766 [ + - + - ]: 3 : if (exdate.isValid())
767 [ + - ]: 3 : ev.exdates.append(exdate);
768 : 36 : }
769 : :
770 : : // Parse dates
771 : : QString dtStartParams =
772 [ + - ]: 66 : props.value(QStringLiteral("DTSTART_PARAMS"));
773 : : QString dtStartVal =
774 [ + - + - ]: 66 : props.value(QStringLiteral("DTSTART")).trimmed();
775 [ + - ]: 33 : ev.dtStart = parseICalDateTime(dtStartVal, dtStartParams);
776 : :
777 : : // Detect all-day
778 [ + - + + : 96 : ev.allDay = dtStartParams.contains(QStringLiteral("VALUE=DATE")) ||
+ + + - +
- - - -
- ]
779 : 30 : dtStartVal.length() == 8;
780 : :
781 : : QString dtEndParams =
782 [ + - ]: 66 : props.value(QStringLiteral("DTEND_PARAMS"));
783 : : QString dtEndVal =
784 [ + - + - ]: 66 : props.value(QStringLiteral("DTEND")).trimmed();
785 [ + + ]: 33 : if (!dtEndVal.isEmpty())
786 [ + - ]: 16 : ev.dtEnd = parseICalDateTime(dtEndVal, dtEndParams);
787 : :
788 : : // DURATION as fallback for DTEND
789 [ + - + + : 50 : if (!ev.dtEnd.isValid() && props.contains(QStringLiteral("DURATION"))) {
+ - + + +
+ + + + +
- - - - ]
790 : : // T-524: Full iCal DURATION parser — supports P, D, T, H, M, W
791 : : // Formats: P1DT2H30M, PT1H, P1W, P1D, PT30M, etc.
792 [ + - + - ]: 8 : QString dur = props.value(QStringLiteral("DURATION")).trimmed();
793 [ + + + - ]: 6 : static thread_local QRegularExpression weekRe(QStringLiteral("(\\d+)W"));
794 [ + + + - ]: 6 : static thread_local QRegularExpression dayRe(QStringLiteral("(\\d+)D"));
795 [ + + + - ]: 6 : static thread_local QRegularExpression hourRe(QStringLiteral("(\\d+)H"));
796 [ + + + - ]: 6 : static thread_local QRegularExpression minRe(QStringLiteral("(\\d+)M"));
797 : :
798 : 4 : qint64 totalSecs = 0;
799 [ + - ]: 4 : auto wm = weekRe.match(dur);
800 [ + - + + : 4 : if (wm.hasMatch()) totalSecs += wm.captured(1).toLongLong() * 7 * 86400;
+ - + - ]
801 [ + - ]: 4 : auto dm = dayRe.match(dur);
802 [ + - + + : 4 : if (dm.hasMatch()) totalSecs += dm.captured(1).toLongLong() * 86400;
+ - + - ]
803 [ + - ]: 4 : auto hm = hourRe.match(dur);
804 [ + - + + : 4 : if (hm.hasMatch()) totalSecs += hm.captured(1).toLongLong() * 3600;
+ - + - ]
805 [ + - ]: 4 : auto mm = minRe.match(dur);
806 [ + - + + : 4 : if (mm.hasMatch()) totalSecs += mm.captured(1).toLongLong() * 60;
+ - + - ]
807 : :
808 [ + + ]: 4 : if (totalSecs > 0)
809 [ + - ]: 3 : ev.dtEnd = ev.dtStart.addSecs(totalSecs);
810 : 4 : }
811 : :
812 : : // LAST-MODIFIED
813 : : QString lastModVal =
814 [ + - + - ]: 66 : props.value(QStringLiteral("LAST-MODIFIED")).trimmed();
815 [ + + ]: 33 : if (!lastModVal.isEmpty())
816 [ + - ]: 2 : ev.lastModified = parseICalDateTime(lastModVal, QString());
817 : :
818 [ + + ]: 33 : if (!ev.uid.isEmpty()) {
819 [ + - ]: 31 : events->append(ev);
820 : : } else {
821 : : // T-79.C2/L12: one malformed component must not abort the whole
822 : : // calendar's sync — skip it and keep the remaining items.
823 [ + - + - : 4 : qCWarning(lcCalDav) << "Skipping VEVENT without UID (href:" << href
+ - + - +
+ ]
824 [ + - ]: 2 : << ")";
825 : : }
826 [ + + + + : 36 : }
+ + ]
827 [ + + + + : 73 : }
+ + + + +
+ + + + +
+ + + + +
+ + ]
828 : :
829 : 32 : return true;
830 : 37 : }
831 : :
832 : : QList<CalendarTask>
833 : 3 : CalDavClient::parseICalTasks(const QByteArray &xmlData) {
834 : 3 : QList<CalendarTask> tasks;
835 : 3 : QString error;
836 [ + - ]: 3 : parseICalTasks(xmlData, &tasks, &error);
837 : 3 : return tasks;
838 : 3 : }
839 : :
840 : 13 : bool CalDavClient::parseICalTasks(const QByteArray &xmlData,
841 : : QList<CalendarTask> *tasks,
842 : : QString *error) {
843 [ + - ]: 13 : if (tasks)
844 [ + - ]: 13 : tasks->clear();
845 [ - + ]: 13 : if (!tasks) {
846 [ # # ]: 0 : if (error)
847 : 0 : *error = QStringLiteral("Invalid parser output");
848 : 0 : return false;
849 : : }
850 : :
851 [ + - ]: 13 : QDomDocument doc;
852 : : // T-511: Check XML parse result
853 [ + - + + ]: 13 : if (!doc.setContent(xmlData)) {
854 [ + - + - : 2 : qCWarning(lcCalDav) << "Failed to parse XML response";
+ - + + ]
855 [ + - ]: 1 : if (error)
856 : 1 : *error = QStringLiteral("Invalid XML response");
857 : 1 : return false;
858 : : }
859 : :
860 : : QDomNodeList responses =
861 [ + - ]: 12 : findElementsByLocalNameDoc(doc, QStringLiteral("response"));
862 : :
863 [ + - + + ]: 22 : for (int i = 0; i < responses.count(); ++i) {
864 [ + - + - ]: 12 : QDomElement resp = responses.at(i).toElement();
865 : :
866 : 12 : QString href;
867 : : QDomNodeList hrefNodes =
868 [ + - ]: 12 : findElementsByLocalName(resp, QStringLiteral("href"));
869 [ + - + + ]: 12 : if (!hrefNodes.isEmpty())
870 [ + - + - : 9 : href = hrefNodes.at(0).toElement().text().trimmed();
+ - + - ]
871 : :
872 : 12 : QString etag;
873 : : QDomNodeList etagNodes =
874 [ + - ]: 12 : findElementsByLocalName(resp, QStringLiteral("getetag"));
875 [ + - + + ]: 12 : if (!etagNodes.isEmpty())
876 [ + - + - : 10 : etag = etagNodes.at(0).toElement().text();
+ - ]
877 : :
878 : 12 : QString icalText;
879 : : QDomNodeList dataNodes =
880 [ + - ]: 12 : findElementsByLocalName(resp, QStringLiteral("calendar-data"));
881 [ + - - + ]: 12 : if (dataNodes.isEmpty())
882 : 0 : continue;
883 [ + - + - : 12 : icalText = dataNodes.at(0).toElement().text();
+ - ]
884 [ - + ]: 12 : if (icalText.isEmpty())
885 : 0 : continue;
886 [ + - + + ]: 12 : if (!validateICalendarText(icalText, QStringLiteral("VTODO"), error))
887 : 2 : return false;
888 : :
889 : : // Find VTODO blocks
890 : : static QRegularExpression vtodoRegex(
891 : 8 : QStringLiteral("BEGIN:VTODO\\s*\\n(.*?)END:VTODO"),
892 [ + + + - : 18 : QRegularExpression::DotMatchesEverythingOption);
+ - - - ]
893 : :
894 [ + - ]: 10 : auto it = vtodoRegex.globalMatch(icalText);
895 [ + - + + ]: 24 : while (it.hasNext()) {
896 [ + - ]: 14 : auto match = it.next();
897 [ + - ]: 14 : QString block = match.captured(1);
898 [ + - ]: 14 : auto props = parseICalBlock(block);
899 : :
900 : 14 : CalendarTask task;
901 [ + - + - ]: 28 : task.uid = props.value(QStringLiteral("UID")).trimmed();
902 : : // T-79.C1/H5: unescape TEXT values once, centrally.
903 : : task.summary =
904 [ + - + - : 28 : unescapeICalText(props.value(QStringLiteral("SUMMARY")).trimmed());
+ - ]
905 [ + - ]: 28 : task.description = unescapeICalText(
906 [ + - + - ]: 56 : props.value(QStringLiteral("DESCRIPTION")).trimmed());
907 : 14 : task.etag = etag;
908 : 14 : task.resourceHref = href;
909 : :
910 : : // Status
911 : : task.status =
912 [ + - ]: 42 : props.value(QStringLiteral("STATUS"), QStringLiteral("NEEDS-ACTION"))
913 [ + - ]: 28 : .trimmed()
914 [ + - ]: 14 : .toUpper();
915 : :
916 : : // Priority (0-9)
917 : : QString priStr =
918 [ + - + - ]: 28 : props.value(QStringLiteral("PRIORITY")).trimmed();
919 [ + + ]: 14 : if (!priStr.isEmpty())
920 [ + - ]: 3 : task.priority = priStr.toInt();
921 : :
922 : : // Percent-complete
923 : : QString pctStr =
924 [ + - + - ]: 28 : props.value(QStringLiteral("PERCENT-COMPLETE")).trimmed();
925 [ + + ]: 14 : if (!pctStr.isEmpty())
926 [ + - ]: 3 : task.percentComplete = pctStr.toInt();
927 : :
928 : : // Due date
929 [ + - + - ]: 28 : QString dueVal = props.value(QStringLiteral("DUE")).trimmed();
930 [ + - ]: 28 : QString dueParams = props.value(QStringLiteral("DUE_PARAMS"));
931 [ + + ]: 14 : if (!dueVal.isEmpty())
932 [ + - ]: 3 : task.due = parseICalDateTime(dueVal, dueParams);
933 : :
934 : : // Completed date
935 : : QString compVal =
936 [ + - + - ]: 28 : props.value(QStringLiteral("COMPLETED")).trimmed();
937 [ + + ]: 14 : if (!compVal.isEmpty())
938 [ + - ]: 2 : task.completedAt = parseICalDateTime(compVal, QString());
939 : :
940 : : // LAST-MODIFIED
941 : : QString lastModVal =
942 [ + - + - ]: 28 : props.value(QStringLiteral("LAST-MODIFIED")).trimmed();
943 [ + + ]: 14 : if (!lastModVal.isEmpty())
944 [ + - ]: 2 : task.lastModified = parseICalDateTime(lastModVal, QString());
945 : :
946 : : // Start date (Sprint 37 – T-452)
947 : : QString dtStartVal =
948 [ + - + - ]: 28 : props.value(QStringLiteral("DTSTART")).trimmed();
949 : : QString dtStartParams =
950 [ + - ]: 28 : props.value(QStringLiteral("DTSTART_PARAMS"));
951 [ + + ]: 14 : if (!dtStartVal.isEmpty())
952 [ + - ]: 1 : task.dtStart = parseICalDateTime(dtStartVal, dtStartParams);
953 : :
954 : : // Created date (Sprint 37 – T-452)
955 : : QString createdVal =
956 [ + - + - ]: 28 : props.value(QStringLiteral("CREATED")).trimmed();
957 [ + + ]: 14 : if (!createdVal.isEmpty())
958 [ + - ]: 1 : task.created = parseICalDateTime(createdVal, QString());
959 : :
960 : : // Organizer (Sprint 37 – T-452)
961 : : // Format: ORGANIZER;CN="John Doe":mailto:john@example.com
962 : : QString orgVal =
963 [ + - + - ]: 28 : props.value(QStringLiteral("ORGANIZER")).trimmed();
964 [ + + ]: 14 : if (!orgVal.isEmpty()) {
965 : : QString orgParams =
966 [ + - ]: 10 : props.value(QStringLiteral("ORGANIZER_PARAMS"));
967 [ + + ]: 9 : if (!orgParams.isEmpty() &&
968 [ + - + + : 9 : orgParams.contains(QStringLiteral("CN="))) {
+ + + + +
+ - - -
- ]
969 : : // Extract CN value
970 : : static QRegularExpression cnRe(
971 [ + + + - : 4 : QStringLiteral("CN=\"?([^\";]+)\"?"));
+ - - - ]
972 [ + - ]: 3 : auto m = cnRe.match(orgParams);
973 [ + - + + : 3 : task.organizer = m.hasMatch() ? m.captured(1) : orgVal;
+ - ]
974 : 3 : } else {
975 : : // Fallback: strip mailto: prefix
976 : 2 : task.organizer = orgVal;
977 [ + - ]: 2 : task.organizer.remove(
978 : 4 : QStringLiteral("mailto:"), Qt::CaseInsensitive);
979 : : }
980 : 5 : }
981 : :
982 [ + + ]: 14 : if (!task.uid.isEmpty()) {
983 [ + - ]: 13 : tasks->append(task);
984 : : } else {
985 : : // T-79.C2/L12: skip the malformed component, keep the rest.
986 [ + - + - : 2 : qCWarning(lcCalDav) << "Skipping VTODO without UID (href:" << href
+ - + - +
+ ]
987 [ + - ]: 1 : << ")";
988 : : }
989 : 14 : }
990 [ + - + + : 24 : }
- + + - +
+ - + + -
+ + - + +
- + ]
991 : :
992 : 10 : return true;
993 : 13 : }
994 : :
995 : : // ═══════════════════════════════════════════════════════
996 : : // Sprint 39 – T-530: Write API (iCal serialization + PUT/DELETE)
997 : : // ═══════════════════════════════════════════════════════
998 : :
999 : 138 : static QByteArray escapeICalText(const QString &text) {
1000 : 138 : QString escaped = text;
1001 [ + - ]: 138 : escaped.replace(QLatin1Char('\\'), QStringLiteral("\\\\"));
1002 [ + - ]: 138 : escaped.replace(QLatin1Char(';'), QStringLiteral("\\;"));
1003 [ + - ]: 138 : escaped.replace(QLatin1Char(','), QStringLiteral("\\,"));
1004 [ + - ]: 138 : escaped.replace(QLatin1Char('\n'), QStringLiteral("\\n"));
1005 [ + - ]: 138 : escaped.remove(QLatin1Char('\r'));
1006 [ + - ]: 276 : return escaped.toUtf8();
1007 : 138 : }
1008 : :
1009 : 65 : static QByteArray formatICalDate(const QDateTime &dt, bool allDay) {
1010 [ + + ]: 65 : if (allDay)
1011 [ + - + - : 22 : return dt.date().toString(QStringLiteral("yyyyMMdd")).toUtf8();
+ - ]
1012 : : // Always emit UTC
1013 [ + - ]: 54 : return dt.toUTC()
1014 [ + - ]: 108 : .toString(QStringLiteral("yyyyMMdd'T'HHmmss'Z'"))
1015 [ + - ]: 54 : .toUtf8();
1016 : : }
1017 : :
1018 : 30 : QByteArray CalDavClient::eventToICalendar(const CalendarEvent &event) {
1019 : 30 : QByteArray ical;
1020 [ + - ]: 30 : ical += "BEGIN:VCALENDAR\r\n";
1021 [ + - ]: 30 : ical += "VERSION:2.0\r\n";
1022 [ + - ]: 30 : ical += "PRODID:-//MailJD//CalDAV Client//EN\r\n";
1023 [ + - ]: 30 : ical += "BEGIN:VEVENT\r\n";
1024 [ + - + - : 30 : ical += "UID:" + escapeICalText(event.uid) + "\r\n";
+ - + - ]
1025 : :
1026 [ + - + + ]: 30 : if (event.dtStart.isValid()) {
1027 [ + + ]: 28 : if (event.allDay) {
1028 [ + - + - : 4 : ical += "DTSTART;VALUE=DATE:" + formatICalDate(event.dtStart, true) + "\r\n";
+ - + - ]
1029 : : } else {
1030 [ + - + - : 24 : ical += "DTSTART:" + formatICalDate(event.dtStart, false) + "\r\n";
+ - + - ]
1031 : : }
1032 : : }
1033 : :
1034 [ + - + + ]: 30 : if (event.dtEnd.isValid()) {
1035 [ + + ]: 27 : if (event.allDay) {
1036 [ + - + - : 4 : ical += "DTEND;VALUE=DATE:" + formatICalDate(event.dtEnd, true) + "\r\n";
+ - + - ]
1037 : : } else {
1038 [ + - + - : 23 : ical += "DTEND:" + formatICalDate(event.dtEnd, false) + "\r\n";
+ - + - ]
1039 : : }
1040 : : }
1041 : :
1042 [ + + ]: 30 : if (!event.summary.isEmpty())
1043 [ + - + - : 28 : ical += "SUMMARY:" + escapeICalText(event.summary) + "\r\n";
+ - + - ]
1044 : :
1045 [ + + ]: 30 : if (!event.description.isEmpty())
1046 [ + - + - : 8 : ical += "DESCRIPTION:" + escapeICalText(event.description) + "\r\n";
+ - + - ]
1047 : :
1048 [ + + ]: 30 : if (!event.location.isEmpty())
1049 [ + - + - : 5 : ical += "LOCATION:" + escapeICalText(event.location) + "\r\n";
+ - + - ]
1050 : :
1051 [ + + ]: 30 : if (!event.rrule.isEmpty()) {
1052 : : // T-610/SEC-09: RRULE uses semicolons as structural delimiters,
1053 : : // so escapeICalText() would break it. Only strip CR/LF for injection prevention.
1054 : 3 : QString safeRrule = event.rrule;
1055 [ + - ]: 3 : safeRrule.remove('\r');
1056 [ + - ]: 3 : safeRrule.remove('\n');
1057 [ + - + - : 3 : ical += "RRULE:" + safeRrule.toUtf8() + "\r\n";
+ - + - ]
1058 : 3 : }
1059 : :
1060 [ + - ]: 30 : ical += "END:VEVENT\r\n";
1061 [ + - ]: 30 : ical += "END:VCALENDAR\r\n";
1062 : 30 : return ical;
1063 : 0 : }
1064 : :
1065 : 24 : QByteArray CalDavClient::taskToICalendar(const CalendarTask &task) {
1066 : 24 : QByteArray ical;
1067 [ + - ]: 24 : ical += "BEGIN:VCALENDAR\r\n";
1068 [ + - ]: 24 : ical += "VERSION:2.0\r\n";
1069 [ + - ]: 24 : ical += "PRODID:-//MailJD//CalDAV Client//EN\r\n";
1070 [ + - ]: 24 : ical += "BEGIN:VTODO\r\n";
1071 [ + - + - : 24 : ical += "UID:" + escapeICalText(task.uid) + "\r\n";
+ - + - ]
1072 : :
1073 [ + + ]: 24 : if (!task.summary.isEmpty())
1074 [ + - + - : 22 : ical += "SUMMARY:" + escapeICalText(task.summary) + "\r\n";
+ - + - ]
1075 : :
1076 [ + + ]: 24 : if (!task.description.isEmpty())
1077 [ + - + - : 4 : ical += "DESCRIPTION:" + escapeICalText(task.description) + "\r\n";
+ - + - ]
1078 : :
1079 [ + + ]: 24 : if (!task.status.isEmpty())
1080 [ + - + - : 17 : ical += "STATUS:" + escapeICalText(task.status) + "\r\n";
+ - + - ]
1081 : :
1082 [ + + ]: 24 : if (task.priority > 0)
1083 [ + - + - : 5 : ical += "PRIORITY:" + QByteArray::number(task.priority) + "\r\n";
+ - + - ]
1084 : :
1085 [ + + ]: 24 : if (task.percentComplete > 0)
1086 [ + - + - : 4 : ical += "PERCENT-COMPLETE:" + QByteArray::number(task.percentComplete) + "\r\n";
+ - + - ]
1087 : :
1088 [ + - + + ]: 24 : if (task.due.isValid()) {
1089 : : // Tasks typically use VALUE=DATE for all-day dues
1090 [ + - + - : 6 : if (task.due.time() == QTime(0, 0)) {
+ + ]
1091 [ + - + - : 2 : ical += "DUE;VALUE=DATE:" + formatICalDate(task.due, true) + "\r\n";
+ - + - ]
1092 : : } else {
1093 [ + - + - : 4 : ical += "DUE:" + formatICalDate(task.due, false) + "\r\n";
+ - + - ]
1094 : : }
1095 : : }
1096 : :
1097 [ + - + + ]: 24 : if (task.dtStart.isValid()) {
1098 [ + - + - : 2 : if (task.dtStart.time() == QTime(0, 0)) {
+ + ]
1099 [ + - + - : 1 : ical += "DTSTART;VALUE=DATE:" + formatICalDate(task.dtStart, true) + "\r\n";
+ - + - ]
1100 : : } else {
1101 [ + - + - : 1 : ical += "DTSTART:" + formatICalDate(task.dtStart, false) + "\r\n";
+ - + - ]
1102 : : }
1103 : : }
1104 : :
1105 [ + - + + ]: 24 : if (task.completedAt.isValid())
1106 [ + - + - : 2 : ical += "COMPLETED:" + formatICalDate(task.completedAt, false) + "\r\n";
+ - + - ]
1107 : :
1108 [ + - ]: 24 : ical += "END:VTODO\r\n";
1109 [ + - ]: 24 : ical += "END:VCALENDAR\r\n";
1110 : 24 : return ical;
1111 : 0 : }
1112 : :
1113 : 47 : QString CalDavClient::resourceUrl(const QString &calendarPath,
1114 : : const QString &uid) const {
1115 [ + - ]: 47 : QString basePath = resolveDavUrl(calendarPath);
1116 [ + + ]: 47 : if (basePath.isEmpty())
1117 : 7 : return {};
1118 [ + - + + ]: 40 : if (!basePath.endsWith(QLatin1Char('/')))
1119 [ + - ]: 3 : basePath += QLatin1Char('/');
1120 [ + - + - : 80 : return basePath + QString::fromUtf8(QUrl::toPercentEncoding(uid)) +
+ - ]
1121 [ + - ]: 120 : QStringLiteral(".ics");
1122 : 47 : }
1123 : :
1124 : 33 : QString CalDavClient::resourceUrlForExistingResource(
1125 : : const QString &resourceHref, const QString &calendarPath,
1126 : : const QString &uid) const {
1127 [ + + ]: 33 : if (!resourceHref.isEmpty())
1128 : 11 : return resolveDavUrl(resourceHref);
1129 : 22 : return resourceUrl(calendarPath, uid);
1130 : : }
1131 : :
1132 : 12 : void CalDavClient::createEvent(const QString &calendarPath,
1133 : : const CalendarEvent &event) {
1134 [ + - ]: 12 : QString url = resourceUrl(calendarPath, event.uid);
1135 [ + + ]: 12 : if (url.isEmpty()) {
1136 [ + - ]: 1 : emit writeFailed(QStringLiteral("Invalid calendar path for event creation"));
1137 : 1 : return;
1138 : : }
1139 : :
1140 [ + - + - ]: 11 : QNetworkRequest request{QUrl(url)};
1141 [ + - + - : 11 : request.setRawHeader("Authorization", authHeader());
+ - ]
1142 [ + - + - : 11 : request.setRawHeader("Content-Type", "text/calendar; charset=utf-8");
+ - ]
1143 [ + - + - : 11 : request.setRawHeader("If-None-Match", "*"); // Only create, don't overwrite
+ - ]
1144 : :
1145 [ + - ]: 11 : QByteArray body = eventToICalendar(event);
1146 [ + - ]: 11 : auto *reply = m_nam->put(request, body);
1147 [ + - ]: 11 : DavNetworkLimits::apply(reply);
1148 [ + - ]: 11 : trackReply(reply); // T-79.F1: lifetime follows request completion
1149 : :
1150 : 11 : connect(reply, &QNetworkReply::finished, this,
1151 [ + - - - ]: 22 : [this, reply, event, url]() {
1152 [ + - ]: 7 : reply->deleteLater();
1153 [ + - ]: 7 : const QString limitError = DavNetworkLimits::failureReason(reply);
1154 [ + + ]: 7 : if (!limitError.isEmpty()) {
1155 [ + - ]: 1 : emit writeFailed(limitError);
1156 : 1 : return;
1157 : : }
1158 [ + - ]: 6 : int status = reply->attribute(
1159 [ + - ]: 6 : QNetworkRequest::HttpStatusCodeAttribute).toInt();
1160 [ + + + + ]: 6 : if (status == 201 || status == 204) {
1161 : : // Update etag from response
1162 : 4 : CalendarEvent saved = event;
1163 [ + - ]: 8 : QByteArray newEtag = reply->rawHeader("ETag");
1164 [ + + ]: 4 : if (!newEtag.isEmpty())
1165 [ + - ]: 3 : saved.etag = QString::fromUtf8(newEtag);
1166 [ + - ]: 8 : const QByteArray location = reply->rawHeader("Location");
1167 : 4 : const QString savedHref = location.isEmpty()
1168 : 6 : ? url
1169 [ + + + - : 4 : : resolveDavUrl(QString::fromUtf8(location));
+ - + + -
- ]
1170 [ + + ]: 4 : if (!savedHref.isEmpty())
1171 : 3 : saved.resourceHref = savedHref;
1172 [ + - + - : 8 : qCDebug(lcCalDav) << "Event created:" << saved.uid;
+ - + - +
+ ]
1173 [ + - ]: 4 : emit eventSaved(saved);
1174 : 4 : } else {
1175 : 4 : QString err = QStringLiteral("Create event failed (HTTP %1): %2")
1176 [ + - + - : 4 : .arg(status).arg(QString::fromUtf8(reply->readAll()));
+ - + - ]
1177 [ + - + - : 4 : qCWarning(lcCalDav) << err;
+ - + + ]
1178 [ + - ]: 2 : emit writeFailed(err);
1179 : 2 : }
1180 [ + + ]: 7 : });
1181 [ + + ]: 12 : }
1182 : :
1183 : 10 : void CalDavClient::updateEvent(const CalendarEvent &event) {
1184 : 10 : QString url = resourceUrlForExistingResource(event.resourceHref,
1185 : 10 : event.calendarPath,
1186 [ + - ]: 10 : event.uid);
1187 [ + + ]: 10 : if (url.isEmpty()) {
1188 [ + - ]: 1 : emit writeFailed(QStringLiteral("Invalid calendar path for event update"));
1189 : 1 : return;
1190 : : }
1191 : :
1192 [ + - + - ]: 9 : QNetworkRequest request{QUrl(url)};
1193 [ + - + - : 9 : request.setRawHeader("Authorization", authHeader());
+ - ]
1194 [ + - + - : 9 : request.setRawHeader("Content-Type", "text/calendar; charset=utf-8");
+ - ]
1195 [ + - + + ]: 9 : if (!isValidEtag(event.etag)) {
1196 [ + - + - : 6 : qCWarning(lcCalDav) << "Rejecting PUT without a valid RFC entity-tag";
+ - + + ]
1197 [ + - ]: 3 : emit writeFailed(QStringLiteral("Invalid or missing server ETag — write "
1198 : : "refused to preserve concurrency protection"));
1199 : 3 : return;
1200 : : }
1201 [ + - + - : 6 : request.setRawHeader("If-Match", event.etag.toUtf8());
+ - ]
1202 : :
1203 [ + - ]: 6 : QByteArray body = eventToICalendar(event);
1204 [ + - ]: 6 : auto *reply = m_nam->put(request, body);
1205 [ + - ]: 6 : DavNetworkLimits::apply(reply);
1206 [ + - ]: 6 : trackReply(reply); // T-79.F1: lifetime follows request completion
1207 : :
1208 [ + - ]: 6 : connect(reply, &QNetworkReply::finished, this,
1209 : 12 : [this, reply, event]() {
1210 [ + - ]: 6 : reply->deleteLater();
1211 [ + - ]: 6 : const QString limitError = DavNetworkLimits::failureReason(reply);
1212 [ + + ]: 6 : if (!limitError.isEmpty()) {
1213 [ + - ]: 1 : emit writeFailed(limitError);
1214 : 1 : return;
1215 : : }
1216 [ + - ]: 5 : int status = reply->attribute(
1217 [ + - ]: 5 : QNetworkRequest::HttpStatusCodeAttribute).toInt();
1218 [ + + + + ]: 5 : if (status == 200 || status == 204) {
1219 : 2 : CalendarEvent saved = event;
1220 [ + - ]: 4 : QByteArray newEtag = reply->rawHeader("ETag");
1221 [ + - ]: 2 : if (!newEtag.isEmpty())
1222 [ + - ]: 2 : saved.etag = QString::fromUtf8(newEtag);
1223 [ + - + - : 4 : qCDebug(lcCalDav) << "Event updated:" << saved.uid;
+ - + - +
+ ]
1224 [ + - ]: 2 : emit eventSaved(saved);
1225 [ + + ]: 5 : } else if (status == 412) {
1226 [ + - ]: 2 : emit writeFailed(QStringLiteral(
1227 : : "Conflict: event was modified on server (ETag mismatch)"));
1228 : : } else {
1229 : 4 : QString err = QStringLiteral("Update event failed (HTTP %1): %2")
1230 [ + - + - : 4 : .arg(status).arg(QString::fromUtf8(reply->readAll()));
+ - + - ]
1231 [ + - + - : 4 : qCWarning(lcCalDav) << err;
+ - + + ]
1232 [ + - ]: 2 : emit writeFailed(err);
1233 : 2 : }
1234 [ + + ]: 6 : });
1235 [ + + + + ]: 13 : }
1236 : :
1237 : 7 : void CalDavClient::deleteEvent(const CalendarEvent &event) {
1238 : 7 : QString url = resourceUrlForExistingResource(event.resourceHref,
1239 : 7 : event.calendarPath,
1240 [ + - ]: 7 : event.uid);
1241 [ + + ]: 7 : if (url.isEmpty()) {
1242 [ + - ]: 1 : emit writeFailed(QStringLiteral("Invalid calendar path for event deletion"));
1243 : 1 : return;
1244 : : }
1245 : :
1246 [ + - + - ]: 6 : QNetworkRequest request{QUrl(url)};
1247 [ + - + - : 6 : request.setRawHeader("Authorization", authHeader());
+ - ]
1248 [ + - + + ]: 6 : if (!isValidEtag(event.etag)) {
1249 [ + - + - : 2 : qCWarning(lcCalDav) << "Rejecting DELETE without a valid RFC entity-tag";
+ - + + ]
1250 [ + - ]: 1 : emit writeFailed(QStringLiteral("Invalid or missing server ETag — delete "
1251 : : "refused to preserve concurrency protection"));
1252 : 1 : return;
1253 : : }
1254 [ + - + - : 5 : request.setRawHeader("If-Match", event.etag.toUtf8());
+ - ]
1255 : :
1256 [ + - ]: 5 : auto *reply = m_nam->deleteResource(request);
1257 [ + - ]: 5 : DavNetworkLimits::apply(reply);
1258 [ + - ]: 5 : trackReply(reply); // T-79.F1: lifetime follows request completion
1259 : :
1260 [ + - ]: 5 : connect(reply, &QNetworkReply::finished, this,
1261 : 10 : [this, reply, uid = event.uid]() {
1262 [ + - ]: 5 : reply->deleteLater();
1263 [ + - ]: 5 : const QString limitError = DavNetworkLimits::failureReason(reply);
1264 [ + + ]: 5 : if (!limitError.isEmpty()) {
1265 [ + - ]: 1 : emit writeFailed(limitError);
1266 : 1 : return;
1267 : : }
1268 [ + - ]: 4 : int status = reply->attribute(
1269 [ + - ]: 4 : QNetworkRequest::HttpStatusCodeAttribute).toInt();
1270 [ + + + + ]: 4 : if (status == 200 || status == 204) {
1271 [ + - + - : 4 : qCDebug(lcCalDav) << "Event deleted:" << uid;
+ - + - +
+ ]
1272 [ + - ]: 2 : emit eventDeleted(uid);
1273 [ + + ]: 4 : } else if (status == 412) {
1274 [ + - ]: 2 : emit writeFailed(QStringLiteral(
1275 : : "Conflict: event was modified on server (ETag mismatch)"));
1276 : : } else {
1277 : 2 : QString err = QStringLiteral("Delete event failed (HTTP %1)")
1278 [ + - ]: 1 : .arg(status);
1279 [ + - + - : 2 : qCWarning(lcCalDav) << err;
+ - + + ]
1280 [ + - ]: 1 : emit writeFailed(err);
1281 : 1 : }
1282 [ + + ]: 5 : });
1283 [ + + + + ]: 8 : }
1284 : :
1285 : 9 : void CalDavClient::createTask(const QString &calendarPath,
1286 : : const CalendarTask &task) {
1287 [ + - ]: 9 : QString url = resourceUrl(calendarPath, task.uid);
1288 [ + + ]: 9 : if (url.isEmpty()) {
1289 [ + - ]: 1 : emit writeFailed(QStringLiteral("Invalid calendar path for task creation"));
1290 : 1 : return;
1291 : : }
1292 : :
1293 [ + - + - ]: 8 : QNetworkRequest request{QUrl(url)};
1294 [ + - + - : 8 : request.setRawHeader("Authorization", authHeader());
+ - ]
1295 [ + - + - : 8 : request.setRawHeader("Content-Type", "text/calendar; charset=utf-8");
+ - ]
1296 [ + - + - : 8 : request.setRawHeader("If-None-Match", "*");
+ - ]
1297 : :
1298 [ + - ]: 8 : QByteArray body = taskToICalendar(task);
1299 [ + - ]: 8 : auto *reply = m_nam->put(request, body);
1300 [ + - ]: 8 : DavNetworkLimits::apply(reply);
1301 [ + - ]: 8 : trackReply(reply); // T-79.F1: lifetime follows request completion
1302 : :
1303 : 8 : connect(reply, &QNetworkReply::finished, this,
1304 [ + - - - ]: 16 : [this, reply, task, url]() {
1305 [ + - ]: 6 : reply->deleteLater();
1306 [ + - ]: 6 : const QString limitError = DavNetworkLimits::failureReason(reply);
1307 [ + + ]: 6 : if (!limitError.isEmpty()) {
1308 [ + - ]: 1 : emit writeFailed(limitError);
1309 : 1 : return;
1310 : : }
1311 [ + - ]: 5 : int status = reply->attribute(
1312 [ + - ]: 5 : QNetworkRequest::HttpStatusCodeAttribute).toInt();
1313 [ + + + + ]: 5 : if (status == 201 || status == 204) {
1314 : 3 : CalendarTask saved = task;
1315 [ + - ]: 6 : QByteArray newEtag = reply->rawHeader("ETag");
1316 [ + + ]: 3 : if (!newEtag.isEmpty())
1317 [ + - ]: 2 : saved.etag = QString::fromUtf8(newEtag);
1318 [ + - ]: 6 : const QByteArray location = reply->rawHeader("Location");
1319 : 3 : const QString savedHref = location.isEmpty()
1320 : 4 : ? url
1321 [ + + + - : 3 : : resolveDavUrl(QString::fromUtf8(location));
+ - + + -
- ]
1322 [ + + ]: 3 : if (!savedHref.isEmpty())
1323 : 2 : saved.resourceHref = savedHref;
1324 [ + - + - : 6 : qCDebug(lcCalDav) << "Task created:" << saved.uid;
+ - + - +
+ ]
1325 [ + - ]: 3 : emit taskSaved(saved);
1326 : 3 : } else {
1327 : 4 : QString err = QStringLiteral("Create task failed (HTTP %1): %2")
1328 [ + - + - : 4 : .arg(status).arg(QString::fromUtf8(reply->readAll()));
+ - + - ]
1329 [ + - + - : 4 : qCWarning(lcCalDav) << err;
+ - + + ]
1330 [ + - ]: 2 : emit writeFailed(err);
1331 : 2 : }
1332 [ + + ]: 6 : });
1333 [ + + ]: 9 : }
1334 : :
1335 : 7 : void CalDavClient::updateTask(const CalendarTask &task) {
1336 : 7 : QString url = resourceUrlForExistingResource(task.resourceHref,
1337 : 7 : task.calendarPath,
1338 [ + - ]: 7 : task.uid);
1339 [ + + ]: 7 : if (url.isEmpty()) {
1340 [ + - ]: 1 : emit writeFailed(QStringLiteral("Invalid calendar path for task update"));
1341 : 1 : return;
1342 : : }
1343 : :
1344 [ + - + - ]: 6 : QNetworkRequest request{QUrl(url)};
1345 [ + - + - : 6 : request.setRawHeader("Authorization", authHeader());
+ - ]
1346 [ + - + - : 6 : request.setRawHeader("Content-Type", "text/calendar; charset=utf-8");
+ - ]
1347 [ + - + + ]: 6 : if (!isValidEtag(task.etag)) {
1348 [ + - + - : 2 : qCWarning(lcCalDav) << "Rejecting task PUT without a valid RFC entity-tag";
+ - + + ]
1349 [ + - ]: 1 : emit writeFailed(QStringLiteral("Invalid or missing server ETag — write "
1350 : : "refused to preserve concurrency protection"));
1351 : 1 : return;
1352 : : }
1353 [ + - + - : 5 : request.setRawHeader("If-Match", task.etag.toUtf8());
+ - ]
1354 : :
1355 [ + - ]: 5 : QByteArray body = taskToICalendar(task);
1356 [ + - ]: 5 : auto *reply = m_nam->put(request, body);
1357 [ + - ]: 5 : DavNetworkLimits::apply(reply);
1358 [ + - ]: 5 : trackReply(reply); // T-79.F1: lifetime follows request completion
1359 : :
1360 [ + - ]: 5 : connect(reply, &QNetworkReply::finished, this,
1361 : 10 : [this, reply, task]() {
1362 [ + - ]: 5 : reply->deleteLater();
1363 [ + - ]: 5 : const QString limitError = DavNetworkLimits::failureReason(reply);
1364 [ + + ]: 5 : if (!limitError.isEmpty()) {
1365 [ + - ]: 1 : emit writeFailed(limitError);
1366 : 1 : return;
1367 : : }
1368 [ + - ]: 4 : int status = reply->attribute(
1369 [ + - ]: 4 : QNetworkRequest::HttpStatusCodeAttribute).toInt();
1370 [ + + + + ]: 4 : if (status == 200 || status == 204) {
1371 : 3 : CalendarTask saved = task;
1372 [ + - ]: 6 : QByteArray newEtag = reply->rawHeader("ETag");
1373 [ + + ]: 3 : if (!newEtag.isEmpty())
1374 [ + - ]: 2 : saved.etag = QString::fromUtf8(newEtag);
1375 [ + - + - : 6 : qCDebug(lcCalDav) << "Task updated:" << saved.uid;
+ - + - +
+ ]
1376 [ + - ]: 3 : emit taskSaved(saved);
1377 [ + - ]: 4 : } else if (status == 412) {
1378 [ + - ]: 2 : emit writeFailed(QStringLiteral(
1379 : : "Conflict: task was modified on server (ETag mismatch)"));
1380 : : } else {
1381 : 0 : QString err = QStringLiteral("Update task failed (HTTP %1): %2")
1382 [ # # # # : 0 : .arg(status).arg(QString::fromUtf8(reply->readAll()));
# # # # ]
1383 [ # # # # : 0 : qCWarning(lcCalDav) << err;
# # # # ]
1384 [ # # ]: 0 : emit writeFailed(err);
1385 : 0 : }
1386 [ + + ]: 5 : });
1387 [ + + + + ]: 8 : }
1388 : :
1389 : 7 : void CalDavClient::deleteTask(const CalendarTask &task) {
1390 : 7 : QString url = resourceUrlForExistingResource(task.resourceHref,
1391 : 7 : task.calendarPath,
1392 [ + - ]: 7 : task.uid);
1393 [ + + ]: 7 : if (url.isEmpty()) {
1394 [ + - ]: 1 : emit writeFailed(QStringLiteral("Invalid calendar path for task deletion"));
1395 : 1 : return;
1396 : : }
1397 : :
1398 [ + - + - ]: 6 : QNetworkRequest request{QUrl(url)};
1399 [ + - + - : 6 : request.setRawHeader("Authorization", authHeader());
+ - ]
1400 [ + - + + ]: 6 : if (!isValidEtag(task.etag)) {
1401 [ + - + - : 2 : qCWarning(lcCalDav) << "Rejecting task DELETE without a valid RFC entity-tag";
+ - + + ]
1402 [ + - ]: 1 : emit writeFailed(QStringLiteral("Invalid or missing server ETag — delete "
1403 : : "refused to preserve concurrency protection"));
1404 : 1 : return;
1405 : : }
1406 [ + - + - : 5 : request.setRawHeader("If-Match", task.etag.toUtf8());
+ - ]
1407 : :
1408 [ + - ]: 5 : auto *reply = m_nam->deleteResource(request);
1409 [ + - ]: 5 : DavNetworkLimits::apply(reply);
1410 [ + - ]: 5 : trackReply(reply); // T-79.F1: lifetime follows request completion
1411 : :
1412 [ + - ]: 5 : connect(reply, &QNetworkReply::finished, this,
1413 : 10 : [this, reply, uid = task.uid]() {
1414 [ + - ]: 5 : reply->deleteLater();
1415 [ + - ]: 5 : const QString limitError = DavNetworkLimits::failureReason(reply);
1416 [ + + ]: 5 : if (!limitError.isEmpty()) {
1417 [ + - ]: 1 : emit writeFailed(limitError);
1418 : 1 : return;
1419 : : }
1420 [ + - ]: 4 : int status = reply->attribute(
1421 [ + - ]: 4 : QNetworkRequest::HttpStatusCodeAttribute).toInt();
1422 [ + + + + ]: 4 : if (status == 200 || status == 204) {
1423 [ + - + - : 4 : qCDebug(lcCalDav) << "Task deleted:" << uid;
+ - + - +
+ ]
1424 [ + - ]: 2 : emit taskDeleted(uid);
1425 [ + + ]: 4 : } else if (status == 412) {
1426 [ + - ]: 2 : emit writeFailed(QStringLiteral(
1427 : : "Conflict: task was modified on server (ETag mismatch)"));
1428 : : } else {
1429 : 2 : QString err = QStringLiteral("Delete task failed (HTTP %1)")
1430 [ + - ]: 1 : .arg(status);
1431 [ + - + - : 2 : qCWarning(lcCalDav) << err;
+ - + + ]
1432 [ + - ]: 1 : emit writeFailed(err);
1433 : 1 : }
1434 [ + + ]: 5 : });
1435 [ + + + + ]: 8 : }
|