Compare commits

..

1 Commits

Author SHA1 Message Date
25e9bc6e93 fix(database): make v2->v3 chatlog migration deterministic and FK-safe
All checks were successful
CI Code / Check spelling (pull_request) Successful in 16s
CI Code / Check coding style (pull_request) Successful in 31s
CI Code / Code Coverage (pull_request) Successful in 3m12s
CI Code / Linux (debian) (pull_request) Successful in 4m40s
CI Code / Linux (arch) (pull_request) Successful in 6m54s
CI Code / Linux (ubuntu) (pull_request) Successful in 7m3s
_migrate_to_v3 deduplicated duplicate archive_id rows with a bare-column
GROUP BY, which lets SQLite keep an arbitrary row per group (in practice the
older row, dropping the latest), and copied rows with SELECT *, which relies
on positional column matching between the v2 and v3 schemas.

- Keep MAX(id) per archive_id group ("latest insert wins") instead of
  GROUP BY's implementation-defined pick.
- Sweep replaces_db_id / replaced_by_db_id after dedupe, NULLing references
  to dropped rows so the migrated table carries no dangling XEP-0308 (LMC)
  links (the "Broken LMC reference" condition verify_integrity flags).
- List columns explicitly instead of SELECT * so the copy cannot mis-bind,
  and note at the schema that column changes must be mirrored here.

Refs #128
2026-06-19 08:43:23 +00:00
4 changed files with 47 additions and 52 deletions

View File

@@ -178,6 +178,9 @@ _sqlite_init(ProfAccount* account)
// replaced_by_db_id Inverse FK: id of the most recent correction.
// Maintained by the AFTER INSERT trigger below so
// readers can follow the chain forward in O(1).
//
// NOTE: any change to these columns (add/remove/reorder) must be mirrored
// in `_migrate_to_v3`, which copies rows via an explicit column list.
const char* query = "CREATE TABLE IF NOT EXISTS `ChatLogs` ("
"`id` INTEGER PRIMARY KEY AUTOINCREMENT, "
"`from_jid` TEXT NOT NULL, "
@@ -865,11 +868,37 @@ _migrate_to_v3(void)
"`replaces_db_id` INTEGER, "
"`replaced_by_db_id` INTEGER)",
// Copy rows into the deduped table. Columns are listed explicitly
// (instead of `SELECT *`) so the migration cannot silently mis-bind
// should the v2/v3 column order ever diverge — keep this list in sync
// with the `ChatLogs_v3_migration` schema above and the live `ChatLogs`
// schema. For duplicate `archive_id` groups (MAM rebroadcasts) keep the
// highest `id` deterministically ("latest insert wins") rather than
// GROUP BY's implementation-defined arbitrary pick.
"INSERT INTO `ChatLogs_v3_migration` "
"SELECT * FROM `ChatLogs` "
"WHERE `archive_id` IS NULL "
"(`id`, `from_jid`, `to_jid`, `from_resource`, `to_resource`, `message`, "
"`timestamp`, `type`, `stanza_id`, `archive_id`, `encryption`, "
"`marked_read`, `replace_id`, `replaces_db_id`, `replaced_by_db_id`) "
"SELECT `id`, `from_jid`, `to_jid`, `from_resource`, `to_resource`, `message`, "
"`timestamp`, `type`, `stanza_id`, `archive_id`, `encryption`, "
"`marked_read`, `replace_id`, `replaces_db_id`, `replaced_by_db_id` "
"FROM `ChatLogs` WHERE `archive_id` IS NULL "
"UNION ALL "
"SELECT * FROM (SELECT * FROM `ChatLogs` WHERE `archive_id` IS NOT NULL GROUP BY `archive_id`)",
"SELECT `id`, `from_jid`, `to_jid`, `from_resource`, `to_resource`, `message`, "
"`timestamp`, `type`, `stanza_id`, `archive_id`, `encryption`, "
"`marked_read`, `replace_id`, `replaces_db_id`, `replaced_by_db_id` "
"FROM `ChatLogs` WHERE `archive_id` IS NOT NULL "
"AND `id` IN (SELECT MAX(`id`) FROM `ChatLogs` WHERE `archive_id` IS NOT NULL GROUP BY `archive_id`)",
// Dropping duplicate rows above can leave the surviving rows' correction
// back-links pointing at ids that no longer exist. NULL them out so the
// XEP-0308 (LMC) chain carries no dangling local foreign keys.
"UPDATE `ChatLogs_v3_migration` SET `replaces_db_id` = NULL "
"WHERE `replaces_db_id` IS NOT NULL "
"AND `replaces_db_id` NOT IN (SELECT `id` FROM `ChatLogs_v3_migration`)",
"UPDATE `ChatLogs_v3_migration` SET `replaced_by_db_id` = NULL "
"WHERE `replaced_by_db_id` IS NOT NULL "
"AND `replaced_by_db_id` NOT IN (SELECT `id` FROM `ChatLogs_v3_migration`)",
"DROP TABLE `ChatLogs` ",
"ALTER TABLE `ChatLogs_v3_migration` RENAME TO `ChatLogs` ",

View File

