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 [ + + + - : 21 : Q_LOGGING_CATEGORY(lcContactStore, "mailjd.contactstore")
+ - - - ]
11 : :
12 [ + - ]: 121 : ContactStore::ContactStore(QObject *parent) : QObject(parent) {}
13 : :
14 : 80 : ContactStore::~ContactStore() { close(); }
15 : :
16 : 120 : bool ContactStore::open(const QString &dbPath) {
17 [ + - + + ]: 120 : if (!DatabaseSecurity::preparePath(dbPath)) {
18 [ + - + - : 2 : qCWarning(lcContactStore)
+ + ]
19 [ + - + - ]: 1 : << "Failed to create private contacts DB:" << dbPath;
20 : 1 : 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 [ + - + - : 2 : qCWarning(lcContactStore)
+ + ]
29 [ + - + - : 1 : << "Failed to open contacts DB:" << m_db.lastError().text();
+ - + - ]
30 : 1 : return false;
31 : : }
32 [ + - - + ]: 118 : 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 [ + - ]: 118 : QSqlQuery pragma(m_db);
41 [ + - ]: 118 : pragma.exec(QStringLiteral("PRAGMA journal_mode=WAL"));
42 [ + - ]: 118 : pragma.exec(QStringLiteral("PRAGMA synchronous=NORMAL"));
43 : : // T-619/SEC-19: Prevent SQLITE_BUSY errors during concurrent access
44 [ + - ]: 118 : pragma.exec(QStringLiteral("PRAGMA busy_timeout=5000"));
45 : :
46 [ + - ]: 118 : return createSchema();
47 : 118 : }
48 : :
49 : 98 : void ContactStore::close() {
50 [ + + ]: 98 : if (m_db.isOpen()) {
51 : 68 : m_db.close();
52 : : }
53 : : // T-402/Bug 20: Release reference before removeDatabase
54 [ + - + - ]: 98 : m_db = QSqlDatabase();
55 [ + + ]: 98 : if (!m_connectionName.isEmpty()) {
56 : 69 : QSqlDatabase::removeDatabase(m_connectionName);
57 : 69 : m_connectionName.clear();
58 : : }
59 : 98 : }
60 : :
61 : 51 : bool ContactStore::isOpen() const { return m_db.isOpen(); }
62 : :
63 : 118 : bool ContactStore::createSchema() {
64 [ + - ]: 118 : QSqlQuery q(m_db);
65 [ + - ]: 118 : 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 [ + + ]: 118 : if (!ok) {
79 [ + - + - : 2 : qCWarning(lcContactStore)
+ + ]
80 [ + - + - : 1 : << "Failed to create contacts table:" << q.lastError().text();
+ - + - ]
81 : 1 : return false;
82 : : }
83 : :
84 [ + - ]: 117 : QSqlQuery schemaQuery(m_db);
85 [ + - ]: 117 : schemaQuery.prepare(QStringLiteral(
86 : : "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'contacts'"));
87 : : const bool needsLegacyMigration =
88 [ + - + - : 351 : schemaQuery.exec() && schemaQuery.next() &&
+ - + - ]
89 [ + - + - : 351 : schemaQuery.value(0).toString().contains(
+ - + + +
- - - -
- ]
90 [ + - + - : 234 : QStringLiteral("UNIQUE(email, source)"));
+ - - - -
- ]
91 [ + - ]: 117 : schemaQuery.finish();
92 [ + + ]: 117 : 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 [ + - ]: 114 : q.exec(QStringLiteral(
138 : : "CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email)"));
139 [ + - ]: 114 : q.exec(QStringLiteral(
140 : : "CREATE INDEX IF NOT EXISTS idx_contacts_last_used "
141 : : "ON contacts(last_used DESC)"));
142 [ + - ]: 114 : q.exec(QStringLiteral(
143 : : "CREATE INDEX IF NOT EXISTS idx_contacts_carddav_uid "
144 : : "ON contacts(carddav_uid)"));
145 [ + - ]: 114 : q.exec(QStringLiteral(
146 : : "CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_local_email_unique "
147 : : "ON contacts(email, source) WHERE source = 'local'"));
148 [ + - ]: 114 : 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 : 114 : return true;
153 : 118 : }
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 [ + - ]: 54 : q.prepare(QStringLiteral(
283 : : "SELECT id, display_name, email, source, carddav_uid, carddav_etag, "
284 : : "last_used, use_count, created_at, updated_at "
285 : : "FROM contacts "
286 : : "WHERE display_name LIKE ? OR email LIKE ? "
287 : : "ORDER BY use_count DESC, last_used DESC "
288 : : "LIMIT ?"));
289 [ + - + - ]: 108 : QString pattern = QStringLiteral("%%1%").arg(query.trimmed());
290 [ + - ]: 54 : q.addBindValue(pattern);
291 [ + - ]: 54 : q.addBindValue(pattern);
292 [ + - ]: 54 : q.addBindValue(limit);
293 : :
294 [ + - + + ]: 54 : if (q.exec()) {
295 [ + - + + ]: 93 : while (q.next()) {
296 [ + - + - ]: 40 : result.append(contactFromQuery(q));
297 : : }
298 : : }
299 : 54 : return result;
300 : 54 : }
301 : :
302 : : // --- Usage Tracking ---
303 : :
304 : 15 : void ContactStore::recordUsage(const QString &email,
305 : : const QString &displayName) {
306 [ + - + - ]: 15 : QString lowerEmail = email.toLower().trimmed();
307 [ + + ]: 15 : if (lowerEmail.isEmpty())
308 : 2 : return;
309 : :
310 : : // Try to update existing contact
311 [ + - ]: 13 : QSqlQuery q(m_db);
312 [ + - ]: 13 : q.prepare(QStringLiteral(
313 : : "UPDATE contacts SET use_count = use_count + 1, "
314 : : "last_used = datetime('now'), updated_at = datetime('now'), "
315 : : "display_name = CASE WHEN display_name = '' OR display_name = email "
316 : : " THEN ? ELSE display_name END "
317 : : "WHERE email = ?"));
318 [ + - + - ]: 13 : q.addBindValue(displayName.trimmed());
319 [ + - ]: 13 : q.addBindValue(lowerEmail);
320 : :
321 [ + - + + : 13 : if (q.exec() && q.numRowsAffected() > 0) {
+ - + + +
+ ]
322 : 5 : return; // Updated existing
323 : : }
324 : :
325 : : // Insert new local contact
326 : 8 : Contact c;
327 [ + - + + : 16 : c.displayName = displayName.trimmed().isEmpty() ? lowerEmail
+ - ]
328 : 8 : : displayName.trimmed();
329 : 8 : c.email = lowerEmail;
330 : 8 : c.source = QStringLiteral("local");
331 : 8 : c.useCount = 1;
332 [ + - ]: 8 : c.lastUsed = QDateTime::currentDateTime();
333 [ + - ]: 8 : addContact(c);
334 [ + + + + ]: 20 : }
335 : :
336 : : // --- CardDAV Sync Helpers ---
337 : :
338 : 21 : QList<Contact> ContactStore::cardDavContacts() const {
339 : 21 : QList<Contact> result;
340 [ + - ]: 21 : QSqlQuery q(m_db);
341 [ + - ]: 21 : q.exec(QStringLiteral(
342 : : "SELECT id, display_name, email, source, carddav_uid, carddav_etag, "
343 : : "last_used, use_count, created_at, updated_at "
344 : : "FROM contacts WHERE source = 'carddav' "
345 : : "ORDER BY display_name COLLATE NOCASE"));
346 [ + - + + ]: 43 : while (q.next()) {
347 [ + - + - ]: 22 : result.append(contactFromQuery(q));
348 : : }
349 : 21 : return result;
350 : 21 : }
351 : :
352 : 24 : void ContactStore::upsertCardDavContact(const Contact &contact) {
353 [ + - ]: 24 : QSqlQuery q(m_db);
354 : : // Try update first by CardDAV identity and email. A single vCard UID can
355 : : // legitimately contain multiple EMAIL entries, so email is part of the row
356 : : // identity here.
357 [ + - ]: 24 : q.prepare(QStringLiteral(
358 : : "UPDATE contacts SET display_name = ?, email = ?, "
359 : : "carddav_etag = ?, updated_at = datetime('now') "
360 : : "WHERE carddav_uid = ? AND email = ? AND source = 'carddav'"));
361 [ + - ]: 24 : q.addBindValue(contact.displayName);
362 [ + - + - ]: 24 : q.addBindValue(contact.email.toLower());
363 [ + - ]: 24 : q.addBindValue(contact.cardDavEtag);
364 [ + - ]: 24 : q.addBindValue(contact.cardDavUid);
365 [ + - + - ]: 24 : q.addBindValue(contact.email.toLower());
366 : :
367 [ + - + + : 24 : if (q.exec() && q.numRowsAffected() > 0) {
+ - + + +
+ ]
368 : 4 : return; // Updated
369 : : }
370 : :
371 : : // Insert new
372 : 20 : Contact c = contact;
373 : 20 : c.source = QStringLiteral("carddav");
374 [ + - ]: 20 : addContact(c);
375 [ + + ]: 24 : }
376 : :
377 : 10 : void ContactStore::removeStaleCardDavContacts(
378 : : const QStringList &activeUids, bool allowEmpty) {
379 : : // T-623/FUNC-12: Guard against empty list — a network error returning an
380 : : // empty list should NOT delete all CardDAV contacts.
381 [ + + ]: 10 : if (activeUids.isEmpty()) {
382 [ + + ]: 5 : if (allowEmpty) {
383 [ + - ]: 3 : QSqlQuery q(m_db);
384 [ + - + + ]: 3 : if (!q.exec(QStringLiteral(
385 : : "DELETE FROM contacts WHERE source = 'carddav'"))) {
386 [ + - + - : 2 : qCWarning(lcContactStore)
+ + ]
387 [ + - ]: 1 : << "removeStaleCardDavContacts failed:"
388 [ + - + - : 1 : << q.lastError().text();
+ - ]
389 : : }
390 : 3 : return;
391 : 3 : }
392 [ + - + - : 4 : qCWarning(lcContactStore)
+ + ]
393 : : << "removeStaleCardDavContacts: activeUids is empty — skipping"
394 [ + - ]: 2 : " to prevent accidental deletion of all contacts";
395 : 2 : return;
396 : : }
397 : :
398 : 5 : QString error;
399 [ + + ]: 10 : if (!DatabaseSync::removeRowsMissingFromUidSet(
400 [ + - ]: 5 : m_db,
401 : 10 : QStringLiteral(
402 : : "DELETE FROM contacts WHERE source = 'carddav' "
403 : : "AND NOT EXISTS "
404 : : "(SELECT 1 FROM mailjd_active_sync_uids active "
405 : : "WHERE active.uid = contacts.carddav_uid)"),
406 : : {}, activeUids, &error)) {
407 [ + - + - : 2 : qCWarning(lcContactStore)
+ + ]
408 [ + - + - ]: 1 : << "removeStaleCardDavContacts failed:" << error;
409 : : }
410 : 5 : }
|