Branch data Line data Source code
1 : : #include "ContactStore.h"
2 : : #include "DatabaseSecurity.h"
3 : : #include "DatabaseSync.h"
4 : :
5 : : #include <QLoggingCategory>
6 : : #include <QSqlError>
7 : : #include <QSqlQuery>
8 : : #include <QUuid>
9 : :
10 [ + + + - : 22 : Q_LOGGING_CATEGORY(lcContactStore, "mailjd.contactstore")
+ - - - ]
11 : :
12 [ + - ]: 123 : ContactStore::ContactStore(QObject *parent) : QObject(parent) {}
13 : :
14 : 183 : ContactStore::~ContactStore() { close(); }
15 : :
16 : 122 : bool ContactStore::open(const QString &dbPath) {
17 [ + - + + ]: 122 : if (!DatabaseSecurity::preparePath(dbPath)) {
18 [ + - + - : 6 : qCWarning(lcContactStore)
+ + ]
19 [ + - + - ]: 3 : << "Failed to create private contacts DB:" << dbPath;
20 : 3 : return false;
21 : : }
22 : : m_connectionName =
23 [ + - + - : 238 : QStringLiteral("contacts_") + QUuid::createUuid().toString();
+ - ]
24 [ + - + - ]: 238 : m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
25 [ + - ]: 119 : m_db.setDatabaseName(dbPath);
26 : :
27 [ + - - + ]: 119 : if (!m_db.open()) {
28 [ # # # # : 0 : qCWarning(lcContactStore)
# # ]
29 [ # # # # : 0 : << "Failed to open contacts DB:" << m_db.lastError().text();
# # # # ]
30 : 0 : return false;
31 : : }
32 [ + - - + ]: 119 : if (!DatabaseSecurity::restrictExistingFile(dbPath)) {
33 [ # # # # : 0 : qCWarning(lcContactStore)
# # ]
34 [ # # # # ]: 0 : << "Failed to restrict contacts DB permissions:" << dbPath;
35 [ # # ]: 0 : m_db.close();
36 : 0 : return false;
37 : : }
38 : :
39 : : // WAL mode for better performance
40 [ + - ]: 119 : QSqlQuery pragma(m_db);
41 [ + - ]: 119 : pragma.exec(QStringLiteral("PRAGMA journal_mode=WAL"));
42 [ + - ]: 119 : pragma.exec(QStringLiteral("PRAGMA synchronous=NORMAL"));
43 : : // T-619/SEC-19: Prevent SQLITE_BUSY errors during concurrent access
44 [ + - ]: 119 : pragma.exec(QStringLiteral("PRAGMA busy_timeout=5000"));
45 : :
46 [ + - ]: 119 : return createSchema();
47 : 119 : }
48 : :
49 : 150 : void ContactStore::close() {
50 [ + + ]: 150 : if (m_db.isOpen()) {
51 : 119 : m_db.close();
52 : : }
53 : : // T-402/Bug 20: Release reference before removeDatabase
54 [ + - + - ]: 150 : m_db = QSqlDatabase();
55 [ + + ]: 150 : if (!m_connectionName.isEmpty()) {
56 : 119 : QSqlDatabase::removeDatabase(m_connectionName);
57 : 119 : m_connectionName.clear();
58 : : }
59 : 150 : }
60 : :
61 : 59 : bool ContactStore::isOpen() const { return m_db.isOpen(); }
62 : :
63 : 119 : bool ContactStore::createSchema() {
64 [ + - ]: 119 : QSqlQuery q(m_db);
65 [ + - ]: 119 : bool ok = q.exec(QStringLiteral(
66 : : "CREATE TABLE IF NOT EXISTS contacts ("
67 : : " id INTEGER PRIMARY KEY AUTOINCREMENT,"
68 : : " display_name TEXT NOT NULL DEFAULT '',"
69 : : " email TEXT NOT NULL,"
70 : : " source TEXT NOT NULL DEFAULT 'local',"
71 : : " carddav_uid TEXT DEFAULT '',"
72 : : " carddav_etag TEXT DEFAULT '',"
73 : : " last_used TEXT,"
74 : : " use_count INTEGER NOT NULL DEFAULT 0,"
75 : : " created_at TEXT NOT NULL DEFAULT (datetime('now')),"
76 : : " updated_at TEXT NOT NULL DEFAULT (datetime('now'))"
77 : : ")"));
78 [ + + ]: 119 : if (!ok) {
79 [ + - + - : 2 : qCWarning(lcContactStore)
+ + ]
80 [ + - + - : 1 : << "Failed to create contacts table:" << q.lastError().text();
+ - + - ]
81 : 1 : return false;
82 : : }
83 : :
84 [ + - ]: 118 : QSqlQuery schemaQuery(m_db);
85 [ + - ]: 118 : schemaQuery.prepare(QStringLiteral(
86 : : "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'contacts'"));
87 : : const bool needsLegacyMigration =
88 [ + - + - : 354 : schemaQuery.exec() && schemaQuery.next() &&
+ - + - ]
89 [ + - + - : 354 : schemaQuery.value(0).toString().contains(
+ - + + +
- - - -
- ]
90 [ + - + - : 236 : QStringLiteral("UNIQUE(email, source)"));
+ - - - -
- ]
91 [ + - ]: 118 : schemaQuery.finish();
92 [ + + ]: 118 : if (needsLegacyMigration) {
93 [ + - + - : 10 : qCInfo(lcContactStore)
+ + ]
94 [ + - ]: 5 : << "Migrating contacts schema away from global email/source uniqueness";
95 [ + - - + ]: 5 : if (!m_db.transaction()) {
96 [ # # # # : 0 : qCWarning(lcContactStore)
# # ]
97 [ # # ]: 0 : << "Failed to start contacts migration transaction:"
98 [ # # # # : 0 : << m_db.lastError().text();
# # ]
99 : 3 : return false;
100 : : }
101 [ + - ]: 5 : QSqlQuery migrate(m_db);
102 [ + - + + : 10 : if (!migrate.exec(QStringLiteral("ALTER TABLE contacts RENAME TO contacts_old")) ||
- - - - ]
103 [ + - + - : 9 : !migrate.exec(QStringLiteral(
+ + + - -
- - - ]
104 : : "CREATE TABLE contacts ("
105 : : " id INTEGER PRIMARY KEY AUTOINCREMENT,"
106 : : " display_name TEXT NOT NULL DEFAULT '',"
107 : : " email TEXT NOT NULL,"
108 : : " source TEXT NOT NULL DEFAULT 'local',"
109 : : " carddav_uid TEXT DEFAULT '',"
110 : : " carddav_etag TEXT DEFAULT '',"
111 : : " last_used TEXT,"
112 : : " use_count INTEGER NOT NULL DEFAULT 0,"
113 : : " created_at TEXT NOT NULL DEFAULT (datetime('now')),"
114 : : " updated_at TEXT NOT NULL DEFAULT (datetime('now'))"
115 : 8 : ")")) ||
116 [ + - + + : 9 : !migrate.exec(QStringLiteral(
+ + + + -
- - - ]
117 : : "INSERT INTO contacts (id, display_name, email, source, carddav_uid, "
118 : : "carddav_etag, last_used, use_count, created_at, updated_at) "
119 : : "SELECT id, display_name, email, source, carddav_uid, carddav_etag, "
120 [ + - + + ]: 14 : "last_used, use_count, created_at, updated_at FROM contacts_old")) ||
121 [ + - - + : 7 : !migrate.exec(QStringLiteral("DROP TABLE contacts_old"))) {
+ + + + +
+ - - -
- ]
122 [ + - + - : 6 : qCWarning(lcContactStore)
+ + ]
123 [ + - ]: 3 : << "Failed to migrate contacts uniqueness schema:"
124 [ + - + - : 3 : << migrate.lastError().text();
+ - ]
125 [ + - ]: 3 : m_db.rollback();
126 : 3 : return false;
127 : : }
128 [ + - - + ]: 2 : if (!m_db.commit()) {
129 [ # # # # : 0 : qCWarning(lcContactStore)
# # ]
130 [ # # ]: 0 : << "Failed to commit contacts uniqueness migration:"
131 [ # # # # : 0 : << m_db.lastError().text();
# # ]
132 [ # # ]: 0 : m_db.rollback();
133 : 0 : return false;
134 : : }
135 [ + + ]: 5 : }
136 : :
137 [ + - ]: 115 : q.exec(QStringLiteral(
138 : : "CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email)"));
139 [ + - ]: 115 : q.exec(QStringLiteral(
140 : : "CREATE INDEX IF NOT EXISTS idx_contacts_last_used "
141 : : "ON contacts(last_used DESC)"));
142 [ + - ]: 115 : q.exec(QStringLiteral(
143 : : "CREATE INDEX IF NOT EXISTS idx_contacts_carddav_uid "
144 : : "ON contacts(carddav_uid)"));
145 [ + - ]: 115 : q.exec(QStringLiteral(
146 : : "CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_local_email_unique "
147 : : "ON contacts(email, source) WHERE source = 'local'"));
148 [ + - ]: 115 : q.exec(QStringLiteral(
149 : : "CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_carddav_identity_unique "
150 : : "ON contacts(carddav_uid, email, source) WHERE source = 'carddav'"));
151 : :
152 : 115 : return true;
153 : 119 : }
154 : :
155 : 108 : Contact ContactStore::contactFromQuery(const QSqlQuery &q) const {
156 : 108 : Contact c;
157 [ + - + - ]: 108 : c.id = q.value(0).toLongLong();
158 [ + - + - ]: 108 : c.displayName = q.value(1).toString();
159 [ + - + - ]: 108 : c.email = q.value(2).toString();
160 [ + - + - ]: 108 : c.source = q.value(3).toString();
161 [ + - + - ]: 108 : c.cardDavUid = q.value(4).toString();
162 [ + - + - ]: 108 : c.cardDavEtag = q.value(5).toString();
163 [ + - + - : 108 : c.lastUsed = QDateTime::fromString(q.value(6).toString(), Qt::ISODate);
+ - ]
164 [ + - + - ]: 108 : c.useCount = q.value(7).toInt();
165 [ + - + - : 108 : c.createdAt = QDateTime::fromString(q.value(8).toString(), Qt::ISODate);
+ - ]
166 [ + - + - : 108 : c.updatedAt = QDateTime::fromString(q.value(9).toString(), Qt::ISODate);
+ - ]
167 : 108 : return c;
168 : 0 : }
169 : :
170 : : // --- CRUD ---
171 : :
172 : 81 : qint64 ContactStore::addContact(const Contact &contact) {
173 [ + - ]: 81 : QSqlQuery q(m_db);
174 [ + - ]: 81 : q.prepare(QStringLiteral(
175 : : "INSERT INTO contacts (display_name, email, source, carddav_uid, "
176 : : "carddav_etag, last_used, use_count) "
177 : : "VALUES (?, ?, ?, ?, ?, ?, ?)"));
178 [ + - ]: 81 : q.addBindValue(contact.displayName);
179 [ + - + - ]: 81 : q.addBindValue(contact.email.toLower());
180 [ + + + - : 162 : q.addBindValue(contact.source.isEmpty() ? QStringLiteral("local")
+ + - - ]
181 : 58 : : contact.source);
182 [ + - ]: 81 : q.addBindValue(contact.cardDavUid);
183 [ + - ]: 81 : q.addBindValue(contact.cardDavEtag);
184 [ + - + - ]: 162 : q.addBindValue(contact.lastUsed.isValid()
185 [ + + + - : 162 : ? contact.lastUsed.toString(Qt::ISODate)
+ + - - ]
186 : : : QVariant());
187 [ + - ]: 81 : q.addBindValue(contact.useCount);
188 : :
189 [ + - + + ]: 81 : if (!q.exec()) {
190 [ + - + - : 8 : qCWarning(lcContactStore)
+ + ]
191 [ + - + - : 4 : << "addContact failed:" << q.lastError().text();
+ - + - ]
192 : 4 : return -1;
193 : : }
194 [ + - + - ]: 77 : return q.lastInsertId().toLongLong();
195 : 81 : }
196 : :
197 : 6 : bool ContactStore::updateContact(const Contact &contact) {
198 [ + - ]: 6 : QSqlQuery q(m_db);
199 [ + - ]: 6 : q.prepare(QStringLiteral(
200 : : "UPDATE contacts SET display_name = ?, email = ?, source = ?, "
201 : : "carddav_uid = ?, carddav_etag = ?, last_used = ?, use_count = ?, "
202 : : "updated_at = datetime('now') WHERE id = ?"));
203 [ + - ]: 6 : q.addBindValue(contact.displayName);
204 [ + - + - ]: 6 : q.addBindValue(contact.email.toLower());
205 [ + - ]: 6 : q.addBindValue(contact.source);
206 [ + - ]: 6 : q.addBindValue(contact.cardDavUid);
207 [ + - ]: 6 : q.addBindValue(contact.cardDavEtag);
208 [ + - + - ]: 12 : q.addBindValue(contact.lastUsed.isValid()
209 [ + + + - : 12 : ? contact.lastUsed.toString(Qt::ISODate)
+ + - - ]
210 : : : QVariant());
211 [ + - ]: 6 : q.addBindValue(contact.useCount);
212 [ + - ]: 6 : q.addBindValue(contact.id);
213 : :
214 [ + - + + ]: 6 : if (!q.exec()) {
215 [ + - + - : 2 : qCWarning(lcContactStore)
+ + ]
216 [ + - + - : 1 : << "updateContact failed:" << q.lastError().text();
+ - + - ]
217 : 1 : return false;
218 : : }
219 [ + - ]: 5 : return q.numRowsAffected() > 0;
220 : 6 : }
221 : :
222 : 10 : bool ContactStore::removeContact(qint64 id) {
223 [ + - ]: 10 : QSqlQuery q(m_db);
224 [ + - ]: 10 : q.prepare(QStringLiteral("DELETE FROM contacts WHERE id = ?"));
225 [ + - ]: 10 : q.addBindValue(id);
226 [ + - + + ]: 10 : if (!q.exec()) {
227 [ + - + - : 2 : qCWarning(lcContactStore)
+ + ]
228 [ + - + - : 1 : << "removeContact failed:" << q.lastError().text();
+ - + - ]
229 : 1 : return false;
230 : : }
231 [ + - ]: 9 : return q.numRowsAffected() > 0;
232 : 10 : }
233 : :
234 : 11 : std::optional<Contact> ContactStore::contactById(qint64 id) const {
235 [ + - ]: 11 : QSqlQuery q(m_db);
236 [ + - ]: 11 : q.prepare(QStringLiteral(
237 : : "SELECT id, display_name, email, source, carddav_uid, carddav_etag, "
238 : : "last_used, use_count, created_at, updated_at "
239 : : "FROM contacts WHERE id = ?"));
240 [ + - ]: 11 : q.addBindValue(id);
241 [ + - + + : 11 : if (q.exec() && q.next()) {
+ - + + +
+ ]
242 [ + - ]: 7 : return contactFromQuery(q);
243 : : }
244 : 4 : return std::nullopt;
245 : 11 : }
246 : :
247 : 13 : std::optional<Contact> ContactStore::contactByEmail(
248 : : const QString &email) const {
249 [ + - ]: 13 : QSqlQuery q(m_db);
250 [ + - ]: 13 : q.prepare(QStringLiteral(
251 : : "SELECT id, display_name, email, source, carddav_uid, carddav_etag, "
252 : : "last_used, use_count, created_at, updated_at "
253 : : "FROM contacts WHERE email = ? ORDER BY use_count DESC LIMIT 1"));
254 [ + - + - ]: 13 : q.addBindValue(email.toLower());
255 [ + - + + : 13 : if (q.exec() && q.next()) {
+ - + + +
+ ]
256 [ + - ]: 11 : return contactFromQuery(q);
257 : : }
258 : 2 : return std::nullopt;
259 : 13 : }
260 : :
261 : 27 : QList<Contact> ContactStore::allContacts() const {
262 : 27 : QList<Contact> result;
263 [ + - ]: 27 : QSqlQuery q(m_db);
264 [ + - ]: 27 : q.exec(QStringLiteral(
265 : : "SELECT id, display_name, email, source, carddav_uid, carddav_etag, "
266 : : "last_used, use_count, created_at, updated_at "
267 : : "FROM contacts ORDER BY display_name COLLATE NOCASE"));
268 [ + - + + ]: 55 : while (q.next()) {
269 [ + - + - ]: 28 : result.append(contactFromQuery(q));
270 : : }
271 : 27 : return result;
272 : 27 : }
273 : :
274 : : // --- Search & Suggestions ---
275 : :
276 : 61 : QList<Contact> ContactStore::search(const QString &query, int limit) const {
277 : 61 : QList<Contact> result;
278 [ + - + + ]: 61 : if (query.trimmed().isEmpty())
279 : 7 : return result;
280 : :
281 [ + - ]: 54 : QSqlQuery q(m_db);
282 : : // SEC-2026-07-21-22: ESCAPE '\' so % and _ in user input are literal.
283 [ + - ]: 54 : q.prepare(QStringLiteral(
284 : : "SELECT id, display_name, email, source, carddav_uid, carddav_etag, "
285 : : "last_used, use_count, created_at, updated_at "
286 : : "FROM contacts "
287 : : "WHERE display_name LIKE ? ESCAPE '\\' OR email LIKE ? ESCAPE '\\' "
288 : : "ORDER BY use_count DESC, last_used DESC "
289 : : "LIMIT ?"));
290 : : // Escape LIKE wildcards in the user-supplied query.
291 [ + - ]: 54 : QString escapedQuery = query.trimmed();
292 [ + - ]: 54 : escapedQuery.replace(QLatin1Char('\\'), QStringLiteral("\\\\"));
293 [ + - ]: 54 : escapedQuery.replace(QLatin1Char('%'), QStringLiteral("\\%"));
294 [ + - ]: 54 : escapedQuery.replace(QLatin1Char('_'), QStringLiteral("\\_"));
295 [ + - ]: 108 : QString pattern = QStringLiteral("%%1%").arg(escapedQuery);
296 [ + - ]: 54 : q.addBindValue(pattern);
297 [ + - ]: 54 : q.addBindValue(pattern);
298 [ + - ]: 54 : q.addBindValue(limit);
299 : :
300 [ + - + + ]: 54 : if (q.exec()) {
301 [ + - + + ]: 93 : while (q.next()) {
302 [ + - + - ]: 40 : result.append(contactFromQuery(q));
303 : : }
304 : : }
305 : 54 : return result;
306 : 54 : }
307 : :
308 : : // --- Usage Tracking ---
309 : :
310 : 15 : void ContactStore::recordUsage(const QString &email,
311 : : const QString &displayName) {
312 [ + - + - ]: 15 : QString lowerEmail = email.toLower().trimmed();
313 [ + + ]: 15 : if (lowerEmail.isEmpty())
314 : 2 : return;
315 : :
316 : : // Try to update existing contact
317 [ + - ]: 13 : QSqlQuery q(m_db);
318 [ + - ]: 13 : q.prepare(QStringLiteral(
319 : : "UPDATE contacts SET use_count = use_count + 1, "
320 : : "last_used = datetime('now'), updated_at = datetime('now'), "
321 : : "display_name = CASE WHEN display_name = '' OR display_name = email "
322 : : " THEN ? ELSE display_name END "
323 : : "WHERE email = ?"));
324 [ + - + - ]: 13 : q.addBindValue(displayName.trimmed());
325 [ + - ]: 13 : q.addBindValue(lowerEmail);
326 : :
327 [ + - + + : 13 : if (q.exec() && q.numRowsAffected() > 0) {
+ - + + +
+ ]
328 : 5 : return; // Updated existing
329 : : }
330 : :
331 : : // Insert new local contact
332 : 8 : Contact c;
333 [ + - + + : 16 : c.displayName = displayName.trimmed().isEmpty() ? lowerEmail
+ - ]
334 : 8 : : displayName.trimmed();
335 : 8 : c.email = lowerEmail;
336 : 8 : c.source = QStringLiteral("local");
337 : 8 : c.useCount = 1;
338 [ + - ]: 8 : c.lastUsed = QDateTime::currentDateTime();
339 [ + - ]: 8 : addContact(c);
340 [ + + + + ]: 20 : }
341 : :
342 : : // --- CardDAV Sync Helpers ---
343 : :
344 : 21 : QList<Contact> ContactStore::cardDavContacts() const {
345 : 21 : QList<Contact> result;
346 [ + - ]: 21 : QSqlQuery q(m_db);
347 [ + - ]: 21 : q.exec(QStringLiteral(
348 : : "SELECT id, display_name, email, source, carddav_uid, carddav_etag, "
349 : : "last_used, use_count, created_at, updated_at "
350 : : "FROM contacts WHERE source = 'carddav' "
351 : : "ORDER BY display_name COLLATE NOCASE"));
352 [ + - + + ]: 43 : while (q.next()) {
353 [ + - + - ]: 22 : result.append(contactFromQuery(q));
354 : : }
355 : 21 : return result;
356 : 21 : }
357 : :
358 : 24 : void ContactStore::upsertCardDavContact(const Contact &contact) {
359 [ + - ]: 24 : QSqlQuery q(m_db);
360 : : // Try update first by CardDAV identity and email. A single vCard UID can
361 : : // legitimately contain multiple EMAIL entries, so email is part of the row
362 : : // identity here.
363 [ + - ]: 24 : q.prepare(QStringLiteral(
364 : : "UPDATE contacts SET display_name = ?, email = ?, "
365 : : "carddav_etag = ?, updated_at = datetime('now') "
366 : : "WHERE carddav_uid = ? AND email = ? AND source = 'carddav'"));
367 [ + - ]: 24 : q.addBindValue(contact.displayName);
368 [ + - + - ]: 24 : q.addBindValue(contact.email.toLower());
369 [ + - ]: 24 : q.addBindValue(contact.cardDavEtag);
370 [ + - ]: 24 : q.addBindValue(contact.cardDavUid);
371 [ + - + - ]: 24 : q.addBindValue(contact.email.toLower());
372 : :
373 [ + - + + : 24 : if (q.exec() && q.numRowsAffected() > 0) {
+ - + + +
+ ]
374 : 4 : return; // Updated
375 : : }
376 : :
377 : : // Insert new
378 : 20 : Contact c = contact;
379 : 20 : c.source = QStringLiteral("carddav");
380 [ + - ]: 20 : addContact(c);
381 [ + + ]: 24 : }
382 : :
383 : 10 : void ContactStore::removeStaleCardDavContacts(
384 : : const QStringList &activeUids, bool allowEmpty) {
385 : : // T-623/FUNC-12: Guard against empty list — a network error returning an
386 : : // empty list should NOT delete all CardDAV contacts.
387 [ + + ]: 10 : if (activeUids.isEmpty()) {
388 [ + + ]: 5 : if (allowEmpty) {
389 [ + - ]: 3 : QSqlQuery q(m_db);
390 [ + - + + ]: 3 : if (!q.exec(QStringLiteral(
391 : : "DELETE FROM contacts WHERE source = 'carddav'"))) {
392 [ + - + - : 2 : qCWarning(lcContactStore)
+ + ]
393 [ + - ]: 1 : << "removeStaleCardDavContacts failed:"
394 [ + - + - : 1 : << q.lastError().text();
+ - ]
395 : : }
396 : 3 : return;
397 : 3 : }
398 [ + - + - : 4 : qCWarning(lcContactStore)
+ + ]
399 : : << "removeStaleCardDavContacts: activeUids is empty — skipping"
400 [ + - ]: 2 : " to prevent accidental deletion of all contacts";
401 : 2 : return;
402 : : }
403 : :
404 : 5 : QString error;
405 [ + + ]: 10 : if (!DatabaseSync::removeRowsMissingFromUidSet(
406 [ + - ]: 5 : m_db,
407 : 10 : QStringLiteral(
408 : : "DELETE FROM contacts WHERE source = 'carddav' "
409 : : "AND NOT EXISTS "
410 : : "(SELECT 1 FROM mailjd_active_sync_uids active "
411 : : "WHERE active.uid = contacts.carddav_uid)"),
412 : : {}, activeUids, &error)) {
413 [ + - + - : 2 : qCWarning(lcContactStore)
+ + ]
414 [ + - + - ]: 1 : << "removeStaleCardDavContacts failed:" << error;
415 : : }
416 : 5 : }
|