@@ -1132,17 +1132,12 @@ _handle_groupchat(xmpp_stanza_t* const stanza)
message->timestamp = NULL;
}
// XEP-0203 §4: trust <delay> only from the room or our server, not a client-forged stamp
message->timestamp = stanza_get_oldest_delay_from(stanza, from_jid->barejid);
if (!message->timestamp && from_jid->domainpart) {
message->timestamp = stanza_get_oldest_delay_from(stanza, from_jid->domainpart);
}
if (!message->timestamp) {
const char* my_domain = connection_get_domain();
if (my_domain) {
message->timestamp = stanza_get_oldest_delay_from(stanza, my_domain);
}
}
// we want to display the oldest delay
message->timestamp = stanza_get_oldest_delay(stanza);
// now this has nothing to do with MUC history
// it's just setting the time to the received time so upon displaying we can use this time
// for example in win_println_incoming_muc_msg()
if (!message->timestamp) {
message->timestamp = g_date_time_new_now_local();
}
@@ -1273,15 +1268,7 @@ _handle_muc_private_message(xmpp_stanza_t* const stanza)
_receive_omemo(stanza, message);
#endif
// XEP-0203 §4: trust <delay> only from a server domain, not a client-forged stamp
const char* my_domain = connection_get_domain();
message->timestamp = stanza_get_delay_from(stanza, my_domain);
if (!message->timestamp && message->from_jid->domainpart) {
message->timestamp = stanza_get_delay_from(stanza, message->from_jid->domainpart);
}
if (!message->timestamp) {
message->timestamp = g_date_time_new_now_local();
}
message->timestamp = stanza_get_delay(stanza);
message->body = xmpp_message_get_body(stanza);
if (!message->plain && !message->body) {
@@ -1432,12 +1419,8 @@ _handle_chat(xmpp_stanza_t* const stanza, gboolean is_mam, gboolean is_carbon, c
// timestamp provided outside like in a <forwarded> by MAM
message->timestamp = timestamp;
} else {
// XEP-0203 §4: trust <delay> only from a server domain, not a client-forged stamp
const char* my_domain = connection_get_domain();
message->timestamp = stanza_get_delay_from(stanza, my_domain);
if (!message->timestamp && jid->domainpart) {
message->timestamp = stanza_get_delay_from(stanza, jid->domainpart);
}
// timestamp in the message stanza or use time of receival (now)
message->timestamp = stanza_get_delay(stanza);
if (!message->timestamp) {
message->timestamp = g_date_time_new_now_local();
}

View File

@@ -1163,7 +1163,7 @@ _stanza_get_delay_timestamp_xep0091(xmpp_stanza_t* const x_stanza)
}
GDateTime*
stanza_get_delay_from(xmpp_stanza_t* const stanza, const char* const from)
stanza_get_delay_from(xmpp_stanza_t* const stanza, gchar* from)
{
xmpp_stanza_t* delay = NULL;
@@ -1195,12 +1195,6 @@ stanza_get_delay_from(xmpp_stanza_t* const stanza, const char* const from)
GDateTime*
stanza_get_oldest_delay(xmpp_stanza_t* const stanza)
{
return stanza_get_oldest_delay_from(stanza, NULL);
}
GDateTime*
stanza_get_oldest_delay_from(xmpp_stanza_t* const stanza, const char* const from)
{
xmpp_stanza_t* child;
const char* child_name;
@@ -1211,37 +1205,27 @@ stanza_get_oldest_delay_from(xmpp_stanza_t* const stanza, const char* const from
child_name = xmpp_stanza_get_name(child);
if (child_name && g_strcmp0(child_name, STANZA_NAME_DELAY) == 0) {
if (from) {
const char* child_from = xmpp_stanza_get_attribute(child, STANZA_ATTR_FROM);
if (!child_from || g_strcmp0(child_from, from) != 0)
continue;
}
GDateTime* tmp = _stanza_get_delay_timestamp_xep0203(child);
if (oldest == NULL) {
oldest = tmp;
} else if (tmp && g_date_time_compare(oldest, tmp) == 1) {
} else if (g_date_time_compare(oldest, tmp) == 1) {
g_date_time_unref(oldest);
oldest = tmp;
} else if (tmp) {
} else {
g_date_time_unref(tmp);
}
}
if (child_name && g_strcmp0(child_name, STANZA_NAME_X) == 0) {
if (from) {
const char* child_from = xmpp_stanza_get_attribute(child, STANZA_ATTR_FROM);
if (!child_from || g_strcmp0(child_from, from) != 0)
continue;
}
GDateTime* tmp = _stanza_get_delay_timestamp_xep0091(child);
if (oldest == NULL) {
oldest = tmp;
} else if (tmp && g_date_time_compare(oldest, tmp) == 1) {
} else if (g_date_time_compare(oldest, tmp) == 1) {
g_date_time_unref(oldest);
oldest = tmp;
} else if (tmp) {
} else {
g_date_time_unref(tmp);
}
}

View File

@@ -305,9 +305,8 @@ xmpp_stanza_t* stanza_create_mediated_invite(xmpp_ctx_t* ctx, const char* const
gboolean stanza_contains_chat_state(xmpp_stanza_t* stanza);
GDateTime* stanza_get_delay(xmpp_stanza_t* const stanza);
GDateTime* stanza_get_delay_from(xmpp_stanza_t* const stanza, const char* const from);
GDateTime* stanza_get_delay_from(xmpp_stanza_t* const stanza, gchar* from);
GDateTime* stanza_get_oldest_delay(xmpp_stanza_t* const stanza);
GDateTime* stanza_get_oldest_delay_from(xmpp_stanza_t* const stanza, const char* const from);
gboolean stanza_is_muc_presence(xmpp_stanza_t* const stanza);
gboolean stanza_is_muc_self_presence(xmpp_stanza_t* const stanza,