mirror of
https://git.jabber.space/devs/cproof.git
synced 2026-07-20 06:46:21 +00:00
feat(history): flat-file backend with bidirectional SQLite migration
A flat-file alternative to the SQLite chatlog backend with runtime
switching, full migration tooling, integrity verification, and a
synthetic load harness. SQLite remains the default; both backends share
one dispatch layer (db_backend_t vtable) so callers don't change.
Storage layout
- Per-contact append-only `flatlog/<account>/<contact>/history.log`
under XDG_DATA_HOME, one line per message
- Single-line file header with embedded format-version marker
(FLATFILE_FORMAT_VERSION); reader warns on missing or mismatched
marker, writer and checker stay in sync via preprocessor
stringification
- Deterministic key=value metadata (`id`, `aid`, `corrects`, `to`,
`to_res`, `read`) plus escaped body \u2014 `\|`, `\]`, `\\`, `\n`, `\r`
literals prevent log injection
- Sparse byte-offset index (FF_INDEX_STEP=500) per contact for
O(log n) time-range lookups; rebuilt on inode / size / mtime
change, extended in-place when the file just grew
- Per-contact GHashTable caches for archive_id presence and
stanza_id \u2192 from_jid mapping (O(1) MAM dedup, O(1) LMC sender
validation)
Hardening
- Path-traversal protection: JID directory name normalisation
(`@` \u2192 `_at_`, slashes and `..` rejected at construction); every
per-contact path is anchored under the account's flatlog/
directory and validated before open
- Symlink-attack protection: every fopen / open uses O_NOFOLLOW; on
ELOOP the operation aborts with an error rather than following
- Filesystem permissions: log files created with mode 0600,
directories with mode 0700; both enforced at creation, verified
on each open and reported on drift by `/history verify`
- Atomic crash-safe export: write to a temp file via mkstemp (mode
0600, random suffix, no name collisions between concurrent
exports), fsync, then rename \u2014 partial state never replaces the
live file
- Concurrency: advisory flock(LOCK_EX) held for the duration of
every write, including append from live messages and full rewrite
from export, so two profanity processes can't interleave bytes
on the same log
- DoS / abuse guards:
* FF_MAX_LINE_LEN = 10 MB \u2014 lines longer than this are rejected
at read with a warning; the parser will not allocate
unbounded memory for a single record
* FF_MAX_LMC_DEPTH = 100 \u2014 `corrects:` chain walk stops at this
depth and emits a warning, preventing a malicious correction
cycle from spinning the apply pass
* FF_VERSION_SCAN_MAX = 16 \u2014 header version probe never reads
past 16 leading comment lines, even on garbage input
* Empty / inverted byte-range early-return in page-up read path
so a malformed time filter cannot cause an unbounded scan
* Zero-entry index guard so a file whose every line failed to
parse cannot cause a NULL deref on later page-up
- LMC sender validation: an incoming correction whose sender does
not match the original message's sender is rejected at write
time and surfaced via cons_show_error; a cycle in the apply pass
is broken via a visited-set
- jid_create_from_bare_and_resource treats NULL, empty string, and
the literal "(null)" as no resource and returns a bare jid;
similar normalisation for barejid eliminates the legacy
"user@host/(null)" artefact that leaked into stored fulljids
whenever g_strdup_printf("%s", NULL) ran inside create_fulljid
Commands
- `/history switch sqlite|flatfile` \u2014 runtime backend swap, closes
the old backend and opens the new one without reconnecting
- `/history export [<jid>]` \u2014 SQLite -> flat-file, merging with any
existing flatlog (dedup keyed on a SHA-256 hash mixing stanza_id,
timestamp, from_jid, body \u2014 robust against id reuse by older
clients)
- `/history import [<jid>]` \u2014 flat-file -> SQLite, same merge
semantics, runs inside a single SQLite transaction with rollback
on per-contact failure
- `/history verify [<jid>]` \u2014 integrity check; emits a structured
list of issues (ERROR / WARNING / INFO) per file:
* file-level: missing log, wrong permissions (\u2260 0600), UTF-8
BOM present, CRLF line endings, empty file
* line-level: invalid UTF-8 (with byte offset), embedded
control characters, unparsable lines, timestamps out of
order, duplicate `id:` and `aid:` (tracked separately so a
stanza/archive id collision isn't double-reported)
* cross-line: broken `corrects:` references whose target id is
not present in the file
- `/history backend` \u2014 show currently active backend
- Active backend indicator `[sqlite]` / `[flatfile]` in the status
bar next to the JID
- Roster-JID autocomplete for verify / export / import
- export and import open a SQLite handle on demand when the
flatfile backend is currently active, so migration works
regardless of which backend is live
Tests
- Unit: database_export (parser round-trip, escape/unescape, dedup
key stability, JID normalisation), database_stress (14 cases
exercising rapid writes, large messages, deep LMC chains, MAM
dedup, concurrent contacts)
- Functional: history persistence across reconnects, export /
import round-trip with content equality, MUC migration,
timestamp normalisation across timezones
- Bench harness P1\u2013P5 (synthetic load: bulk insert, time-range
read, page-up scroll, MAM ingest, mixed workload) and failure
modes F1\u2013F17 (page-up cursor and forward-iteration symmetry,
oversized lines, MAM dedup, LMC depth and cycles, BOM/CRLF,
missing log, empty file, mtime+inode flip, broken corrects, etc.)
- All bench tests integrate with the existing make targets and
emit CSV rows for baseline comparison
Author: jabber.developer2 <jabber.developer2@jabber.space>
Reviewed-by: jabber.developer <jabber.developer@jabber.space>
This commit is contained in:
@@ -275,6 +275,8 @@ static Autocomplete logging_ac;
|
||||
static Autocomplete logging_group_ac;
|
||||
static Autocomplete privacy_ac;
|
||||
static Autocomplete privacy_log_ac;
|
||||
static Autocomplete history_ac;
|
||||
static Autocomplete history_switch_ac;
|
||||
static Autocomplete color_ac;
|
||||
static Autocomplete correction_ac;
|
||||
static Autocomplete avatar_ac;
|
||||
@@ -429,6 +431,8 @@ static Autocomplete* all_acs[] = {
|
||||
&logging_group_ac,
|
||||
&privacy_ac,
|
||||
&privacy_log_ac,
|
||||
&history_ac,
|
||||
&history_switch_ac,
|
||||
&color_ac,
|
||||
&correction_ac,
|
||||
&avatar_ac,
|
||||
@@ -1138,6 +1142,18 @@ cmd_ac_init(void)
|
||||
autocomplete_add(privacy_log_ac, "on");
|
||||
autocomplete_add(privacy_log_ac, "off");
|
||||
autocomplete_add(privacy_log_ac, "redact");
|
||||
autocomplete_add(privacy_log_ac, "flatfile");
|
||||
|
||||
autocomplete_add(history_ac, "on");
|
||||
autocomplete_add(history_ac, "off");
|
||||
autocomplete_add(history_ac, "backend");
|
||||
autocomplete_add(history_ac, "switch");
|
||||
autocomplete_add(history_ac, "verify");
|
||||
autocomplete_add(history_ac, "export");
|
||||
autocomplete_add(history_ac, "import");
|
||||
|
||||
autocomplete_add(history_switch_ac, "sqlite");
|
||||
autocomplete_add(history_switch_ac, "flatfile");
|
||||
|
||||
autocomplete_add(logging_group_ac, "on");
|
||||
autocomplete_add(logging_group_ac, "off");
|
||||
@@ -1776,7 +1792,7 @@ _cmd_ac_complete_params(ProfWin* window, const char* const input, gboolean previ
|
||||
|
||||
// autocomplete boolean settings
|
||||
gchar* boolean_choices[] = { "/beep", "/states", "/outtype", "/flash", "/splash",
|
||||
"/history", "/vercheck", "/privileges", "/wrap",
|
||||
"/vercheck", "/privileges", "/wrap",
|
||||
"/carbons", "/slashguard", "/mam", "/silence" };
|
||||
|
||||
for (int i = 0; i < ARRAY_SIZE(boolean_choices); i++) {
|
||||
@@ -1786,6 +1802,24 @@ _cmd_ac_complete_params(ProfWin* window, const char* const input, gboolean previ
|
||||
}
|
||||
}
|
||||
|
||||
result = autocomplete_param_with_ac(input, "/history switch", history_switch_ac, TRUE, previous);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
gchar* history_jid_subcmds[] = { "/history verify", "/history export", "/history import" };
|
||||
for (int i = 0; i < ARRAY_SIZE(history_jid_subcmds); i++) {
|
||||
result = autocomplete_param_with_func(input, history_jid_subcmds[i], roster_barejid_autocomplete, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
result = autocomplete_param_with_ac(input, "/history", history_ac, TRUE, previous);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// autocomplete nickname in chat rooms
|
||||
if (window->type == WIN_MUC) {
|
||||
ProfMucWin* mucwin = (ProfMucWin*)window;
|
||||
|
||||
@@ -1875,18 +1875,33 @@ static const struct cmd_t command_defs[] = {
|
||||
},
|
||||
|
||||
{ CMD_PREAMBLE("/history",
|
||||
parse_args, 1, 1, &cons_history_setting)
|
||||
parse_args, 1, 2, &cons_history_setting)
|
||||
CMD_MAINFUNC(cmd_history)
|
||||
CMD_TAGS(
|
||||
CMD_TAG_UI,
|
||||
CMD_TAG_CHAT)
|
||||
CMD_SYN(
|
||||
"/history on|off")
|
||||
"/history on|off",
|
||||
"/history backend",
|
||||
"/history switch sqlite|flatfile",
|
||||
"/history verify [<jid>]",
|
||||
"/history export [<jid>]",
|
||||
"/history import [<jid>]")
|
||||
CMD_DESC(
|
||||
"Switch chat history on or off, /logging chat will automatically be enabled when this setting is on. "
|
||||
"When history is enabled, previous messages are shown in chat windows.")
|
||||
"When history is enabled, previous messages are shown in chat windows. "
|
||||
"Use 'backend' to show the active database backend. "
|
||||
"Use 'switch' to change the active database backend at runtime without reconnecting. "
|
||||
"Use 'verify' to check integrity of stored message history. "
|
||||
"Use 'export' to copy messages from SQLite to flat-file format, or 'import' to copy from flat-file to SQLite. "
|
||||
"Both export and import merge with existing data (duplicates are skipped).")
|
||||
CMD_ARGS(
|
||||
{ "on|off", "Enable or disable showing chat history." })
|
||||
{ "on|off", "Enable or disable showing chat history." },
|
||||
{ "backend", "Show the name of the active database backend." },
|
||||
{ "switch sqlite|flatfile", "Switch the active database backend at runtime." },
|
||||
{ "verify [<jid>]", "Verify integrity of message history. Optionally specify a JID to check only one contact." },
|
||||
{ "export [<jid>]", "Export SQLite history to flat-file format. Optionally specify a JID to export only one contact." },
|
||||
{ "import [<jid>]", "Import flat-file history into SQLite. Optionally specify a JID to import only one contact." })
|
||||
},
|
||||
|
||||
{ CMD_PREAMBLE("/log",
|
||||
@@ -2718,7 +2733,7 @@ static const struct cmd_t command_defs[] = {
|
||||
CMD_TAG_CHAT,
|
||||
CMD_TAG_DISCOVERY)
|
||||
CMD_SYN(
|
||||
"/privacy logging on|redact|off",
|
||||
"/privacy logging on|redact|off|flatfile",
|
||||
"/privacy os on|off")
|
||||
CMD_DESC(
|
||||
"Configure privacy settings. "
|
||||
@@ -2726,12 +2741,13 @@ static const struct cmd_t command_defs[] = {
|
||||
"clientid to set the client identification name "
|
||||
"session_alarm to configure an alarm when more clients log in.")
|
||||
CMD_ARGS(
|
||||
{ "logging on|redact|off", "Switch chat logging. This will also disable logging in the internally used SQL database. Your messages will not be saved anywhere locally. This might have unintended consequences, such as not being able to decrypt OMEMO encrypted messages received later via MAM, and should be used with caution." },
|
||||
{ "logging on|redact|off|flatfile", "Switch chat logging. 'on' uses SQLite database (default). 'flatfile' stores messages as plain text files in ~/.local/share/profanity/flatlog/ that can be manually edited with any text editor. 'off' disables logging entirely. 'redact' stores messages with content replaced by '[redacted]'. Note: 'off' might have unintended consequences, such as not being able to decrypt OMEMO encrypted messages received later via MAM. The 'flatfile' setting takes effect on next connection." },
|
||||
{ "os on|off", "Choose whether to include the OS name if a user asks for software information (XEP-0092)." }
|
||||
)
|
||||
CMD_EXAMPLES(
|
||||
"/privacy",
|
||||
"/privacy logging off",
|
||||
"/privacy logging flatfile",
|
||||
"/privacy os off")
|
||||
},
|
||||
|
||||
|
||||
@@ -6686,6 +6686,13 @@ cmd_privacy(ProfWin* window, const char* const command, gchar** args)
|
||||
} else if (g_strcmp0(arg, "redact") == 0) {
|
||||
cons_show("Messages are going to be redacted.");
|
||||
prefs_set_string(PREF_DBLOG, arg);
|
||||
} else if (g_strcmp0(arg, "flatfile") == 0) {
|
||||
auto_gchar gchar* ff_path = files_get_data_path(DIR_FLATLOG);
|
||||
cons_show("Using flat-file backend for message logging. Takes effect on next connection.");
|
||||
cons_show("Flatfile directory: %s", ff_path);
|
||||
prefs_set_string(PREF_DBLOG, arg);
|
||||
prefs_set_boolean(PREF_CHLOG, TRUE);
|
||||
prefs_set_boolean(PREF_HISTORY, TRUE);
|
||||
} else {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
@@ -6722,6 +6729,54 @@ cmd_logging(ProfWin* window, const char* const command, gchar** args)
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static void
|
||||
_show_integrity_issues(GSList* issues)
|
||||
{
|
||||
int errors = 0, warnings = 0, infos = 0;
|
||||
for (GSList* l = issues; l; l = l->next) {
|
||||
integrity_issue_t* issue = l->data;
|
||||
const char* level_str;
|
||||
switch (issue->level) {
|
||||
case INTEGRITY_ERROR:
|
||||
level_str = "ERROR";
|
||||
errors++;
|
||||
break;
|
||||
case INTEGRITY_WARNING:
|
||||
level_str = "WARN";
|
||||
warnings++;
|
||||
break;
|
||||
case INTEGRITY_INFO:
|
||||
level_str = "INFO";
|
||||
infos++;
|
||||
break;
|
||||
default:
|
||||
level_str = "???";
|
||||
break;
|
||||
}
|
||||
if (issue->line > 0) {
|
||||
if (issue->level == INTEGRITY_ERROR) {
|
||||
cons_show_error("[%s] %s:%d — %s", level_str, issue->file, issue->line, issue->message);
|
||||
} else {
|
||||
cons_show("[%s] %s:%d — %s", level_str, issue->file, issue->line, issue->message);
|
||||
}
|
||||
} else {
|
||||
if (issue->level == INTEGRITY_ERROR) {
|
||||
cons_show_error("[%s] %s — %s", level_str, issue->file, issue->message);
|
||||
} else {
|
||||
cons_show("[%s] %s — %s", level_str, issue->file, issue->message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!issues) {
|
||||
cons_show("Verification complete: no issues found.");
|
||||
} else {
|
||||
cons_show("Verification complete: %d error(s), %d warning(s), %d info(s).",
|
||||
errors, warnings, infos);
|
||||
g_slist_free_full(issues, (GDestroyNotify)integrity_issue_free);
|
||||
}
|
||||
}
|
||||
|
||||
gboolean
|
||||
cmd_history(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
@@ -6730,6 +6785,91 @@ cmd_history(ProfWin* window, const char* const command, gchar** args)
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (g_strcmp0(args[0], "backend") == 0) {
|
||||
if (active_db_backend && active_db_backend->name) {
|
||||
cons_show("Active database backend: %s", active_db_backend->name);
|
||||
} else {
|
||||
cons_show("No database backend is currently active.");
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (g_strcmp0(args[0], "switch") == 0) {
|
||||
jabber_conn_status_t conn_status = connection_get_status();
|
||||
if (conn_status != JABBER_CONNECTED) {
|
||||
cons_show_error("You must be connected to switch database backend.");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (args[1] == NULL) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (g_strcmp0(args[1], "sqlite") != 0 && g_strcmp0(args[1], "flatfile") != 0) {
|
||||
cons_show_error("Backend must be 'sqlite' or 'flatfile'.");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (active_db_backend && g_strcmp0(active_db_backend->name, args[1]) == 0) {
|
||||
cons_show("Already using '%s' backend.", args[1]);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
cons_show("Switching database backend to '%s'...", args[1]);
|
||||
if (log_database_switch_backend(args[1])) {
|
||||
cons_show("Database backend switched to '%s'.", args[1]);
|
||||
} else {
|
||||
cons_show_error("Failed to switch database backend.");
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (g_strcmp0(args[0], "verify") == 0) {
|
||||
const gchar* contact_jid = args[1]; // may be NULL (verify all)
|
||||
cons_show("Verifying history integrity...");
|
||||
|
||||
GSList* issues = log_database_verify_integrity(contact_jid);
|
||||
_show_integrity_issues(issues);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (g_strcmp0(args[0], "export") == 0) {
|
||||
#ifdef HAVE_SQLITE
|
||||
const gchar* contact_jid = args[1]; // may be NULL (export all)
|
||||
if (contact_jid) {
|
||||
cons_show("Exporting SQLite history for %s to flat-file...", contact_jid);
|
||||
} else {
|
||||
cons_show("Exporting all SQLite history to flat-file...");
|
||||
}
|
||||
int n = log_database_export_to_flatfile(contact_jid);
|
||||
if (n >= 0) {
|
||||
cons_show("Export complete: %d message(s) exported.", n);
|
||||
}
|
||||
#else
|
||||
cons_show_error("Export requires SQLite support. Rebuild with --with-sqlite.");
|
||||
#endif
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (g_strcmp0(args[0], "import") == 0) {
|
||||
#ifdef HAVE_SQLITE
|
||||
const gchar* contact_jid = args[1]; // may be NULL (import all)
|
||||
if (contact_jid) {
|
||||
cons_show("Importing flat-file history for %s into SQLite...", contact_jid);
|
||||
} else {
|
||||
cons_show("Importing all flat-file history into SQLite...");
|
||||
}
|
||||
int n = log_database_import_from_flatfile(contact_jid);
|
||||
if (n >= 0) {
|
||||
cons_show("Import complete: %d message(s) imported.", n);
|
||||
}
|
||||
#else
|
||||
cons_show_error("Import requires SQLite support. Rebuild with --with-sqlite.");
|
||||
#endif
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
_cmd_set_boolean_preference(args[0], "Chat history", PREF_HISTORY);
|
||||
|
||||
// if set to on, set chlog (/logging chat on)
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
#define DIR_OMEMO "omemo"
|
||||
#define DIR_PLUGINS "plugins"
|
||||
#define DIR_DATABASE "database"
|
||||
#define DIR_FLATLOG "flatlog"
|
||||
#define DIR_DOWNLOADS "downloads"
|
||||
#define DIR_EDITOR "editor"
|
||||
#define DIR_CERTS "certs"
|
||||
|
||||
738
src/database.c
738
src/database.c
@@ -35,703 +35,171 @@
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <sys/stat.h>
|
||||
#include <sys/statvfs.h>
|
||||
#include <sqlite3.h>
|
||||
#include <glib.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
|
||||
#include "log.h"
|
||||
#include "common.h"
|
||||
#include "config/files.h"
|
||||
#include "config.h"
|
||||
#include "database.h"
|
||||
#include "config/preferences.h"
|
||||
#include "config/accounts.h"
|
||||
#include "ui/ui.h"
|
||||
#include "xmpp/xmpp.h"
|
||||
#include "xmpp/message.h"
|
||||
|
||||
static sqlite3* g_chatlog_database;
|
||||
db_backend_t* active_db_backend = NULL;
|
||||
|
||||
static void _add_to_db(ProfMessage* message, const char* type, const Jid* const from_jid, const Jid* const to_jid);
|
||||
static char* _get_db_filename(ProfAccount* account);
|
||||
static prof_msg_type_t _get_message_type_type(const char* const type);
|
||||
static prof_enc_t _get_message_enc_type(const char* const encstr);
|
||||
static int _get_db_version(void);
|
||||
static gboolean _migrate_to_v2(void);
|
||||
static gboolean _check_available_space_for_db_migration(char* path_to_db);
|
||||
|
||||
static const int latest_version = 2;
|
||||
|
||||
// Helper: close DB handle (if any), warn on busy, and shutdown SQLite
|
||||
static void
|
||||
_db_teardown(const char* ctx)
|
||||
void
|
||||
integrity_issue_free(integrity_issue_t* issue)
|
||||
{
|
||||
if (g_chatlog_database) {
|
||||
int rc = sqlite3_close_v2(g_chatlog_database);
|
||||
if (rc != SQLITE_OK) {
|
||||
log_warning("sqlite3_close_v2 in %s returned %d; database may still have active statements.",
|
||||
ctx ? ctx : "db_teardown", rc);
|
||||
}
|
||||
g_chatlog_database = NULL;
|
||||
if (issue) {
|
||||
g_free(issue->file);
|
||||
g_free(issue->message);
|
||||
g_free(issue);
|
||||
}
|
||||
// Safe to call unconditionally; no-op if not initialized.
|
||||
// See: https://www.sqlite.org/c3ref/initialize.html
|
||||
sqlite3_shutdown();
|
||||
}
|
||||
|
||||
// Helper: prepare a statement and log a contextual error on failure
|
||||
static gboolean
|
||||
_db_prepare_ctx(const char* query, sqlite3_stmt** stmt, const char* ctx)
|
||||
{
|
||||
int rc = sqlite3_prepare_v2(g_chatlog_database, query, -1, stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
log_error("SQLite error in %s: (error code: %d) %s",
|
||||
ctx ? ctx : "sqlite3_prepare_v2",
|
||||
rc,
|
||||
sqlite3_errmsg(g_chatlog_database));
|
||||
return FALSE;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static char*
|
||||
_db_strdup(const char* str)
|
||||
{
|
||||
return str ? strdup(str) : NULL;
|
||||
}
|
||||
|
||||
#define auto_sqlite __attribute__((__cleanup__(auto_free_sqlite)))
|
||||
|
||||
static void
|
||||
auto_free_sqlite(gchar** str)
|
||||
{
|
||||
if (str == NULL)
|
||||
return;
|
||||
sqlite3_free(*str);
|
||||
}
|
||||
|
||||
static char*
|
||||
_get_db_filename(ProfAccount* account)
|
||||
{
|
||||
return files_file_in_account_data_path(DIR_DATABASE, account->jid, "chatlog.db");
|
||||
}
|
||||
|
||||
gboolean
|
||||
log_database_init(ProfAccount* account)
|
||||
{
|
||||
int ret = sqlite3_initialize();
|
||||
if (ret != SQLITE_OK) {
|
||||
log_error("Error initializing SQLite database: %d", ret);
|
||||
return FALSE;
|
||||
}
|
||||
auto_gchar gchar* pref_dblog = prefs_get_string(PREF_DBLOG);
|
||||
|
||||
auto_char char* filename = _get_db_filename(account);
|
||||
if (!filename) {
|
||||
sqlite3_shutdown();
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
ret = sqlite3_open(filename, &g_chatlog_database);
|
||||
if (ret != SQLITE_OK) {
|
||||
const char* err_msg = g_chatlog_database ? sqlite3_errmsg(g_chatlog_database) : "(no handle)";
|
||||
log_error("Error opening SQLite database: %s", err_msg);
|
||||
_db_teardown("log_database_init(open)");
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
char* err_msg = NULL;
|
||||
|
||||
int db_version = _get_db_version();
|
||||
if (db_version == latest_version) {
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// ChatLogs Table
|
||||
// Contains all chat messages
|
||||
//
|
||||
// id is primary key
|
||||
// from_jid is the sender's jid
|
||||
// to_jid is the receiver's jid
|
||||
// from_resource is the sender's resource
|
||||
// to_resource is the receiver's resource
|
||||
// message is the message's text
|
||||
// timestamp is the timestamp like "2020/03/24 11:12:14"
|
||||
// type is there to distinguish: message (chat), MUC message (muc), muc pm (mucpm)
|
||||
// stanza_id is the ID in <message>
|
||||
// archive_id is the stanza-id from from XEP-0359: Unique and Stable Stanza IDs used for XEP-0313: Message Archive Management
|
||||
// encryption is to distinguish: none, omemo, otr, pgp
|
||||
// marked_read is 0/1 whether a message has been marked as read via XEP-0333: Chat Markers
|
||||
// replace_id is the ID from XEP-0308: Last Message Correction
|
||||
// replaces_db_id is ID (primary key) of the original message that LMC message corrects/replaces
|
||||
// replaced_by_db_id is ID (primary key) of the last correcting (LMC) message for the original message
|
||||
const char* query = "CREATE TABLE IF NOT EXISTS `ChatLogs` ("
|
||||
"`id` INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||
"`from_jid` TEXT NOT NULL, "
|
||||
"`to_jid` TEXT NOT NULL, "
|
||||
"`from_resource` TEXT, "
|
||||
"`to_resource` TEXT, "
|
||||
"`message` TEXT, "
|
||||
"`timestamp` TEXT, "
|
||||
"`type` TEXT, "
|
||||
"`stanza_id` TEXT, "
|
||||
"`archive_id` TEXT, "
|
||||
"`encryption` TEXT, "
|
||||
"`marked_read` INTEGER, "
|
||||
"`replace_id` TEXT, "
|
||||
"`replaces_db_id` INTEGER, "
|
||||
"`replaced_by_db_id` INTEGER)";
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
|
||||
goto out;
|
||||
}
|
||||
|
||||
query = "CREATE TRIGGER IF NOT EXISTS update_corrected_message "
|
||||
"AFTER INSERT ON ChatLogs "
|
||||
"FOR EACH ROW "
|
||||
"WHEN NEW.replaces_db_id IS NOT NULL "
|
||||
"BEGIN "
|
||||
"UPDATE ChatLogs "
|
||||
"SET replaced_by_db_id = NEW.id "
|
||||
"WHERE id = NEW.replaces_db_id; "
|
||||
"END;";
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
|
||||
log_error("Unable to add `update_corrected_message` trigger.");
|
||||
goto out;
|
||||
}
|
||||
|
||||
query = "CREATE INDEX IF NOT EXISTS ChatLogs_timestamp_IDX ON `ChatLogs` (`timestamp`)";
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
|
||||
log_error("Unable to create index for timestamp.");
|
||||
goto out;
|
||||
}
|
||||
query = "CREATE INDEX IF NOT EXISTS ChatLogs_to_from_jid_IDX ON `ChatLogs` (`to_jid`, `from_jid`)";
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
|
||||
log_error("Unable to create index for to_jid.");
|
||||
goto out;
|
||||
}
|
||||
|
||||
query = "CREATE TABLE IF NOT EXISTS `DbVersion` (`dv_id` INTEGER PRIMARY KEY, `version` INTEGER UNIQUE)";
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
|
||||
goto out;
|
||||
}
|
||||
|
||||
if (db_version == -1) {
|
||||
query = "INSERT OR IGNORE INTO `DbVersion` (`version`) VALUES ('2')";
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
|
||||
goto out;
|
||||
}
|
||||
db_version = _get_db_version();
|
||||
}
|
||||
|
||||
// Unlikely event, but we don't want to migrate if we are just unable to determine the DB version
|
||||
if (db_version == -1) {
|
||||
cons_show_error("DB Initialization Error: Unable to check DB version.");
|
||||
goto out;
|
||||
}
|
||||
|
||||
if (db_version < latest_version) {
|
||||
cons_show("Migrating database schema. This operation may take a while...");
|
||||
if (db_version < 2 && (!_check_available_space_for_db_migration(filename) || !_migrate_to_v2())) {
|
||||
cons_show_error("Database Initialization Error: Unable to migrate database to version 2. Please, check error logs for details.");
|
||||
goto out;
|
||||
}
|
||||
cons_show("Database schema migration was successful.");
|
||||
}
|
||||
|
||||
log_debug("Initialized SQLite database: %s", filename);
|
||||
return TRUE;
|
||||
|
||||
out:
|
||||
if (err_msg) {
|
||||
log_error("SQLite error in log_database_init(): %s", err_msg);
|
||||
sqlite3_free(err_msg);
|
||||
// Select backend based on preference
|
||||
if (g_strcmp0(pref_dblog, "flatfile") == 0) {
|
||||
active_db_backend = db_backend_flatfile();
|
||||
log_info("Using flat-file database backend");
|
||||
} else {
|
||||
log_error("Unknown SQLite error in log_database_init().");
|
||||
#ifdef HAVE_SQLITE
|
||||
active_db_backend = db_backend_sqlite();
|
||||
log_info("Using SQLite database backend");
|
||||
#else
|
||||
log_warning("SQLite not compiled in, falling back to flat-file backend");
|
||||
active_db_backend = db_backend_flatfile();
|
||||
#endif
|
||||
}
|
||||
_db_teardown("log_database_init(out)");
|
||||
return FALSE;
|
||||
|
||||
if (!active_db_backend) {
|
||||
log_error("log_database_init: no backend available");
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
return active_db_backend->init(account);
|
||||
}
|
||||
|
||||
void
|
||||
log_database_close(void)
|
||||
{
|
||||
log_debug("log_database_close() called");
|
||||
_db_teardown("log_database_close");
|
||||
if (active_db_backend) {
|
||||
active_db_backend->close();
|
||||
}
|
||||
active_db_backend = NULL;
|
||||
}
|
||||
|
||||
gboolean
|
||||
log_database_switch_backend(const char* new_backend)
|
||||
{
|
||||
// Close current backend
|
||||
log_database_close();
|
||||
|
||||
// Update preference (user must /save to persist across restarts)
|
||||
prefs_set_string(PREF_DBLOG, new_backend);
|
||||
|
||||
// Get current account to reinitialize
|
||||
const char* account_name = session_get_account_name();
|
||||
if (!account_name) {
|
||||
log_error("log_database_switch_backend: no active session");
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
ProfAccount* account = accounts_get_account(account_name);
|
||||
if (!account) {
|
||||
log_error("log_database_switch_backend: account '%s' not found", account_name);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
gboolean result = log_database_init(account);
|
||||
account_free(account);
|
||||
return result;
|
||||
}
|
||||
|
||||
void
|
||||
log_database_add_incoming(ProfMessage* message)
|
||||
{
|
||||
if (message->to_jid) {
|
||||
_add_to_db(message, NULL, message->from_jid, message->to_jid);
|
||||
if (active_db_backend && active_db_backend->add_incoming) {
|
||||
active_db_backend->add_incoming(message);
|
||||
} else {
|
||||
_add_to_db(message, NULL, message->from_jid, connection_get_jid());
|
||||
log_warning("log_database_add_incoming: no backend or method available");
|
||||
}
|
||||
}
|
||||
|
||||
static void
|
||||
_log_database_add_outgoing(const char* type, const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc)
|
||||
{
|
||||
ProfMessage* msg = message_init();
|
||||
|
||||
msg->id = _db_strdup(id);
|
||||
msg->from_jid = jid_create(barejid);
|
||||
msg->plain = _db_strdup(message);
|
||||
msg->replace_id = _db_strdup(replace_id);
|
||||
msg->timestamp = g_date_time_new_now_local(); // TODO: get from outside. best to have whole ProfMessage from outside
|
||||
msg->enc = enc;
|
||||
|
||||
_add_to_db(msg, type, connection_get_jid(), msg->from_jid); // TODO: myjid now in profmessage
|
||||
|
||||
message_free(msg);
|
||||
}
|
||||
|
||||
void
|
||||
log_database_add_outgoing_chat(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc)
|
||||
{
|
||||
_log_database_add_outgoing("chat", id, barejid, message, replace_id, enc);
|
||||
if (active_db_backend && active_db_backend->add_outgoing_chat) {
|
||||
active_db_backend->add_outgoing_chat(id, barejid, message, replace_id, enc);
|
||||
} else {
|
||||
log_warning("log_database_add_outgoing_chat: no backend or method available");
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
log_database_add_outgoing_muc(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc)
|
||||
{
|
||||
_log_database_add_outgoing("muc", id, barejid, message, replace_id, enc);
|
||||
if (active_db_backend && active_db_backend->add_outgoing_muc) {
|
||||
active_db_backend->add_outgoing_muc(id, barejid, message, replace_id, enc);
|
||||
} else {
|
||||
log_warning("log_database_add_outgoing_muc: no backend or method available");
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
log_database_add_outgoing_muc_pm(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc)
|
||||
{
|
||||
_log_database_add_outgoing("mucpm", id, barejid, message, replace_id, enc);
|
||||
if (active_db_backend && active_db_backend->add_outgoing_muc_pm) {
|
||||
active_db_backend->add_outgoing_muc_pm(id, barejid, message, replace_id, enc);
|
||||
} else {
|
||||
log_warning("log_database_add_outgoing_muc_pm: no backend or method available");
|
||||
}
|
||||
}
|
||||
|
||||
// Get info (timestamp and stanza_id) of the first or last message in db
|
||||
ProfMessage*
|
||||
log_database_get_limits_info(const gchar* const contact_barejid, gboolean is_last)
|
||||
{
|
||||
sqlite3_stmt* stmt = NULL;
|
||||
const Jid* myjid = connection_get_jid();
|
||||
// Always return a valid ProfMessage to avoid NULL dereferences in callers
|
||||
ProfMessage* msg = message_init();
|
||||
if (!myjid || !myjid->str) {
|
||||
// If caller requested the last message and we have no context, fall back to now
|
||||
if (is_last) {
|
||||
msg->timestamp = g_date_time_new_now_utc();
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
const char* order = is_last ? "DESC" : "ASC";
|
||||
auto_sqlite char* query = sqlite3_mprintf("SELECT `archive_id`, `timestamp` FROM `ChatLogs` WHERE "
|
||||
"(`from_jid` = %Q AND `to_jid` = %Q) OR "
|
||||
"(`from_jid` = %Q AND `to_jid` = %Q) "
|
||||
"ORDER BY `timestamp` %s LIMIT 1;",
|
||||
contact_barejid, myjid->barejid, myjid->barejid, contact_barejid, order);
|
||||
|
||||
if (!query) {
|
||||
log_error("Could not allocate memory for SQL query in log_database_get_limits_info()");
|
||||
if (is_last) {
|
||||
msg->timestamp = g_date_time_new_now_utc();
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
if (!_db_prepare_ctx(query, &stmt, "log_database_get_limits_info()")) {
|
||||
if (is_last) {
|
||||
msg->timestamp = g_date_time_new_now_utc();
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
char* archive_id = (char*)sqlite3_column_text(stmt, 0);
|
||||
char* date = (char*)sqlite3_column_text(stmt, 1);
|
||||
|
||||
msg->stanzaid = _db_strdup(archive_id);
|
||||
msg->timestamp = g_date_time_new_from_iso8601(date, NULL);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
// If nothing was found and caller expects the last message, provide a sane default
|
||||
if (!msg->timestamp && is_last) {
|
||||
msg->timestamp = g_date_time_new_now_utc();
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
// Query previous chats, constraints start_time and end_time. If end_time is
|
||||
// null the current time is used. from_start gets first few messages if true
|
||||
// otherwise the last ones. Flip flips the order of the results
|
||||
db_history_result_t
|
||||
log_database_get_previous_chat(const gchar* const contact_barejid, const gchar* start_time, const gchar* end_time, gboolean from_start, gboolean flip, GSList** result)
|
||||
{
|
||||
if (!g_chatlog_database) {
|
||||
log_warning("log_database_get_previous_chat() called but db is not initialized");
|
||||
return DB_RESPONSE_ERROR;
|
||||
if (active_db_backend && active_db_backend->get_previous_chat) {
|
||||
return active_db_backend->get_previous_chat(contact_barejid, start_time, end_time, from_start, flip, result);
|
||||
}
|
||||
sqlite3_stmt* stmt = NULL;
|
||||
const Jid* myjid = connection_get_jid();
|
||||
if (!myjid->str) {
|
||||
log_warning("log_database_get_previous_chat() called but no connection detected.");
|
||||
return DB_RESPONSE_ERROR;
|
||||
}
|
||||
|
||||
// Flip order when querying older pages
|
||||
const gchar* sort1 = from_start ? "ASC" : "DESC";
|
||||
const gchar* sort2 = !flip ? "ASC" : "DESC";
|
||||
GDateTime* now = g_date_time_new_now_local();
|
||||
auto_gchar gchar* end_date_fmt = end_time ? g_strdup(end_time) : g_date_time_format_iso8601(now);
|
||||
auto_sqlite gchar* query = sqlite3_mprintf("SELECT * FROM ("
|
||||
"SELECT COALESCE(B.`message`, A.`message`) AS message, "
|
||||
"A.`timestamp`, A.`from_jid`, A.`from_resource`, A.`to_jid`, A.`to_resource`, A.`type`, A.`encryption`, A.`stanza_id` FROM `ChatLogs` AS A "
|
||||
"LEFT JOIN `ChatLogs` AS B ON (A.`replaced_by_db_id` = B.`id` AND A.`from_jid` = B.`from_jid`) "
|
||||
"WHERE (A.`replaces_db_id` IS NULL) "
|
||||
"AND ((A.`from_jid` = %Q AND A.`to_jid` = %Q) OR (A.`from_jid` = %Q AND A.`to_jid` = %Q)) "
|
||||
"AND A.`timestamp` < %Q "
|
||||
"AND (%Q IS NULL OR A.`timestamp` > %Q) "
|
||||
"ORDER BY A.`timestamp` %s LIMIT %d) "
|
||||
"ORDER BY `timestamp` %s;",
|
||||
contact_barejid, myjid->barejid, myjid->barejid, contact_barejid, end_date_fmt, start_time, start_time, sort1, MESSAGES_TO_RETRIEVE, sort2);
|
||||
|
||||
g_date_time_unref(now);
|
||||
|
||||
if (!query) {
|
||||
log_error("Could not allocate memory.");
|
||||
return DB_RESPONSE_ERROR;
|
||||
}
|
||||
|
||||
if (!_db_prepare_ctx(query, &stmt, "log_database_get_previous_chat()")) {
|
||||
return DB_RESPONSE_ERROR;
|
||||
}
|
||||
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
char* message = (char*)sqlite3_column_text(stmt, 0);
|
||||
char* date = (char*)sqlite3_column_text(stmt, 1);
|
||||
char* from_jid = (char*)sqlite3_column_text(stmt, 2);
|
||||
char* from_resource = (char*)sqlite3_column_text(stmt, 3);
|
||||
char* to_jid = (char*)sqlite3_column_text(stmt, 4);
|
||||
char* to_resource = (char*)sqlite3_column_text(stmt, 5);
|
||||
char* type = (char*)sqlite3_column_text(stmt, 6);
|
||||
char* encryption = (char*)sqlite3_column_text(stmt, 7);
|
||||
char* id = (char*)sqlite3_column_text(stmt, 8);
|
||||
|
||||
ProfMessage* msg = message_init();
|
||||
msg->id = id ? strdup(id) : NULL;
|
||||
msg->from_jid = jid_create_from_bare_and_resource(from_jid, from_resource);
|
||||
msg->to_jid = jid_create_from_bare_and_resource(to_jid, to_resource);
|
||||
msg->plain = strdup(message ?: "");
|
||||
msg->timestamp = g_date_time_new_from_iso8601(date, NULL);
|
||||
msg->type = _get_message_type_type(type);
|
||||
msg->enc = _get_message_enc_type(encryption);
|
||||
|
||||
*result = g_slist_append(*result, msg);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
return g_slist_length(*result) != 0 ? DB_RESPONSE_SUCCESS : DB_RESPONSE_EMPTY;
|
||||
return DB_RESPONSE_ERROR;
|
||||
}
|
||||
|
||||
static const char*
|
||||
_get_message_type_str(prof_msg_type_t type)
|
||||
ProfMessage*
|
||||
log_database_get_limits_info(const gchar* const contact_barejid, gboolean is_last)
|
||||
{
|
||||
switch (type) {
|
||||
case PROF_MSG_TYPE_CHAT:
|
||||
return "chat";
|
||||
case PROF_MSG_TYPE_MUC:
|
||||
return "muc";
|
||||
case PROF_MSG_TYPE_MUCPM:
|
||||
return "mucpm";
|
||||
case PROF_MSG_TYPE_UNINITIALIZED:
|
||||
return NULL;
|
||||
if (active_db_backend && active_db_backend->get_limits_info) {
|
||||
return active_db_backend->get_limits_info(contact_barejid, is_last);
|
||||
}
|
||||
return NULL;
|
||||
// Fallback: return an empty message with sane defaults
|
||||
ProfMessage* msg = message_init();
|
||||
if (is_last) {
|
||||
msg->timestamp = g_date_time_new_now_utc();
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
static prof_msg_type_t
|
||||
_get_message_type_type(const char* const type)
|
||||
GSList*
|
||||
log_database_verify_integrity(const gchar* const contact_barejid)
|
||||
{
|
||||
if (g_strcmp0(type, "chat") == 0) {
|
||||
return PROF_MSG_TYPE_CHAT;
|
||||
} else if (g_strcmp0(type, "muc") == 0) {
|
||||
return PROF_MSG_TYPE_MUC;
|
||||
} else if (g_strcmp0(type, "mucpm") == 0) {
|
||||
return PROF_MSG_TYPE_MUCPM;
|
||||
} else {
|
||||
return PROF_MSG_TYPE_UNINITIALIZED;
|
||||
}
|
||||
}
|
||||
|
||||
static const char*
|
||||
_get_message_enc_str(prof_enc_t enc)
|
||||
{
|
||||
switch (enc) {
|
||||
case PROF_MSG_ENC_OX:
|
||||
return "ox";
|
||||
case PROF_MSG_ENC_PGP:
|
||||
return "pgp";
|
||||
case PROF_MSG_ENC_OTR:
|
||||
return "otr";
|
||||
case PROF_MSG_ENC_OMEMO:
|
||||
return "omemo";
|
||||
case PROF_MSG_ENC_NONE:
|
||||
return "none";
|
||||
}
|
||||
|
||||
return "none";
|
||||
}
|
||||
|
||||
static prof_enc_t
|
||||
_get_message_enc_type(const char* const encstr)
|
||||
{
|
||||
if (g_strcmp0(encstr, "ox") == 0) {
|
||||
return PROF_MSG_ENC_OX;
|
||||
} else if (g_strcmp0(encstr, "pgp") == 0) {
|
||||
return PROF_MSG_ENC_PGP;
|
||||
} else if (g_strcmp0(encstr, "otr") == 0) {
|
||||
return PROF_MSG_ENC_OTR;
|
||||
} else if (g_strcmp0(encstr, "omemo") == 0) {
|
||||
return PROF_MSG_ENC_OMEMO;
|
||||
}
|
||||
|
||||
return PROF_MSG_ENC_NONE;
|
||||
}
|
||||
|
||||
static void
|
||||
_add_to_db(ProfMessage* message, const char* type, const Jid* const from_jid, const Jid* const to_jid)
|
||||
{
|
||||
auto_gchar gchar* pref_dblog = prefs_get_string(PREF_DBLOG);
|
||||
sqlite_int64 original_message_id = -1;
|
||||
|
||||
if (g_strcmp0(pref_dblog, "off") == 0) {
|
||||
return;
|
||||
} else if (g_strcmp0(pref_dblog, "redact") == 0) {
|
||||
if (message->plain) {
|
||||
free(message->plain);
|
||||
}
|
||||
message->plain = strdup("[REDACTED]");
|
||||
}
|
||||
|
||||
if (!g_chatlog_database) {
|
||||
log_debug("log_database_add() called but db is not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
char* err_msg;
|
||||
auto_gchar gchar* date_fmt = NULL;
|
||||
|
||||
if (message->timestamp) {
|
||||
date_fmt = g_date_time_format_iso8601(message->timestamp);
|
||||
} else {
|
||||
GDateTime* dt = g_date_time_new_now_local();
|
||||
date_fmt = g_date_time_format_iso8601(dt);
|
||||
g_date_time_unref(dt);
|
||||
}
|
||||
|
||||
const char* enc = _get_message_enc_str(message->enc);
|
||||
|
||||
if (!type) {
|
||||
type = (char*)_get_message_type_str(message->type);
|
||||
}
|
||||
|
||||
// Apply LMC and check its validity (XEP-0308)
|
||||
if (message->replace_id) {
|
||||
auto_sqlite char* replace_check_query = sqlite3_mprintf("SELECT `id`, `from_jid`, `replaces_db_id` FROM `ChatLogs` WHERE `stanza_id` = %Q ORDER BY `timestamp` DESC LIMIT 1",
|
||||
message->replace_id);
|
||||
|
||||
if (!replace_check_query) {
|
||||
log_error("Could not allocate memory for SQL replace query in log_database_add()");
|
||||
return;
|
||||
}
|
||||
|
||||
sqlite3_stmt* lmc_stmt = NULL;
|
||||
if (!_db_prepare_ctx(replace_check_query, &lmc_stmt, "_add_to_db(replace_check)")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sqlite3_step(lmc_stmt) == SQLITE_ROW) {
|
||||
original_message_id = sqlite3_column_int64(lmc_stmt, 0);
|
||||
const char* from_jid_orig = (const char*)sqlite3_column_text(lmc_stmt, 1);
|
||||
|
||||
// Handle non-XEP-compliant replacement messages (edit->edit->original)
|
||||
sqlite_int64 tmp = sqlite3_column_int64(lmc_stmt, 2);
|
||||
original_message_id = tmp ? tmp : original_message_id;
|
||||
|
||||
if (g_strcmp0(from_jid_orig, from_jid->barejid) != 0) {
|
||||
log_error("Mismatch in sender JIDs when trying to do LMC. Corrected message sender: %s. Original message sender: %s. Replace-ID: %s. Message: %s", from_jid->barejid, from_jid_orig, message->replace_id, message->plain);
|
||||
cons_show_error("%s sent a message correction with mismatched sender. See log for details.", from_jid->barejid);
|
||||
sqlite3_finalize(lmc_stmt);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
log_warning("Got LMC message that does not have original message counterpart in the database from %s", message->from_jid->fulljid);
|
||||
}
|
||||
sqlite3_finalize(lmc_stmt);
|
||||
}
|
||||
|
||||
// stanza-id (XEP-0359) doesn't have to be present in the message.
|
||||
// But if it's duplicated, it's a serious server-side problem, so we better track it.
|
||||
// Unless it's MAM, in that case it's expected behaviour.
|
||||
if (message->stanzaid && !message->is_mam) {
|
||||
auto_sqlite char* duplicate_check_query = sqlite3_mprintf("SELECT 1 FROM `ChatLogs` WHERE (`archive_id` = %Q)",
|
||||
message->stanzaid);
|
||||
|
||||
if (!duplicate_check_query) {
|
||||
log_error("Could not allocate memory for SQL duplicate query in log_database_add()");
|
||||
return;
|
||||
}
|
||||
|
||||
sqlite3_stmt* stmt;
|
||||
if (_db_prepare_ctx(duplicate_check_query, &stmt, "_add_to_db(duplicate_check)")) {
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
log_error("Duplicate stanza-id found for the message. stanza_id: %s; archive_id: %s; sender: %s; content: %s", message->id, message->stanzaid, from_jid->barejid, message->plain);
|
||||
cons_show_error("Got a message with duplicate (server-generated) stanza-id from %s.", from_jid->fulljid);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
}
|
||||
|
||||
auto_sqlite char* orig_message_id = original_message_id == -1 ? NULL : sqlite3_mprintf("%d", original_message_id);
|
||||
|
||||
auto_sqlite char* query = sqlite3_mprintf("INSERT INTO `ChatLogs` "
|
||||
"(`from_jid`, `from_resource`, `to_jid`, `to_resource`, "
|
||||
"`message`, `timestamp`, `stanza_id`, `archive_id`, "
|
||||
"`replaces_db_id`, `replace_id`, `type`, `encryption`) "
|
||||
"VALUES (%Q, %Q, %Q, %Q, %Q, %Q, %Q, %Q, %Q, %Q, %Q, %Q)",
|
||||
from_jid->barejid,
|
||||
from_jid->resourcepart,
|
||||
to_jid->barejid,
|
||||
to_jid->resourcepart,
|
||||
message->plain,
|
||||
date_fmt,
|
||||
message->id,
|
||||
message->stanzaid,
|
||||
orig_message_id,
|
||||
message->replace_id,
|
||||
type,
|
||||
enc);
|
||||
if (!query) {
|
||||
log_error("Could not allocate memory for SQL insert query in log_database_add()");
|
||||
return;
|
||||
}
|
||||
|
||||
log_debug("Writing to DB. Query: %s", query);
|
||||
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
|
||||
if (err_msg) {
|
||||
log_error("SQLite error in _add_to_db(): %s", err_msg);
|
||||
sqlite3_free(err_msg);
|
||||
} else {
|
||||
log_error("Unknown SQLite error in _add_to_db().");
|
||||
}
|
||||
} else {
|
||||
int inserted_rows_count = sqlite3_changes(g_chatlog_database);
|
||||
if (inserted_rows_count < 1) {
|
||||
log_error("SQLite did not insert message (rows: %d, id: %s, content: %s)", inserted_rows_count, message->id, message->plain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static int
|
||||
_get_db_version(void)
|
||||
{
|
||||
int current_version = -1;
|
||||
const char* query = "SELECT `version` FROM `DbVersion` LIMIT 1";
|
||||
sqlite3_stmt* statement;
|
||||
if (_db_prepare_ctx(query, &statement, "_get_db_version()")) {
|
||||
if (sqlite3_step(statement) == SQLITE_ROW) {
|
||||
current_version = sqlite3_column_int(statement, 0);
|
||||
}
|
||||
sqlite3_finalize(statement);
|
||||
}
|
||||
return current_version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migration to version 2 introduces new columns. Returns TRUE on success.
|
||||
*
|
||||
* New columns:
|
||||
* `replaces_db_id` database ID for correcting message of the original message
|
||||
* `replaced_by_db_id` database ID for original message of the last correcting message
|
||||
*/
|
||||
static gboolean
|
||||
_migrate_to_v2(void)
|
||||
{
|
||||
char* err_msg = NULL;
|
||||
|
||||
// from_resource, to_resource, message, timestamp, stanza_id, archive_id, replace_id, type, encryption
|
||||
const char* sql_statements[] = {
|
||||
"BEGIN TRANSACTION",
|
||||
"ALTER TABLE `ChatLogs` ADD COLUMN `replaces_db_id` INTEGER;",
|
||||
"ALTER TABLE `ChatLogs` ADD COLUMN `replaced_by_db_id` INTEGER;",
|
||||
"UPDATE `ChatLogs` AS A "
|
||||
"SET `replaces_db_id` = B.`id` "
|
||||
"FROM `ChatLogs` AS B "
|
||||
"WHERE A.`replace_id` IS NOT NULL AND A.`replace_id` != '' "
|
||||
"AND A.`replace_id` = B.`stanza_id` "
|
||||
"AND A.`from_jid` = B.`from_jid` AND A.`to_jid` = B.`to_jid`;",
|
||||
"UPDATE `ChatLogs` AS A "
|
||||
"SET `replaced_by_db_id` = B.`id` "
|
||||
"FROM `ChatLogs` AS B "
|
||||
"WHERE (A.`replace_id` IS NULL OR A.`replace_id` = '') "
|
||||
"AND A.`id` = B.`replaces_db_id` "
|
||||
"AND A.`from_jid` = B.`from_jid`;",
|
||||
"UPDATE ChatLogs SET "
|
||||
"from_resource = COALESCE(NULLIF(from_resource, ''), NULL), "
|
||||
"to_resource = COALESCE(NULLIF(to_resource, ''), NULL), "
|
||||
"message = COALESCE(NULLIF(message, ''), NULL), "
|
||||
"timestamp = COALESCE(NULLIF(timestamp, ''), NULL), "
|
||||
"stanza_id = COALESCE(NULLIF(stanza_id, ''), NULL), "
|
||||
"archive_id = COALESCE(NULLIF(archive_id, ''), NULL), "
|
||||
"replace_id = COALESCE(NULLIF(replace_id, ''), NULL), "
|
||||
"type = COALESCE(NULLIF(type, ''), NULL), "
|
||||
"encryption = COALESCE(NULLIF(encryption, ''), NULL);",
|
||||
"UPDATE `DbVersion` SET `version` = 2;",
|
||||
"END TRANSACTION"
|
||||
};
|
||||
|
||||
int statements_count = sizeof(sql_statements) / sizeof(sql_statements[0]);
|
||||
|
||||
for (int i = 0; i < statements_count; i++) {
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, sql_statements[i], NULL, 0, &err_msg)) {
|
||||
log_error("SQLite error in _migrate_to_v2() on statement %d: %s", i, err_msg);
|
||||
if (err_msg) {
|
||||
sqlite3_free(err_msg);
|
||||
err_msg = NULL;
|
||||
}
|
||||
goto cleanup;
|
||||
}
|
||||
}
|
||||
|
||||
return TRUE;
|
||||
|
||||
cleanup:
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, "ROLLBACK;", NULL, 0, &err_msg)) {
|
||||
log_error("[DB Migration] Unable to ROLLBACK: %s", err_msg);
|
||||
if (err_msg) {
|
||||
sqlite3_free(err_msg);
|
||||
}
|
||||
}
|
||||
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
// Checks if there is more system storage space available than current database takes + 40% (for indexing and other potential size increases)
|
||||
static gboolean
|
||||
_check_available_space_for_db_migration(char* path_to_db)
|
||||
{
|
||||
struct stat file_stat;
|
||||
struct statvfs fs_stat;
|
||||
|
||||
if (statvfs(path_to_db, &fs_stat) == 0 && stat(path_to_db, &file_stat) == 0) {
|
||||
unsigned long long file_size = file_stat.st_size / 1024;
|
||||
unsigned long long available_space_kb = fs_stat.f_frsize * fs_stat.f_bavail / 1024;
|
||||
log_debug("_check_available_space_for_db_migration(): Available space on disk: %llu KB; DB size: %llu KB", available_space_kb, file_size);
|
||||
|
||||
return (available_space_kb >= (file_size + (file_size * 10 / 4)));
|
||||
} else {
|
||||
log_error("Error checking available space.");
|
||||
return FALSE;
|
||||
if (active_db_backend && active_db_backend->verify_integrity) {
|
||||
return active_db_backend->verify_integrity(contact_barejid);
|
||||
}
|
||||
|
||||
GSList* issues = NULL;
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_WARNING;
|
||||
issue->file = g_strdup("N/A");
|
||||
issue->line = 0;
|
||||
issue->message = g_strdup("Active backend does not support integrity verification");
|
||||
issues = g_slist_append(issues, issue);
|
||||
return issues;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,58 @@ typedef enum {
|
||||
DB_RESPONSE_SUCCESS
|
||||
} db_history_result_t;
|
||||
|
||||
// Integrity verification issue levels
|
||||
typedef enum {
|
||||
INTEGRITY_ERROR,
|
||||
INTEGRITY_WARNING,
|
||||
INTEGRITY_INFO
|
||||
} integrity_level_t;
|
||||
|
||||
// A single integrity issue found during verification
|
||||
typedef struct
|
||||
{
|
||||
integrity_level_t level;
|
||||
char* file;
|
||||
int line;
|
||||
char* message;
|
||||
} integrity_issue_t;
|
||||
|
||||
void integrity_issue_free(integrity_issue_t* issue);
|
||||
|
||||
// Backend vtable: pluggable storage backends implement this interface
|
||||
typedef struct db_backend_t
|
||||
{
|
||||
const char* name;
|
||||
gboolean (*init)(ProfAccount* account);
|
||||
void (*close)(void);
|
||||
void (*add_incoming)(ProfMessage* message);
|
||||
void (*add_outgoing_chat)(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc);
|
||||
void (*add_outgoing_muc)(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc);
|
||||
void (*add_outgoing_muc_pm)(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc);
|
||||
db_history_result_t (*get_previous_chat)(const gchar* const contact_barejid, const gchar* start_time, const gchar* end_time, gboolean from_start, gboolean flip, GSList** result);
|
||||
ProfMessage* (*get_limits_info)(const gchar* const contact_barejid, gboolean is_last);
|
||||
GSList* (*verify_integrity)(const gchar* const contact_barejid);
|
||||
} db_backend_t;
|
||||
|
||||
// Active backend (set during init based on PREF_DBLOG)
|
||||
extern db_backend_t* active_db_backend;
|
||||
|
||||
// Backend registry
|
||||
#ifdef HAVE_SQLITE
|
||||
db_backend_t* db_backend_sqlite(void);
|
||||
GSList* db_sqlite_list_contacts(void);
|
||||
GSList* db_sqlite_get_all_chat(const gchar* const contact_barejid);
|
||||
void db_sqlite_begin_transaction(void);
|
||||
void db_sqlite_end_transaction(void);
|
||||
void db_sqlite_rollback_transaction(void);
|
||||
int db_sqlite_last_changes(void);
|
||||
gboolean db_sqlite_is_open(void);
|
||||
#endif
|
||||
db_backend_t* db_backend_flatfile(void);
|
||||
|
||||
// Public API (dispatches to active_db_backend)
|
||||
gboolean log_database_init(ProfAccount* account);
|
||||
gboolean log_database_switch_backend(const char* new_backend);
|
||||
void log_database_add_incoming(ProfMessage* message);
|
||||
void log_database_add_outgoing_chat(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc);
|
||||
void log_database_add_outgoing_muc(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc);
|
||||
@@ -56,5 +107,12 @@ void log_database_add_outgoing_muc_pm(const char* const id, const char* const ba
|
||||
db_history_result_t log_database_get_previous_chat(const gchar* const contact_barejid, const gchar* start_time, const gchar* end_time, gboolean from_start, gboolean flip, GSList** result);
|
||||
ProfMessage* log_database_get_limits_info(const gchar* const contact_barejid, gboolean is_last);
|
||||
void log_database_close(void);
|
||||
GSList* log_database_verify_integrity(const gchar* const contact_barejid);
|
||||
|
||||
// Cross-backend export/import (requires HAVE_SQLITE)
|
||||
#ifdef HAVE_SQLITE
|
||||
int log_database_export_to_flatfile(const gchar* const contact_jid);
|
||||
int log_database_import_from_flatfile(const gchar* const contact_jid);
|
||||
#endif
|
||||
|
||||
#endif // DATABASE_H
|
||||
|
||||
680
src/database_export.c
Normal file
680
src/database_export.c
Normal file
@@ -0,0 +1,680 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// This file is part of CProof.
|
||||
// See LICENSE for the full GPLv3 text and the special OpenSSL linking exception.
|
||||
|
||||
/*
|
||||
* database_export.c
|
||||
* vim: expandtab:ts=4:sts=4:sw=4
|
||||
*
|
||||
* Cross-backend export/import for message history.
|
||||
* Reads from one backend, writes to the other, with merge + dedup.
|
||||
*/
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <glib.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/file.h>
|
||||
|
||||
#ifdef HAVE_SQLITE
|
||||
#include <sqlite3.h>
|
||||
#endif
|
||||
|
||||
#include "log.h"
|
||||
#include "common.h"
|
||||
#include "config/files.h"
|
||||
#include "config/accounts.h"
|
||||
#include "database.h"
|
||||
#include "database_flatfile.h"
|
||||
#include "config/preferences.h"
|
||||
#include "ui/ui.h"
|
||||
#include "xmpp/session.h"
|
||||
#include "xmpp/xmpp.h"
|
||||
#include "xmpp/message.h"
|
||||
|
||||
// =========================================================================
|
||||
// Everything below is only used when HAVE_SQLITE is defined
|
||||
// =========================================================================
|
||||
|
||||
#ifdef HAVE_SQLITE
|
||||
|
||||
// Print progress to console every N messages during export/import
|
||||
#define EXPORT_PROGRESS_INTERVAL 500
|
||||
|
||||
// Open SQLite if it isn't already (e.g. when flatfile is the active backend
|
||||
// and sqlite was never initialised this session, or was closed by /history
|
||||
// switch). Returns 1 if we opened it (caller must close), 0 if it was already
|
||||
// open, -1 on failure.
|
||||
static int
|
||||
_ensure_sqlite_open(void)
|
||||
{
|
||||
if (db_sqlite_is_open())
|
||||
return 0;
|
||||
|
||||
db_backend_t* sqlite_be = db_backend_sqlite();
|
||||
if (!sqlite_be || !sqlite_be->init)
|
||||
return -1;
|
||||
|
||||
const char* account_name = session_get_account_name();
|
||||
if (!account_name)
|
||||
return -1;
|
||||
|
||||
ProfAccount* account = accounts_get_account(account_name);
|
||||
if (!account)
|
||||
return -1;
|
||||
|
||||
gboolean ok = sqlite_be->init(account);
|
||||
account_free(account);
|
||||
return ok ? 1 : -1;
|
||||
}
|
||||
|
||||
static void
|
||||
_close_temp_sqlite(int opened)
|
||||
{
|
||||
if (opened != 1)
|
||||
return;
|
||||
db_backend_t* sqlite_be = db_backend_sqlite();
|
||||
if (sqlite_be && sqlite_be->close)
|
||||
sqlite_be->close();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Dedup key: stanza_id if present, else hash of timestamp+from+body
|
||||
// =========================================================================
|
||||
|
||||
// Returns a g_strdup'd key — callers free with g_free (gchar* convention).
|
||||
//
|
||||
// stanza_id is mixed into the hash but is NOT used as the sole key, since
|
||||
// some clients (older Pidgin, Adium) reuse incremental ids per session and
|
||||
// servers echo them back unchanged. Trusting stanza_id alone silently drops
|
||||
// legitimate distinct messages on collision. By hashing id + timestamp +
|
||||
// from_jid + body together we preserve id as a disambiguator without making
|
||||
// it a single point of failure: true exact duplicates still dedup, but two
|
||||
// messages that share an id with different content (or different timestamps)
|
||||
// stay distinct.
|
||||
static gchar*
|
||||
_make_dedup_key(const char* stanza_id, const char* timestamp, const char* from_jid, const char* body)
|
||||
{
|
||||
GChecksum* sum = g_checksum_new(G_CHECKSUM_SHA256);
|
||||
g_checksum_update(sum, (const guchar*)(stanza_id ? stanza_id : ""), -1);
|
||||
g_checksum_update(sum, (const guchar*)"|", 1);
|
||||
g_checksum_update(sum, (const guchar*)(timestamp ? timestamp : ""), -1);
|
||||
g_checksum_update(sum, (const guchar*)"|", 1);
|
||||
g_checksum_update(sum, (const guchar*)(from_jid ? from_jid : ""), -1);
|
||||
g_checksum_update(sum, (const guchar*)"|", 1);
|
||||
g_checksum_update(sum, (const guchar*)(body ? body : ""), -1);
|
||||
gchar* key = g_strdup(g_checksum_get_string(sum));
|
||||
g_checksum_free(sum);
|
||||
return key;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helper: reverse ff_jid_to_dir (best-effort)
|
||||
// '_at_' -> '@'
|
||||
// Returns a g_strdup'd jid — callers free with g_free (gchar* convention).
|
||||
// str_replace allocates via libc malloc, so we hand over to g_-allocated.
|
||||
// =========================================================================
|
||||
|
||||
static gchar*
|
||||
_dir_to_jid(const char* dirname)
|
||||
{
|
||||
if (!dirname)
|
||||
return NULL;
|
||||
char* raw = str_replace(dirname, "_at_", "@");
|
||||
if (!raw)
|
||||
return NULL;
|
||||
gchar* gres = g_strdup(raw);
|
||||
free(raw);
|
||||
return gres;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helper: list all contact JIDs from flatfile directory
|
||||
// Returns a GSList of g_strdup'd JID strings. Caller frees with g_slist_free_full(list, g_free).
|
||||
// =========================================================================
|
||||
|
||||
static GSList*
|
||||
_ff_list_contacts(void)
|
||||
{
|
||||
GSList* contacts = NULL;
|
||||
|
||||
if (!g_flatfile_account_jid)
|
||||
return NULL;
|
||||
|
||||
auto_gchar gchar* data_path = files_get_data_path(DIR_FLATLOG);
|
||||
auto_gchar gchar* my_dir = ff_jid_to_dir(g_flatfile_account_jid);
|
||||
auto_gchar gchar* base_dir = g_strdup_printf("%s/%s", data_path, my_dir);
|
||||
|
||||
GError* err = NULL;
|
||||
GDir* dir = g_dir_open(base_dir, 0, &err);
|
||||
if (!dir) {
|
||||
if (err) {
|
||||
log_warning("export: cannot open flatlog dir %s: %s", base_dir, err->message);
|
||||
g_error_free(err);
|
||||
} else {
|
||||
log_warning("export: cannot open flatlog dir %s (no error details)", base_dir);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
const gchar* entry;
|
||||
while ((entry = g_dir_read_name(dir)) != NULL) {
|
||||
auto_gchar gchar* full_path = g_strdup_printf("%s/%s/history.log", base_dir, entry);
|
||||
if (g_file_test(full_path, G_FILE_TEST_IS_REGULAR)) {
|
||||
gchar* jid = _dir_to_jid(entry);
|
||||
if (jid) {
|
||||
contacts = g_slist_prepend(contacts, jid);
|
||||
}
|
||||
}
|
||||
}
|
||||
g_dir_close(dir);
|
||||
|
||||
return g_slist_reverse(contacts);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helper: read ALL parsed lines from a flatfile for a contact
|
||||
// Returns GSList<ff_parsed_line_t*>. Caller frees with g_slist_free_full(list, (GDestroyNotify)ff_parsed_line_free).
|
||||
// =========================================================================
|
||||
|
||||
// Hard ceiling on the total size of a single contact's history.log that
|
||||
// _ff_read_all_lines is willing to slurp into memory. Each line expands
|
||||
// ~10x on parse (12 g_strdup'd fields + GDateTime), so 2 GB on disk
|
||||
// becomes ~20 GB peak heap — well past any realistic working set.
|
||||
#define FF_EXPORT_MAX_FILE_BYTES (2LL * 1024 * 1024 * 1024)
|
||||
|
||||
static GSList*
|
||||
_ff_read_all_lines(const char* contact_barejid)
|
||||
{
|
||||
GSList* lines = NULL;
|
||||
auto_gchar gchar* log_path = ff_get_log_path(contact_barejid);
|
||||
if (!log_path)
|
||||
return NULL;
|
||||
|
||||
// Refuse upfront if the file is so large that parsing will OOM the
|
||||
// process. We have no streaming-parse path; the whole file becomes
|
||||
// a GSList of ff_parsed_line_t in memory. Surface a clear error so
|
||||
// the user knows what hit them, rather than the kernel killing prof.
|
||||
struct stat st;
|
||||
if (stat(log_path, &st) == 0 && st.st_size > FF_EXPORT_MAX_FILE_BYTES) {
|
||||
log_error("export: %s is %lld bytes (> %lld limit); refusing to load.",
|
||||
log_path, (long long)st.st_size,
|
||||
(long long)FF_EXPORT_MAX_FILE_BYTES);
|
||||
cons_show_error("Flat-file for %s exceeds %lld MB load limit; cannot export.",
|
||||
contact_barejid, (long long)(FF_EXPORT_MAX_FILE_BYTES / (1024 * 1024)));
|
||||
return NULL;
|
||||
}
|
||||
|
||||
FILE* fp = fopen(log_path, "r");
|
||||
if (!fp)
|
||||
return NULL;
|
||||
|
||||
while (1) {
|
||||
gboolean truncated = FALSE;
|
||||
auto_char char* buf = ff_readline(fp, &truncated);
|
||||
if (!buf)
|
||||
break;
|
||||
if (truncated)
|
||||
break;
|
||||
if (buf[0] == '\0' || buf[0] == '#')
|
||||
continue;
|
||||
|
||||
ff_parsed_line_t* pl = ff_parse_line(buf);
|
||||
if (pl) {
|
||||
lines = g_slist_prepend(lines, pl);
|
||||
}
|
||||
}
|
||||
fclose(fp);
|
||||
|
||||
return g_slist_reverse(lines);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Comparator for sorting ff_parsed_line_t by timestamp (ISO8601 is lexicographic)
|
||||
// =========================================================================
|
||||
|
||||
static gint
|
||||
_compare_parsed_lines_by_timestamp(gconstpointer a, gconstpointer b)
|
||||
{
|
||||
const ff_parsed_line_t* la = a;
|
||||
const ff_parsed_line_t* lb = b;
|
||||
int cmp = g_strcmp0(la->timestamp_str, lb->timestamp_str);
|
||||
if (cmp != 0)
|
||||
return cmp;
|
||||
// Secondary key: stanza_id (stabilises sort for same-second messages)
|
||||
cmp = g_strcmp0(la->stanza_id, lb->stanza_id);
|
||||
if (cmp != 0)
|
||||
return cmp;
|
||||
return g_strcmp0(la->from_jid, lb->from_jid);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Export: SQLite -> flatfile (merge)
|
||||
// =========================================================================
|
||||
|
||||
// Callback data for the SQLite query
|
||||
typedef struct
|
||||
{
|
||||
GHashTable* seen_keys; // dedup set
|
||||
FILE* fp; // output file
|
||||
int exported; // counter
|
||||
int skipped; // counter
|
||||
} export_ctx_t;
|
||||
|
||||
// Export a single contact's messages from SQLite to flatfile.
|
||||
// Returns the number of newly exported messages, or -1 on error/skip.
|
||||
static int
|
||||
_export_one_contact(const char* contact)
|
||||
{
|
||||
// 1. Read existing flatfile lines for dedup
|
||||
GHashTable* seen_keys = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
|
||||
|
||||
GSList* existing = _ff_read_all_lines(contact);
|
||||
for (GSList* l = existing; l; l = l->next) {
|
||||
ff_parsed_line_t* pl = l->data;
|
||||
gchar* key = _make_dedup_key(pl->stanza_id, pl->timestamp_str, pl->from_jid, pl->message);
|
||||
g_hash_table_add(seen_keys, key);
|
||||
}
|
||||
int existing_count = g_slist_length(existing);
|
||||
|
||||
// 2. Query SQLite for this contact — get ALL messages via direct SQL
|
||||
GSList* sqlite_lines = db_sqlite_get_all_chat(contact);
|
||||
|
||||
if (!sqlite_lines) {
|
||||
log_debug("export: no SQLite messages for %s", contact);
|
||||
g_hash_table_destroy(seen_keys);
|
||||
g_slist_free_full(existing, (GDestroyNotify)ff_parsed_line_free);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// 3. Merge: write existing flatfile lines + new SQLite lines to temp file
|
||||
auto_gchar gchar* log_path = ff_get_log_path(contact);
|
||||
if (!log_path) {
|
||||
g_hash_table_destroy(seen_keys);
|
||||
g_slist_free_full(existing, (GDestroyNotify)ff_parsed_line_free);
|
||||
g_slist_free_full(sqlite_lines, (GDestroyNotify)message_free);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Ensure directory
|
||||
auto_gchar gchar* dir = g_path_get_dirname(log_path);
|
||||
ff_ensure_dir(dir);
|
||||
|
||||
// Use mkstemp() to create a unique-named temp file. The previous fixed
|
||||
// "{log_path}.export.tmp" name + unlink-retry was vulnerable to two
|
||||
// concurrent exports clobbering each other's in-flight tmp files
|
||||
// (process B unlinks A's mid-write tmp, both race to write, second
|
||||
// rename wins). Random suffix eliminates that.
|
||||
//
|
||||
// mkstemp on glibc creates the file with mode 0600. We fchmod to be
|
||||
// sure across libc implementations. mkstemp uses O_RDWR|O_CREAT|O_EXCL
|
||||
// internally; symlink attacks aren't possible because the name was
|
||||
// just generated and no other process knows it.
|
||||
auto_gchar gchar* tmp_path = g_strdup_printf("%s.export.XXXXXX", log_path);
|
||||
int tmp_fd = mkstemp(tmp_path);
|
||||
if (tmp_fd < 0) {
|
||||
log_error("export: mkstemp(%s) failed: %s", tmp_path, strerror(errno));
|
||||
g_hash_table_destroy(seen_keys);
|
||||
g_slist_free_full(existing, (GDestroyNotify)ff_parsed_line_free);
|
||||
g_slist_free_full(sqlite_lines, (GDestroyNotify)message_free);
|
||||
return -1;
|
||||
}
|
||||
if (fchmod(tmp_fd, S_IRUSR | S_IWUSR) != 0) {
|
||||
log_warning("export: fchmod(%s, 0600) failed: %s", tmp_path, strerror(errno));
|
||||
}
|
||||
FILE* fp = fdopen(tmp_fd, "w");
|
||||
if (!fp) {
|
||||
log_error("export: fdopen failed for %s: %s", tmp_path, strerror(errno));
|
||||
close(tmp_fd);
|
||||
unlink(tmp_path);
|
||||
g_hash_table_destroy(seen_keys);
|
||||
g_slist_free_full(existing, (GDestroyNotify)ff_parsed_line_free);
|
||||
g_slist_free_full(sqlite_lines, (GDestroyNotify)message_free);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Lock the temp file
|
||||
int fd = fileno(fp);
|
||||
if (flock(fd, LOCK_EX) != 0) {
|
||||
log_warning("export: flock(%s) failed: %s", tmp_path, strerror(errno));
|
||||
}
|
||||
|
||||
fprintf(fp, "%s", FLATFILE_HEADER);
|
||||
|
||||
int contact_exported = 0;
|
||||
int contact_skipped = 0;
|
||||
|
||||
// Collect ALL lines into a merged list for sorted output.
|
||||
// Transfer ownership of every parsed line from `existing` directly into
|
||||
// `merged` instead of deep-copying — saves ~12 g_strdup calls per row,
|
||||
// which dominate wall time at 100k+ rows. After splicing we free the
|
||||
// list nodes (not the data) and NULL out existing so the final cleanup
|
||||
// doesn't double-free.
|
||||
GSList* merged = NULL;
|
||||
for (GSList* l = existing; l; l = l->next) {
|
||||
merged = g_slist_prepend(merged, l->data);
|
||||
}
|
||||
g_slist_free(existing);
|
||||
existing = NULL;
|
||||
|
||||
// Add SQLite messages that aren't already in the flatfile
|
||||
for (GSList* l = sqlite_lines; l; l = l->next) {
|
||||
ProfMessage* msg = l->data;
|
||||
if (!msg || !msg->timestamp)
|
||||
continue;
|
||||
|
||||
auto_gchar gchar* ts = g_date_time_format_iso8601(msg->timestamp);
|
||||
const char* from_jid = msg->from_jid ? msg->from_jid->barejid : "unknown";
|
||||
const char* body = msg->plain ? msg->plain : "";
|
||||
const char* sid = msg->id ? msg->id : "";
|
||||
|
||||
gchar* key = _make_dedup_key(sid, ts, from_jid, body);
|
||||
if (g_hash_table_contains(seen_keys, key)) {
|
||||
g_free(key);
|
||||
contact_skipped++;
|
||||
continue;
|
||||
}
|
||||
g_hash_table_add(seen_keys, key);
|
||||
|
||||
ff_parsed_line_t* pl = g_malloc0(sizeof(ff_parsed_line_t));
|
||||
pl->timestamp_str = g_strdup(ts);
|
||||
pl->timestamp = g_date_time_ref(msg->timestamp);
|
||||
pl->type = g_strdup(ff_get_message_type_str(msg->type));
|
||||
pl->enc = g_strdup(ff_get_message_enc_str(msg->enc));
|
||||
pl->stanza_id = g_strdup(sid);
|
||||
pl->archive_id = msg->stanzaid ? g_strdup(msg->stanzaid) : NULL;
|
||||
pl->replace_id = NULL;
|
||||
pl->from_jid = g_strdup(from_jid);
|
||||
pl->from_resource = msg->from_jid ? g_strdup(msg->from_jid->resourcepart) : NULL;
|
||||
pl->to_jid = msg->to_jid ? g_strdup(msg->to_jid->barejid) : NULL;
|
||||
pl->to_resource = msg->to_jid ? g_strdup(msg->to_jid->resourcepart) : NULL;
|
||||
pl->marked_read = msg->marked_read;
|
||||
pl->message = g_strdup(body);
|
||||
merged = g_slist_prepend(merged, pl);
|
||||
contact_exported++;
|
||||
}
|
||||
|
||||
// Sort merged list by timestamp (ISO8601 is lexicographically sortable)
|
||||
merged = g_slist_sort(merged, _compare_parsed_lines_by_timestamp);
|
||||
|
||||
// Cache the total length once — calling g_slist_length() inside the
|
||||
// write loop is O(n) per call, turning the loop into O(n²) wall time
|
||||
// (catastrophic at >100k rows).
|
||||
int merged_total = (int)g_slist_length(merged);
|
||||
|
||||
// Write all lines sorted
|
||||
int written = 0;
|
||||
for (GSList* l = merged; l; l = l->next) {
|
||||
ff_parsed_line_t* pl = l->data;
|
||||
ff_write_line(fp, pl->timestamp_str,
|
||||
pl->type, pl->enc,
|
||||
pl->stanza_id, pl->archive_id, pl->replace_id,
|
||||
pl->from_jid, pl->from_resource,
|
||||
pl->to_jid, pl->to_resource, pl->marked_read,
|
||||
pl->message);
|
||||
written++;
|
||||
if (written % EXPORT_PROGRESS_INTERVAL == 0) {
|
||||
cons_show(" ... %s: %d/%d written so far", contact, written, merged_total);
|
||||
}
|
||||
}
|
||||
|
||||
g_slist_free_full(merged, (GDestroyNotify)ff_parsed_line_free);
|
||||
|
||||
fflush(fp);
|
||||
fsync(fd); // ensure data is on disk before atomic rename
|
||||
fclose(fp); // also releases flock
|
||||
|
||||
// Atomic rename
|
||||
int result = -1;
|
||||
if (rename(tmp_path, log_path) != 0) {
|
||||
log_error("export: rename %s -> %s failed: %s", tmp_path, log_path, strerror(errno));
|
||||
cons_show_error("Export failed for %s: could not rename temp file (%s)", contact, strerror(errno));
|
||||
unlink(tmp_path);
|
||||
} else {
|
||||
cons_show("Exported %s: %d new, %d skipped (already existed: %d)",
|
||||
contact, contact_exported, contact_skipped, existing_count);
|
||||
result = contact_exported;
|
||||
}
|
||||
|
||||
g_hash_table_destroy(seen_keys);
|
||||
g_slist_free_full(existing, (GDestroyNotify)ff_parsed_line_free);
|
||||
g_slist_free_full(sqlite_lines, (GDestroyNotify)message_free);
|
||||
return result;
|
||||
}
|
||||
|
||||
int
|
||||
log_database_export_to_flatfile(const gchar* const contact_jid)
|
||||
{
|
||||
const Jid* myjid = connection_get_jid();
|
||||
if (!myjid || !myjid->barejid) {
|
||||
cons_show_error("Export failed: not connected.");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!g_flatfile_account_jid) {
|
||||
// Flatfile backend not initialized — init it temporarily
|
||||
// We need g_flatfile_account_jid for path construction
|
||||
g_flatfile_account_jid = g_strdup(myjid->barejid);
|
||||
}
|
||||
|
||||
// Get the SQLite backend
|
||||
db_backend_t* sqlite_be = db_backend_sqlite();
|
||||
if (!sqlite_be) {
|
||||
cons_show_error("Export failed: SQLite backend not available.");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// SQLite must be open for db_sqlite_list_contacts/db_sqlite_get_all_chat
|
||||
// to return rows. When the flatfile backend is active, sqlite is closed —
|
||||
// queries silently return NULL and the export reports zero changes.
|
||||
int sqlite_opened = _ensure_sqlite_open();
|
||||
if (sqlite_opened < 0) {
|
||||
cons_show_error("Export failed: could not open SQLite database.");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Determine contact list
|
||||
GSList* contacts = NULL;
|
||||
if (contact_jid) {
|
||||
contacts = g_slist_append(contacts, g_strdup(contact_jid));
|
||||
} else {
|
||||
contacts = db_sqlite_list_contacts();
|
||||
if (!contacts) {
|
||||
cons_show("No contacts found in SQLite database.");
|
||||
_close_temp_sqlite(sqlite_opened);
|
||||
return 0;
|
||||
}
|
||||
cons_show("Exporting %d contact(s) to flat-file format...", g_slist_length(contacts));
|
||||
}
|
||||
|
||||
int total_exported = 0;
|
||||
|
||||
for (GSList* c = contacts; c; c = c->next) {
|
||||
const char* contact = c->data;
|
||||
int exported = _export_one_contact(contact);
|
||||
if (exported > 0)
|
||||
total_exported += exported;
|
||||
}
|
||||
|
||||
g_slist_free_full(contacts, g_free);
|
||||
_close_temp_sqlite(sqlite_opened);
|
||||
return total_exported;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Import: flatfile -> SQLite (merge)
|
||||
// =========================================================================
|
||||
|
||||
int
|
||||
log_database_import_from_flatfile(const gchar* const contact_jid)
|
||||
{
|
||||
const Jid* myjid = connection_get_jid();
|
||||
if (!myjid || !myjid->barejid) {
|
||||
cons_show_error("Import failed: not connected.");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!g_flatfile_account_jid) {
|
||||
g_flatfile_account_jid = g_strdup(myjid->barejid);
|
||||
}
|
||||
|
||||
db_backend_t* sqlite_be = db_backend_sqlite();
|
||||
if (!sqlite_be) {
|
||||
cons_show_error("Import failed: SQLite backend not available.");
|
||||
return -1;
|
||||
}
|
||||
|
||||
int sqlite_opened = _ensure_sqlite_open();
|
||||
if (sqlite_opened < 0) {
|
||||
cons_show_error("Import failed: could not open SQLite database.");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Determine contacts
|
||||
GSList* contacts = NULL;
|
||||
if (contact_jid) {
|
||||
contacts = g_slist_append(contacts, g_strdup(contact_jid));
|
||||
} else {
|
||||
contacts = _ff_list_contacts();
|
||||
if (!contacts) {
|
||||
cons_show("No flat-file history found to import.");
|
||||
_close_temp_sqlite(sqlite_opened);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
int total_imported = 0;
|
||||
|
||||
for (GSList* c = contacts; c; c = c->next) {
|
||||
const char* contact = c->data;
|
||||
|
||||
// 1. Read all flatfile lines
|
||||
GSList* ff_lines = _ff_read_all_lines(contact);
|
||||
if (!ff_lines) {
|
||||
log_debug("import: no flatfile messages for %s", contact);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Build dedup set from SQLite (existing stanza_ids + fallback keys)
|
||||
GHashTable* seen_keys = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
|
||||
|
||||
// Read all SQLite messages for this contact via direct SQL query
|
||||
GSList* existing_msgs = db_sqlite_get_all_chat(contact);
|
||||
for (GSList* l = existing_msgs; l; l = l->next) {
|
||||
ProfMessage* msg = l->data;
|
||||
if (!msg)
|
||||
continue;
|
||||
auto_gchar gchar* ts = msg->timestamp ? g_date_time_format_iso8601(msg->timestamp) : g_strdup("");
|
||||
gchar* key = _make_dedup_key(msg->id, ts, msg->from_jid ? msg->from_jid->barejid : "", msg->plain);
|
||||
g_hash_table_add(seen_keys, key);
|
||||
}
|
||||
g_slist_free_full(existing_msgs, (GDestroyNotify)message_free);
|
||||
|
||||
// 3. For each flatfile line not in seen_keys, insert via add_incoming
|
||||
// Wrap in a transaction for atomicity and performance
|
||||
int contact_imported = 0;
|
||||
int contact_skipped = 0;
|
||||
int total_lines = g_slist_length(ff_lines);
|
||||
|
||||
db_sqlite_begin_transaction();
|
||||
gboolean import_ok = TRUE;
|
||||
|
||||
for (GSList* l = ff_lines; l; l = l->next) {
|
||||
ff_parsed_line_t* pl = l->data;
|
||||
if (!pl || !pl->timestamp)
|
||||
continue;
|
||||
|
||||
auto_gchar gchar* ts = g_date_time_format_iso8601(pl->timestamp);
|
||||
gchar* key = _make_dedup_key(pl->stanza_id, ts, pl->from_jid, pl->message);
|
||||
|
||||
if (g_hash_table_contains(seen_keys, key)) {
|
||||
g_free(key);
|
||||
contact_skipped++;
|
||||
continue;
|
||||
}
|
||||
g_hash_table_add(seen_keys, key);
|
||||
|
||||
// Build a ProfMessage and insert via add_incoming
|
||||
// (add_incoming uses _add_to_db which preserves the original timestamp)
|
||||
gboolean is_outgoing = (g_strcmp0(pl->from_jid, myjid->barejid) == 0);
|
||||
|
||||
ProfMessage* msg = message_init();
|
||||
msg->id = pl->stanza_id ? g_strdup(pl->stanza_id) : NULL;
|
||||
msg->stanzaid = pl->archive_id ? g_strdup(pl->archive_id) : NULL;
|
||||
msg->plain = g_strdup(pl->message ? pl->message : "");
|
||||
msg->timestamp = g_date_time_ref(pl->timestamp);
|
||||
msg->type = ff_get_message_type_type(pl->type);
|
||||
msg->enc = ff_get_message_enc_type(pl->enc);
|
||||
msg->replace_id = pl->replace_id ? g_strdup(pl->replace_id) : NULL;
|
||||
msg->is_mam = TRUE; // Suppress dedup check in the backend
|
||||
|
||||
if (is_outgoing) {
|
||||
msg->from_jid = jid_create(myjid->barejid);
|
||||
if (pl->to_jid) {
|
||||
msg->to_jid = jid_create_from_bare_and_resource(pl->to_jid, pl->to_resource);
|
||||
} else {
|
||||
msg->to_jid = jid_create(contact);
|
||||
}
|
||||
} else {
|
||||
msg->from_jid = jid_create_from_bare_and_resource(pl->from_jid, pl->from_resource);
|
||||
if (pl->to_jid) {
|
||||
msg->to_jid = jid_create_from_bare_and_resource(pl->to_jid, pl->to_resource);
|
||||
} else {
|
||||
msg->to_jid = jid_create(myjid->barejid);
|
||||
}
|
||||
}
|
||||
|
||||
sqlite_be->add_incoming(msg);
|
||||
message_free(msg);
|
||||
|
||||
// Verify the row was actually inserted
|
||||
if (db_sqlite_last_changes() < 1) {
|
||||
log_error("import: insert failed for %s at %s", contact, ts);
|
||||
import_ok = FALSE;
|
||||
break;
|
||||
}
|
||||
contact_imported++;
|
||||
|
||||
if (contact_imported % EXPORT_PROGRESS_INTERVAL == 0) {
|
||||
cons_show(" ... %s: %d/%d imported so far", contact, contact_imported, total_lines);
|
||||
}
|
||||
}
|
||||
|
||||
if (import_ok) {
|
||||
db_sqlite_end_transaction();
|
||||
} else {
|
||||
db_sqlite_rollback_transaction();
|
||||
cons_show_error("Import of %s failed — transaction rolled back.", contact);
|
||||
}
|
||||
|
||||
cons_show("Imported %s: %d new, %d skipped (already in SQLite)",
|
||||
contact, contact_imported, contact_skipped);
|
||||
total_imported += contact_imported;
|
||||
|
||||
g_hash_table_destroy(seen_keys);
|
||||
g_slist_free_full(ff_lines, (GDestroyNotify)ff_parsed_line_free);
|
||||
|
||||
// If the per-contact import bailed mid-stream (likely a system-level
|
||||
// problem: disk full, sqlite locked, etc.) — don't blindly try the
|
||||
// remaining contacts; they'll almost certainly hit the same error
|
||||
// and produce a confusing wall of "Import of X failed" messages.
|
||||
// Stop here so the user can investigate the root cause.
|
||||
if (!import_ok) {
|
||||
cons_show_error("Aborting import — system-level failure suspected. Fix the underlying issue and retry.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
g_slist_free_full(contacts, g_free);
|
||||
_close_temp_sqlite(sqlite_opened);
|
||||
return total_imported;
|
||||
}
|
||||
|
||||
#endif /* HAVE_SQLITE */
|
||||
1022
src/database_flatfile.c
Normal file
1022
src/database_flatfile.c
Normal file
File diff suppressed because it is too large
Load Diff
172
src/database_flatfile.h
Normal file
172
src/database_flatfile.h
Normal file
@@ -0,0 +1,172 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// This file is part of CProof.
|
||||
// See LICENSE for the full GPLv3 text and the special OpenSSL linking exception.
|
||||
|
||||
/*
|
||||
* database_flatfile.h
|
||||
* vim: expandtab:ts=4:sts=4:sw=4
|
||||
*
|
||||
* Internal header shared between database_flatfile*.c modules.
|
||||
* Not part of the public API — do not include from outside the flatfile backend.
|
||||
*/
|
||||
|
||||
#ifndef DATABASE_FLATFILE_H
|
||||
#define DATABASE_FLATFILE_H
|
||||
|
||||
#include <glib.h>
|
||||
#include <stdio.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
#include "database.h"
|
||||
#include "xmpp/xmpp.h"
|
||||
#include "xmpp/message.h"
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
#define DIR_FLATLOG "flatlog"
|
||||
#define FLATFILE_FORMAT_VERSION 1
|
||||
#define FF_VERSION_MARKER "format-version: "
|
||||
#define FF_STRINGIFY_(x) #x
|
||||
#define FF_STRINGIFY(x) FF_STRINGIFY_(x)
|
||||
#define FLATFILE_HEADER "# cproof chat log — UTF-8, LF line endings, " FF_VERSION_MARKER FF_STRINGIFY(FLATFILE_FORMAT_VERSION) "\n"
|
||||
#define FF_MAX_LINE_LEN (10 * 1024 * 1024) /* 10 MB — reject lines longer than this */
|
||||
#define FF_MAX_LMC_DEPTH 100 /* max correction chain depth */
|
||||
|
||||
// --- Shared global ---
|
||||
|
||||
// Account JID stored during init for path construction.
|
||||
// Defined in database_flatfile.c, used by all flatfile modules.
|
||||
extern char* g_flatfile_account_jid;
|
||||
|
||||
// --- Parsed line structure ---
|
||||
|
||||
typedef struct
|
||||
{
|
||||
char* timestamp_str;
|
||||
GDateTime* timestamp;
|
||||
char* type;
|
||||
char* enc;
|
||||
char* stanza_id;
|
||||
char* archive_id;
|
||||
char* replace_id;
|
||||
char* from_jid;
|
||||
char* from_resource;
|
||||
char* to_jid;
|
||||
char* to_resource;
|
||||
int marked_read; // -1 = unset (NULL in DB), 0 = unread, 1 = read
|
||||
char* message;
|
||||
off_t file_offset;
|
||||
} ff_parsed_line_t;
|
||||
|
||||
// --- Sparse index for single-file lookup ---
|
||||
|
||||
// Sample every N-th log line for the sparse index.
|
||||
// 500 lines ≈ one index entry per ~50 KB of log data, so a 100K-message
|
||||
// contact requires only ~200 entries (~3 KB) for O(log n) time-range lookup.
|
||||
#define FF_INDEX_STEP 500
|
||||
|
||||
// --- Metadata field prefixes (used in _ff_cache_line_ids and ff_parse_line) ---
|
||||
|
||||
#define FF_META_PREFIX_ID "id:"
|
||||
#define FF_META_PREFIX_AID "aid:"
|
||||
|
||||
typedef struct
|
||||
{
|
||||
off_t byte_offset;
|
||||
gint64 timestamp_epoch;
|
||||
} ff_index_entry_t;
|
||||
|
||||
typedef struct
|
||||
{
|
||||
time_t mtime;
|
||||
off_t size;
|
||||
ino_t inode;
|
||||
} ff_file_stamp_t;
|
||||
|
||||
typedef struct
|
||||
{
|
||||
char* filepath;
|
||||
ff_index_entry_t* entries;
|
||||
size_t n_entries;
|
||||
size_t cap_entries;
|
||||
size_t total_lines;
|
||||
ff_file_stamp_t stamp;
|
||||
int bom_len;
|
||||
GHashTable* archive_ids; // set: archive_id -> NULL (MAM dedup, O(1))
|
||||
GHashTable* stanza_senders; // map: stanza_id -> from_jid (LMC validation, O(1))
|
||||
} ff_contact_state_t;
|
||||
|
||||
// State management
|
||||
ff_contact_state_t* ff_state_new(const char* filepath);
|
||||
void ff_state_free(ff_contact_state_t* state);
|
||||
gboolean ff_state_ensure_fresh(ff_contact_state_t* state);
|
||||
off_t ff_state_offset_for_time(ff_contact_state_t* state, const char* iso_time);
|
||||
|
||||
// --- Type conversion helpers ---
|
||||
|
||||
const char* ff_get_message_type_str(prof_msg_type_t type);
|
||||
prof_msg_type_t ff_get_message_type_type(const char* const type);
|
||||
const char* ff_get_message_enc_str(prof_enc_t enc);
|
||||
prof_enc_t ff_get_message_enc_type(const char* const encstr);
|
||||
|
||||
// --- Path helpers ---
|
||||
|
||||
char* ff_jid_to_dir(const char* jid);
|
||||
char* ff_get_contact_dir(const char* contact_barejid);
|
||||
char* ff_get_log_path(const char* contact_barejid);
|
||||
gboolean ff_ensure_dir(const char* path);
|
||||
|
||||
// --- Escape / unescape ---
|
||||
|
||||
char* ff_escape_message(const char* text);
|
||||
char* ff_unescape_message(const char* text);
|
||||
char* ff_escape_meta_value(const char* val);
|
||||
char* ff_unescape_meta_value(const char* val);
|
||||
|
||||
// --- I/O ---
|
||||
|
||||
// Skip UTF-8 BOM if present; returns 3 if skipped, 0 otherwise.
|
||||
int ff_skip_bom(FILE* fp);
|
||||
|
||||
// Scan leading '#' comment lines for the format-version marker. Returns
|
||||
// the version found, 0 if no marker is present, or -1 on read error.
|
||||
// File position is rewound to the start of the comment block on entry.
|
||||
int ff_read_format_version(FILE* fp);
|
||||
|
||||
char* ff_readline(FILE* fp, gboolean* truncated);
|
||||
void ff_write_line(FILE* fp, const char* timestamp, const char* type, const char* enc,
|
||||
const char* stanza_id, const char* archive_id, const char* replace_id,
|
||||
const char* from_jid, const char* from_resource,
|
||||
const char* to_jid, const char* to_resource, int marked_read,
|
||||
const char* message_text);
|
||||
|
||||
// --- Parser helpers ---
|
||||
|
||||
const char* ff_find_unescaped_char(const char* str, char ch);
|
||||
char** ff_split_meta(const char* meta);
|
||||
const char* ff_find_unescaped_colonspace(const char* str);
|
||||
char* ff_unescape_sender_resource(const char* res);
|
||||
|
||||
// --- Parser ---
|
||||
|
||||
void ff_parsed_line_free(ff_parsed_line_t* pl);
|
||||
ff_parsed_line_t* ff_parse_line(const char* line);
|
||||
ProfMessage* ff_parsed_to_profmessage(ff_parsed_line_t* pl);
|
||||
|
||||
// --- Integrity verification (database_flatfile_verify.c) ---
|
||||
|
||||
// Run integrity checks against one contact's history.log (or every contact
|
||||
// when contact_barejid == NULL).
|
||||
//
|
||||
// Returns a freshly allocated GSList<integrity_issue_t*> reporting:
|
||||
// - File-level: missing log, BOM, CRLF, empty, wrong permissions
|
||||
// - Line-level: invalid UTF-8, control characters, unparsable lines,
|
||||
// timestamps out of order, duplicate stanza-id /
|
||||
// archive-id (tracked separately)
|
||||
// - Cross-line: broken `corrects:` LMC references
|
||||
//
|
||||
// Callers must free with g_slist_free_full(issues, integrity_issue_free).
|
||||
GSList* ff_verify_integrity(const gchar* const contact_barejid);
|
||||
|
||||
#endif
|
||||
825
src/database_flatfile_parser.c
Normal file
825
src/database_flatfile_parser.c
Normal file
@@ -0,0 +1,825 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// This file is part of CProof.
|
||||
// See LICENSE for the full GPLv3 text and the special OpenSSL linking exception.
|
||||
|
||||
/*
|
||||
* database_flatfile_parser.c
|
||||
* vim: expandtab:ts=4:sts=4:sw=4
|
||||
*
|
||||
* Flat-file backend: type helpers, path helpers, escape/unescape,
|
||||
* line I/O, and the tolerant log-line parser.
|
||||
*/
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <sys/stat.h>
|
||||
#include <glib.h>
|
||||
#include <glib/gstdio.h>
|
||||
#include <limits.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
|
||||
#include "log.h"
|
||||
#include "common.h"
|
||||
#include "config/files.h"
|
||||
#include "database_flatfile.h"
|
||||
|
||||
// =========================================================================
|
||||
// Type conversion helpers
|
||||
// =========================================================================
|
||||
|
||||
const char*
|
||||
ff_get_message_type_str(prof_msg_type_t type)
|
||||
{
|
||||
switch (type) {
|
||||
case PROF_MSG_TYPE_CHAT:
|
||||
return "chat";
|
||||
case PROF_MSG_TYPE_MUC:
|
||||
return "muc";
|
||||
case PROF_MSG_TYPE_MUCPM:
|
||||
return "mucpm";
|
||||
case PROF_MSG_TYPE_UNINITIALIZED:
|
||||
return "chat";
|
||||
}
|
||||
return "chat";
|
||||
}
|
||||
|
||||
prof_msg_type_t
|
||||
ff_get_message_type_type(const char* const type)
|
||||
{
|
||||
if (g_strcmp0(type, "chat") == 0) {
|
||||
return PROF_MSG_TYPE_CHAT;
|
||||
} else if (g_strcmp0(type, "muc") == 0) {
|
||||
return PROF_MSG_TYPE_MUC;
|
||||
} else if (g_strcmp0(type, "mucpm") == 0) {
|
||||
return PROF_MSG_TYPE_MUCPM;
|
||||
}
|
||||
return PROF_MSG_TYPE_CHAT;
|
||||
}
|
||||
|
||||
const char*
|
||||
ff_get_message_enc_str(prof_enc_t enc)
|
||||
{
|
||||
switch (enc) {
|
||||
case PROF_MSG_ENC_OX:
|
||||
return "ox";
|
||||
case PROF_MSG_ENC_PGP:
|
||||
return "pgp";
|
||||
case PROF_MSG_ENC_OTR:
|
||||
return "otr";
|
||||
case PROF_MSG_ENC_OMEMO:
|
||||
return "omemo";
|
||||
case PROF_MSG_ENC_NONE:
|
||||
return "none";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
prof_enc_t
|
||||
ff_get_message_enc_type(const char* const encstr)
|
||||
{
|
||||
if (g_strcmp0(encstr, "ox") == 0) {
|
||||
return PROF_MSG_ENC_OX;
|
||||
} else if (g_strcmp0(encstr, "pgp") == 0) {
|
||||
return PROF_MSG_ENC_PGP;
|
||||
} else if (g_strcmp0(encstr, "otr") == 0) {
|
||||
return PROF_MSG_ENC_OTR;
|
||||
} else if (g_strcmp0(encstr, "omemo") == 0) {
|
||||
return PROF_MSG_ENC_OMEMO;
|
||||
}
|
||||
return PROF_MSG_ENC_NONE;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Path helpers
|
||||
// =========================================================================
|
||||
|
||||
// Sanitise a JID for use as a directory name.
|
||||
// 1. Replace '@' with '_at_'
|
||||
// 2. Reject / strip path-separator and traversal characters: '/', '\\', '..'
|
||||
// This prevents a malicious federated JID like "../../../tmp/pwned" from
|
||||
// escaping the log directory tree.
|
||||
char*
|
||||
ff_jid_to_dir(const char* jid)
|
||||
{
|
||||
if (!jid || jid[0] == '\0')
|
||||
return NULL;
|
||||
|
||||
// Replace '@' with '_at_' (str_replace returns a freshly allocated string)
|
||||
char* result = str_replace(jid, "@", "_at_");
|
||||
if (!result)
|
||||
return NULL;
|
||||
|
||||
// Replace path-separator characters in place
|
||||
for (char* p = result; *p; p++) {
|
||||
if (*p == '/' || *p == '\\')
|
||||
*p = '_';
|
||||
}
|
||||
|
||||
// Collapse any remaining ".." sequences to "__" (belt-and-suspenders)
|
||||
char* dotdot;
|
||||
while ((dotdot = strstr(result, "..")) != NULL) {
|
||||
dotdot[0] = '_';
|
||||
dotdot[1] = '_';
|
||||
}
|
||||
|
||||
if (result[0] == '\0') {
|
||||
free(result);
|
||||
return NULL;
|
||||
}
|
||||
// Callers free via g_free, str_replace allocates via malloc — handover.
|
||||
char* gresult = g_strdup(result);
|
||||
free(result);
|
||||
return gresult;
|
||||
}
|
||||
|
||||
// Get the base directory for a contact's logs:
|
||||
// ~/.local/share/profanity/flatlog/{my_jid_dir}/{contact_jid_dir}/
|
||||
char*
|
||||
ff_get_contact_dir(const char* contact_barejid)
|
||||
{
|
||||
if (!g_flatfile_account_jid || !contact_barejid)
|
||||
return NULL;
|
||||
|
||||
auto_gchar gchar* data_path = files_get_data_path(DIR_FLATLOG);
|
||||
auto_gchar gchar* my_dir = ff_jid_to_dir(g_flatfile_account_jid);
|
||||
auto_gchar gchar* contact_dir = ff_jid_to_dir(contact_barejid);
|
||||
|
||||
char* result = g_strdup_printf("%s/%s/%s", data_path, my_dir, contact_dir);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get the single log file path for a contact: {contact_dir}/history.log
|
||||
char*
|
||||
ff_get_log_path(const char* contact_barejid)
|
||||
{
|
||||
auto_gchar gchar* contact_dir = ff_get_contact_dir(contact_barejid);
|
||||
if (!contact_dir)
|
||||
return NULL;
|
||||
|
||||
char* result = g_strdup_printf("%s/history.log", contact_dir);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Ensure the directory exists, create if needed.
|
||||
// Refuses to follow symlinks at the final component.
|
||||
gboolean
|
||||
ff_ensure_dir(const char* path)
|
||||
{
|
||||
if (g_file_test(path, G_FILE_TEST_IS_SYMLINK)) {
|
||||
log_error("flatfile: directory path is a symlink, refusing: %s", path);
|
||||
return FALSE;
|
||||
}
|
||||
if (g_file_test(path, G_FILE_TEST_IS_DIR))
|
||||
return TRUE;
|
||||
if (g_mkdir_with_parents(path, S_IRWXU) != 0) {
|
||||
log_error("flatfile: Could not create directory: %s (errno=%d)", path, errno);
|
||||
return FALSE;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Escape / unescape helpers
|
||||
// =========================================================================
|
||||
//
|
||||
// Message text and metadata values from remote peers can contain arbitrary
|
||||
// characters including newlines, pipes and brackets. Without escaping, a
|
||||
// crafted message could inject fake log lines (log injection / format
|
||||
// injection). We escape on write and unescape on read.
|
||||
|
||||
// Escape message body: \ -> \\, \n -> \n literal, \r -> \r literal
|
||||
char*
|
||||
ff_escape_message(const char* text)
|
||||
{
|
||||
if (!text)
|
||||
return g_strdup("");
|
||||
GString* out = g_string_sized_new(strlen(text));
|
||||
for (const char* p = text; *p; p++) {
|
||||
switch (*p) {
|
||||
case '\\':
|
||||
g_string_append(out, "\\\\");
|
||||
break;
|
||||
case '\n':
|
||||
g_string_append(out, "\\n");
|
||||
break;
|
||||
case '\r':
|
||||
g_string_append(out, "\\r");
|
||||
break;
|
||||
default:
|
||||
g_string_append_c(out, *p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return g_string_free(out, FALSE);
|
||||
}
|
||||
|
||||
// Unescape message body: \\ -> \, \n -> newline, \r -> CR
|
||||
char*
|
||||
ff_unescape_message(const char* text)
|
||||
{
|
||||
if (!text)
|
||||
return g_strdup("");
|
||||
GString* out = g_string_sized_new(strlen(text));
|
||||
for (const char* p = text; *p; p++) {
|
||||
if (*p != '\\' || !*(p + 1)) {
|
||||
g_string_append_c(out, *p);
|
||||
continue;
|
||||
}
|
||||
p++;
|
||||
switch (*p) {
|
||||
case '\\':
|
||||
g_string_append_c(out, '\\');
|
||||
break;
|
||||
case 'n':
|
||||
g_string_append_c(out, '\n');
|
||||
break;
|
||||
case 'r':
|
||||
g_string_append_c(out, '\r');
|
||||
break;
|
||||
default:
|
||||
g_string_append_c(out, '\\');
|
||||
g_string_append_c(out, *p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return g_string_free(out, FALSE);
|
||||
}
|
||||
|
||||
// Escape metadata value (stanza_id, archive_id, replace_id):
|
||||
// these come from remote servers and may contain |, ], \, newlines.
|
||||
char*
|
||||
ff_escape_meta_value(const char* val)
|
||||
{
|
||||
if (!val || strlen(val) == 0)
|
||||
return NULL;
|
||||
GString* out = g_string_sized_new(strlen(val));
|
||||
for (const char* p = val; *p; p++) {
|
||||
switch (*p) {
|
||||
case '|':
|
||||
g_string_append(out, "\\|");
|
||||
break;
|
||||
case ']':
|
||||
g_string_append(out, "\\]");
|
||||
break;
|
||||
case '\\':
|
||||
g_string_append(out, "\\\\");
|
||||
break;
|
||||
case '\n':
|
||||
g_string_append(out, "\\n");
|
||||
break;
|
||||
case '\r':
|
||||
g_string_append(out, "\\r");
|
||||
break;
|
||||
default:
|
||||
g_string_append_c(out, *p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return g_string_free(out, FALSE);
|
||||
}
|
||||
|
||||
// Unescape metadata value
|
||||
char*
|
||||
ff_unescape_meta_value(const char* val)
|
||||
{
|
||||
if (!val)
|
||||
return NULL;
|
||||
GString* out = g_string_sized_new(strlen(val));
|
||||
for (const char* p = val; *p; p++) {
|
||||
if (*p != '\\' || !*(p + 1)) {
|
||||
g_string_append_c(out, *p);
|
||||
continue;
|
||||
}
|
||||
p++;
|
||||
switch (*p) {
|
||||
case '|':
|
||||
g_string_append_c(out, '|');
|
||||
break;
|
||||
case ']':
|
||||
g_string_append_c(out, ']');
|
||||
break;
|
||||
case '\\':
|
||||
g_string_append_c(out, '\\');
|
||||
break;
|
||||
case 'n':
|
||||
g_string_append_c(out, '\n');
|
||||
break;
|
||||
case 'r':
|
||||
g_string_append_c(out, '\r');
|
||||
break;
|
||||
default:
|
||||
g_string_append_c(out, '\\');
|
||||
g_string_append_c(out, *p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return g_string_free(out, FALSE);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// BOM helper
|
||||
// =========================================================================
|
||||
//
|
||||
// Skip UTF-8 BOM (EF BB BF) at the beginning of a file.
|
||||
// Returns 3 if BOM was present, 0 otherwise. File position is set past
|
||||
// the BOM (or rewound to the start).
|
||||
int
|
||||
ff_skip_bom(FILE* fp)
|
||||
{
|
||||
int c1 = fgetc(fp);
|
||||
int c2 = fgetc(fp);
|
||||
int c3 = fgetc(fp);
|
||||
if (c1 == 0xEF && c2 == 0xBB && c3 == 0xBF)
|
||||
return 3;
|
||||
fseek(fp, 0, SEEK_SET);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Format-version helper
|
||||
// =========================================================================
|
||||
//
|
||||
// Scan leading '#' comment lines for the `format-version: <N>` marker
|
||||
// (anywhere within the comment line). Stops at the first non-comment
|
||||
// line or after FF_VERSION_SCAN_MAX comments. Restores fp to its
|
||||
// position at entry, so callers can re-parse the header normally
|
||||
// afterwards.
|
||||
#define FF_VERSION_SCAN_MAX 16
|
||||
|
||||
int
|
||||
ff_read_format_version(FILE* fp)
|
||||
{
|
||||
long start = ftell(fp);
|
||||
if (start < 0)
|
||||
return -1;
|
||||
|
||||
int version = 0;
|
||||
size_t marker_len = strlen(FF_VERSION_MARKER);
|
||||
|
||||
for (int i = 0; i < FF_VERSION_SCAN_MAX; i++) {
|
||||
gboolean truncated = FALSE;
|
||||
char* line = ff_readline(fp, &truncated);
|
||||
if (!line)
|
||||
break;
|
||||
if (line[0] != '#') {
|
||||
free(line);
|
||||
break;
|
||||
}
|
||||
char* found = strstr(line, FF_VERSION_MARKER);
|
||||
if (found) {
|
||||
char* num_start = found + marker_len;
|
||||
char* endptr = NULL;
|
||||
long v = strtol(num_start, &endptr, 10);
|
||||
if (endptr != num_start && v > 0 && v <= INT_MAX)
|
||||
version = (int)v;
|
||||
free(line);
|
||||
break;
|
||||
}
|
||||
free(line);
|
||||
}
|
||||
|
||||
fseek(fp, start, SEEK_SET);
|
||||
return version;
|
||||
}
|
||||
|
||||
// Readline helper
|
||||
// =========================================================================
|
||||
//
|
||||
// POSIX getline() for dynamic-length lines. Returns line without trailing
|
||||
// newline, or NULL on EOF. Sets *truncated=TRUE if the line had no trailing
|
||||
// newline at EOF (partial write detection).
|
||||
char*
|
||||
ff_readline(FILE* fp, gboolean* truncated)
|
||||
{
|
||||
char* line = NULL;
|
||||
size_t cap = 0;
|
||||
ssize_t nread = getline(&line, &cap, fp);
|
||||
if (nread == -1) {
|
||||
free(line);
|
||||
return NULL;
|
||||
}
|
||||
// Guard against pathological lines that could exhaust memory.
|
||||
// getline() already allocated, so we free and skip if too long.
|
||||
if (nread > FF_MAX_LINE_LEN) {
|
||||
log_error("flatfile: line too long (%zd bytes), skipping", nread);
|
||||
// Check newline termination before freeing
|
||||
gboolean had_newline = (nread > 0 && line[nread - 1] == '\n');
|
||||
free(line);
|
||||
// Skip to next newline if the overlength line wasn't newline-terminated
|
||||
if (!had_newline) {
|
||||
int ch;
|
||||
while ((ch = fgetc(fp)) != EOF && ch != '\n') {}
|
||||
}
|
||||
if (truncated)
|
||||
*truncated = FALSE;
|
||||
// Return empty string so caller's loop continues (parse will reject it).
|
||||
// strdup() so callers can always free() consistently.
|
||||
return strdup("");
|
||||
}
|
||||
if (truncated)
|
||||
*truncated = FALSE;
|
||||
if (nread > 0 && line[nread - 1] == '\n') {
|
||||
line[--nread] = '\0';
|
||||
} else if (feof(fp)) {
|
||||
// Line without trailing newline at EOF — likely a partial write
|
||||
if (truncated)
|
||||
*truncated = TRUE;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Line writer
|
||||
// =========================================================================
|
||||
//
|
||||
// Format: {ISO8601} [{type}|{enc}|id:{stanza_id}|aid:{archive_id}|corrects:{replace_id}] {from_jid}/{resource}: {message}
|
||||
//
|
||||
// Message text is escaped: \\ -> \\\\, newline -> \\n, CR -> \\r
|
||||
// Metadata values are escaped: |, ], \\, newline, CR
|
||||
// Lines starting with '#' are comments.
|
||||
// Empty lines are skipped.
|
||||
|
||||
void
|
||||
ff_write_line(FILE* fp, const char* timestamp, const char* type, const char* enc,
|
||||
const char* stanza_id, const char* archive_id, const char* replace_id,
|
||||
const char* from_jid, const char* from_resource,
|
||||
const char* to_jid, const char* to_resource, int marked_read,
|
||||
const char* message_text)
|
||||
{
|
||||
// Escape metadata values from remote peers
|
||||
auto_gchar gchar* safe_sid = ff_escape_meta_value(stanza_id);
|
||||
auto_gchar gchar* safe_aid = ff_escape_meta_value(archive_id);
|
||||
auto_gchar gchar* safe_rid = ff_escape_meta_value(replace_id);
|
||||
|
||||
// Build metadata section: [type|enc|id:...|aid:...|corrects:...|to:...|to_res:...|read:...]
|
||||
GString* meta = g_string_new("[");
|
||||
g_string_append(meta, type ? type : "chat");
|
||||
g_string_append_c(meta, '|');
|
||||
g_string_append(meta, enc ? enc : "none");
|
||||
if (safe_sid) {
|
||||
g_string_append_printf(meta, "|id:%s", safe_sid);
|
||||
}
|
||||
if (safe_aid) {
|
||||
g_string_append_printf(meta, "|aid:%s", safe_aid);
|
||||
}
|
||||
if (safe_rid) {
|
||||
g_string_append_printf(meta, "|corrects:%s", safe_rid);
|
||||
}
|
||||
if (to_jid && strlen(to_jid) > 0
|
||||
&& g_strcmp0(to_jid, "(null)") != 0) {
|
||||
auto_gchar gchar* safe_to = ff_escape_meta_value(to_jid);
|
||||
g_string_append_printf(meta, "|to:%s", safe_to);
|
||||
}
|
||||
if (to_resource && strlen(to_resource) > 0
|
||||
&& g_strcmp0(to_resource, "(null)") != 0) {
|
||||
auto_gchar gchar* safe_tores = ff_escape_meta_value(to_resource);
|
||||
g_string_append_printf(meta, "|to_res:%s", safe_tores);
|
||||
}
|
||||
if (marked_read >= 0) {
|
||||
g_string_append_printf(meta, "|read:%d", marked_read ? 1 : 0);
|
||||
}
|
||||
g_string_append_c(meta, ']');
|
||||
|
||||
// Build sender — escape ": " in the resource part to prevent
|
||||
// the parser from splitting at the wrong point.
|
||||
const gboolean from_jid_usable = from_jid && *from_jid
|
||||
&& g_strcmp0(from_jid, "(null)") != 0;
|
||||
GString* sender = g_string_new(from_jid_usable ? from_jid : "unknown");
|
||||
if (from_resource && strlen(from_resource) > 0
|
||||
&& g_strcmp0(from_resource, "(null)") != 0) {
|
||||
// Escape backslash and colon-space in resource
|
||||
GString* safe_res = g_string_sized_new(strlen(from_resource));
|
||||
for (const char* p = from_resource; *p; p++) {
|
||||
if (*p == '\\') {
|
||||
g_string_append(safe_res, "\\\\");
|
||||
} else if (*p == ':' && *(p + 1) == ' ') {
|
||||
g_string_append(safe_res, "\\: ");
|
||||
p++; // skip the space too
|
||||
} else {
|
||||
g_string_append_c(safe_res, *p);
|
||||
}
|
||||
}
|
||||
g_string_append_printf(sender, "/%s", safe_res->str);
|
||||
g_string_free(safe_res, TRUE);
|
||||
}
|
||||
|
||||
// Escape message body to prevent log injection
|
||||
auto_char char* safe_msg = ff_escape_message(message_text);
|
||||
|
||||
int ret = fprintf(fp, "%s %s %s: %s\n",
|
||||
timestamp, meta->str, sender->str, safe_msg);
|
||||
if (ret < 0) {
|
||||
log_error("flatfile: fprintf failed (errno=%d)", errno);
|
||||
}
|
||||
|
||||
g_string_free(meta, TRUE);
|
||||
g_string_free(sender, TRUE);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Parser helpers
|
||||
// =========================================================================
|
||||
|
||||
// Find the first occurrence of 'ch' that is not preceded by an unescaped backslash.
|
||||
const char*
|
||||
ff_find_unescaped_char(const char* str, char ch)
|
||||
{
|
||||
if (!str)
|
||||
return NULL;
|
||||
for (const char* p = str; *p; p++) {
|
||||
if (*p == '\\' && *(p + 1)) {
|
||||
p++; // skip escaped character
|
||||
continue;
|
||||
}
|
||||
if (*p == ch)
|
||||
return p;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Split metadata content on unescaped '|'. Returns a NULL-terminated array.
|
||||
// Caller must g_strfreev() the result.
|
||||
char**
|
||||
ff_split_meta(const char* meta)
|
||||
{
|
||||
GPtrArray* arr = g_ptr_array_new();
|
||||
const char* start = meta;
|
||||
for (const char* p = meta;; p++) {
|
||||
if (*p == '\\' && *(p + 1)) {
|
||||
p++; // skip escaped char
|
||||
continue;
|
||||
}
|
||||
if (*p == '|' || *p == '\0') {
|
||||
g_ptr_array_add(arr, g_strndup(start, p - start));
|
||||
if (*p == '\0')
|
||||
break;
|
||||
start = p + 1;
|
||||
}
|
||||
}
|
||||
g_ptr_array_add(arr, NULL);
|
||||
return (char**)g_ptr_array_free(arr, FALSE);
|
||||
}
|
||||
|
||||
// Find first unescaped ": " (colon-space) in a string.
|
||||
const char*
|
||||
ff_find_unescaped_colonspace(const char* str)
|
||||
{
|
||||
if (!str)
|
||||
return NULL;
|
||||
for (const char* p = str; *p; p++) {
|
||||
if (*p == '\\' && *(p + 1)) {
|
||||
p++; // skip escaped character
|
||||
continue;
|
||||
}
|
||||
if (*p == ':' && *(p + 1) == ' ') {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Unescape a sender resource: "\\" -> '\', "\: " -> ": "
|
||||
char*
|
||||
ff_unescape_sender_resource(const char* res)
|
||||
{
|
||||
if (!res)
|
||||
return NULL;
|
||||
GString* out = g_string_sized_new(strlen(res));
|
||||
for (const char* p = res; *p; p++) {
|
||||
if (*p != '\\' || !*(p + 1)) {
|
||||
g_string_append_c(out, *p);
|
||||
continue;
|
||||
}
|
||||
p++;
|
||||
if (*p == '\\') {
|
||||
g_string_append_c(out, '\\');
|
||||
} else if (*p == ':' && *(p + 1) == ' ') {
|
||||
g_string_append(out, ": ");
|
||||
p++; // skip the space
|
||||
} else {
|
||||
// Unknown escape — preserve literally
|
||||
g_string_append_c(out, '\\');
|
||||
g_string_append_c(out, *p);
|
||||
}
|
||||
}
|
||||
return g_string_free(out, FALSE);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Line parser
|
||||
// =========================================================================
|
||||
|
||||
void
|
||||
ff_parsed_line_free(ff_parsed_line_t* pl)
|
||||
{
|
||||
if (!pl)
|
||||
return;
|
||||
g_free(pl->timestamp_str);
|
||||
if (pl->timestamp)
|
||||
g_date_time_unref(pl->timestamp);
|
||||
g_free(pl->type);
|
||||
g_free(pl->enc);
|
||||
g_free(pl->stanza_id);
|
||||
g_free(pl->archive_id);
|
||||
g_free(pl->replace_id);
|
||||
g_free(pl->from_jid);
|
||||
g_free(pl->from_resource);
|
||||
g_free(pl->to_jid);
|
||||
g_free(pl->to_resource);
|
||||
g_free(pl->message);
|
||||
g_free(pl);
|
||||
}
|
||||
|
||||
// Populate the parsed-line metadata fields from the pipe-split parts[]
|
||||
// (the contents of a "[...]" section). Index 0 is type, index 1 is enc;
|
||||
// remaining parts are key:value pairs (id:, aid:, corrects:, to:, to_res:, read:).
|
||||
static void
|
||||
_ff_parse_meta_parts(char** parts, ff_parsed_line_t* out)
|
||||
{
|
||||
for (int i = 0; parts[i]; i++) {
|
||||
if (i == 0) {
|
||||
out->type = g_strdup(parts[i]);
|
||||
} else if (i == 1) {
|
||||
out->enc = g_strdup(parts[i]);
|
||||
} else if (g_str_has_prefix(parts[i], FF_META_PREFIX_ID)) {
|
||||
out->stanza_id = ff_unescape_meta_value(parts[i] + strlen(FF_META_PREFIX_ID));
|
||||
} else if (g_str_has_prefix(parts[i], FF_META_PREFIX_AID)) {
|
||||
out->archive_id = ff_unescape_meta_value(parts[i] + strlen(FF_META_PREFIX_AID));
|
||||
} else if (g_str_has_prefix(parts[i], "corrects:")) {
|
||||
out->replace_id = ff_unescape_meta_value(parts[i] + strlen("corrects:"));
|
||||
} else if (g_str_has_prefix(parts[i], "to:")) {
|
||||
out->to_jid = ff_unescape_meta_value(parts[i] + strlen("to:"));
|
||||
} else if (g_str_has_prefix(parts[i], "to_res:")) {
|
||||
out->to_resource = ff_unescape_meta_value(parts[i] + strlen("to_res:"));
|
||||
} else if (g_str_has_prefix(parts[i], "read:")) {
|
||||
out->marked_read = atoi(parts[i] + strlen("read:")) ? 1 : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse a single line. Returns NULL on parse failure.
|
||||
// Line format: {timestamp} [{metadata}] {sender}: {message}
|
||||
ff_parsed_line_t*
|
||||
ff_parse_line(const char* line)
|
||||
{
|
||||
if (!line || line[0] == '\0' || line[0] == '#') {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Strip trailing \r if present (CRLF handling)
|
||||
char* work = g_strdup(line);
|
||||
gsize len = strlen(work);
|
||||
if (len > 0 && work[len - 1] == '\r') {
|
||||
work[len - 1] = '\0';
|
||||
len--;
|
||||
}
|
||||
if (len == 0) {
|
||||
g_free(work);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// UTF-8 validation
|
||||
const gchar* end;
|
||||
if (!g_utf8_validate(work, -1, &end)) {
|
||||
log_warning("flatfile: invalid UTF-8 at byte offset %ld", (long)(end - work));
|
||||
// Attempt Latin-1 fallback
|
||||
gsize br, bw;
|
||||
GError* err = NULL;
|
||||
char* converted = g_convert(work, -1, "UTF-8", "ISO-8859-1", &br, &bw, &err);
|
||||
if (converted) {
|
||||
g_free(work);
|
||||
work = converted;
|
||||
} else {
|
||||
if (err)
|
||||
g_error_free(err);
|
||||
g_free(work);
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
ff_parsed_line_t* result = g_malloc0(sizeof(ff_parsed_line_t));
|
||||
result->file_offset = -1;
|
||||
result->marked_read = -1; // unset by default
|
||||
|
||||
// Parse timestamp — everything up to first space followed by '['
|
||||
char* bracket_start = strchr(work, '[');
|
||||
char* first_space = strchr(work, ' ');
|
||||
|
||||
if (bracket_start && first_space && first_space < bracket_start) {
|
||||
// Standard format with metadata: {timestamp} [{meta}] {sender}: {msg}
|
||||
result->timestamp_str = g_strndup(work, first_space - work);
|
||||
|
||||
// Parse metadata section [...]
|
||||
const char* bracket_end = ff_find_unescaped_char(bracket_start + 1, ']');
|
||||
if (!bracket_end) {
|
||||
// No closing bracket — malformed metadata
|
||||
ff_parsed_line_free(result);
|
||||
g_free(work);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
auto_gchar gchar* meta_content = g_strndup(bracket_start + 1, bracket_end - bracket_start - 1);
|
||||
char** parts = ff_split_meta(meta_content);
|
||||
if (parts) {
|
||||
_ff_parse_meta_parts(parts, result);
|
||||
g_strfreev(parts);
|
||||
}
|
||||
|
||||
// Parse sender: message after '] '
|
||||
const char* after_meta = bracket_end + 1;
|
||||
if (*after_meta == ' ')
|
||||
after_meta++;
|
||||
|
||||
// Find first *unescaped* ': ' which separates sender from message.
|
||||
const char* colon = ff_find_unescaped_colonspace(after_meta);
|
||||
if (colon) {
|
||||
auto_gchar gchar* raw_sender = g_strndup(after_meta, colon - after_meta);
|
||||
|
||||
// Split sender into jid/resource, then unescape resource
|
||||
char* slash = strchr(raw_sender, '/');
|
||||
if (slash) {
|
||||
result->from_jid = g_strndup(raw_sender, slash - raw_sender);
|
||||
result->from_resource = ff_unescape_sender_resource(slash + 1);
|
||||
} else {
|
||||
result->from_jid = g_strdup(raw_sender);
|
||||
}
|
||||
|
||||
result->message = ff_unescape_message(colon + 2);
|
||||
} else {
|
||||
// No ': ' found, treat entire rest as message with unknown sender
|
||||
result->from_jid = g_strdup("unknown");
|
||||
result->message = ff_unescape_message(after_meta);
|
||||
}
|
||||
} else if (first_space) {
|
||||
// Legacy/simple format without metadata: {timestamp} - {sender}: {msg}
|
||||
result->timestamp_str = g_strndup(work, first_space - work);
|
||||
result->type = g_strdup("chat");
|
||||
result->enc = g_strdup("none");
|
||||
|
||||
char* rest = first_space + 1;
|
||||
// Skip " - " if present (chatlog.c format)
|
||||
if (g_str_has_prefix(rest, "- ")) {
|
||||
rest += 2;
|
||||
}
|
||||
|
||||
char* colon = strstr(rest, ": ");
|
||||
if (colon) {
|
||||
char* sender = g_strndup(rest, colon - rest);
|
||||
char* slash = strchr(sender, '/');
|
||||
if (slash) {
|
||||
result->from_jid = g_strndup(sender, slash - sender);
|
||||
result->from_resource = g_strdup(slash + 1);
|
||||
} else {
|
||||
result->from_jid = g_strdup(sender);
|
||||
}
|
||||
g_free(sender);
|
||||
result->message = ff_unescape_message(colon + 2);
|
||||
} else {
|
||||
result->from_jid = g_strdup("unknown");
|
||||
result->message = ff_unescape_message(rest);
|
||||
}
|
||||
} else {
|
||||
// No space at all — can't parse
|
||||
ff_parsed_line_free(result);
|
||||
g_free(work);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Parse timestamp
|
||||
result->timestamp = g_date_time_new_from_iso8601(result->timestamp_str, NULL);
|
||||
if (!result->timestamp) {
|
||||
log_warning("flatfile: unparsable timestamp: %s", result->timestamp_str);
|
||||
ff_parsed_line_free(result);
|
||||
g_free(work);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Default type/enc if missing
|
||||
if (!result->type)
|
||||
result->type = g_strdup("chat");
|
||||
if (!result->enc)
|
||||
result->enc = g_strdup("none");
|
||||
|
||||
g_free(work);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Convert parsed line to ProfMessage
|
||||
ProfMessage*
|
||||
ff_parsed_to_profmessage(ff_parsed_line_t* pl)
|
||||
{
|
||||
ProfMessage* msg = message_init();
|
||||
msg->id = pl->stanza_id ? g_strdup(pl->stanza_id) : NULL;
|
||||
msg->from_jid = jid_create_from_bare_and_resource(pl->from_jid, pl->from_resource);
|
||||
if (pl->to_jid) {
|
||||
msg->to_jid = jid_create_from_bare_and_resource(pl->to_jid, pl->to_resource);
|
||||
}
|
||||
msg->plain = g_strdup(pl->message ? pl->message : "");
|
||||
msg->timestamp = g_date_time_ref(pl->timestamp);
|
||||
msg->type = ff_get_message_type_type(pl->type);
|
||||
msg->enc = ff_get_message_enc_type(pl->enc);
|
||||
return msg;
|
||||
}
|
||||
360
src/database_flatfile_verify.c
Normal file
360
src/database_flatfile_verify.c
Normal file
@@ -0,0 +1,360 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// This file is part of CProof.
|
||||
// See LICENSE for the full GPLv3 text and the special OpenSSL linking exception.
|
||||
|
||||
/*
|
||||
* database_flatfile_verify.c
|
||||
* vim: expandtab:ts=4:sts=4:sw=4
|
||||
*
|
||||
* Flat-file backend: integrity verification (/history verify).
|
||||
*
|
||||
* High-level flow (see ff_verify_integrity):
|
||||
* 1. Resolve target contact directories — either a single contact (when
|
||||
* contact_barejid != NULL) or every contact directory under
|
||||
* flatlog/<account>/.
|
||||
* 2. For each contact's history.log:
|
||||
* - Check file permissions (warn if not 0600).
|
||||
* - First pass: line-by-line UTF-8 / control-char / parser /
|
||||
* duplicate-id / timestamp-ordering checks. Records every
|
||||
* stanza-id and archive-id seen so the second pass can resolve
|
||||
* LMC references. stanza-id and archive-id are tracked in
|
||||
* *separate* hash tables — duplicate detection is reported
|
||||
* per id-kind to avoid spurious cross-kind warnings.
|
||||
* - Second pass: re-read the file and verify each `corrects:` link
|
||||
* points at a stanza-id that was seen in pass 1.
|
||||
* 3. Aggregate every issue into a GSList<integrity_issue_t> and return
|
||||
* it to the caller, ready for /history verify rendering.
|
||||
*
|
||||
* The file is split into small helpers so each pass can be understood in
|
||||
* isolation; ff_verify_integrity itself is just orchestration.
|
||||
*/
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <sys/stat.h>
|
||||
#include <glib.h>
|
||||
#include <glib/gstdio.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "log.h"
|
||||
#include "config/files.h"
|
||||
#include "database_flatfile.h"
|
||||
|
||||
// =========================================================================
|
||||
// Issue construction
|
||||
// =========================================================================
|
||||
|
||||
static integrity_issue_t*
|
||||
_issue_new(integrity_level_t level, const char* file, int line, char* message)
|
||||
{
|
||||
integrity_issue_t* issue = g_new0(integrity_issue_t, 1);
|
||||
issue->level = level;
|
||||
issue->file = g_strdup(file ? file : "");
|
||||
issue->line = line;
|
||||
issue->message = message; // takes ownership of the (g_strdup'd) message
|
||||
return issue;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Contact discovery
|
||||
// =========================================================================
|
||||
|
||||
// Build the list of contact directories to verify. If contact_barejid is
|
||||
// supplied, the list is either a singleton or empty (and an INFO issue is
|
||||
// appended explaining no logs exist for that contact). Otherwise, every
|
||||
// subdirectory of flatlog/<account>/ is enumerated.
|
||||
static GSList*
|
||||
_collect_contact_dirs(const gchar* const contact_barejid, GSList** issues)
|
||||
{
|
||||
GSList* contact_dirs = NULL;
|
||||
|
||||
if (contact_barejid) {
|
||||
auto_gchar gchar* cdir = ff_get_contact_dir(contact_barejid);
|
||||
if (cdir && g_file_test(cdir, G_FILE_TEST_IS_DIR)) {
|
||||
contact_dirs = g_slist_prepend(contact_dirs, g_strdup(cdir));
|
||||
} else {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_INFO, contact_barejid, 0,
|
||||
g_strdup("No log files found for this contact")));
|
||||
}
|
||||
return contact_dirs;
|
||||
}
|
||||
|
||||
auto_gchar gchar* data_path = files_get_data_path(DIR_FLATLOG);
|
||||
auto_gchar gchar* my_dir = ff_jid_to_dir(g_flatfile_account_jid);
|
||||
auto_gchar gchar* base_dir = g_strdup_printf("%s/%s", data_path, my_dir);
|
||||
|
||||
GDir* dir = g_dir_open(base_dir, 0, NULL);
|
||||
if (!dir)
|
||||
return NULL;
|
||||
|
||||
const gchar* dname;
|
||||
while ((dname = g_dir_read_name(dir)) != NULL) {
|
||||
char* full = g_strdup_printf("%s/%s", base_dir, dname);
|
||||
if (g_file_test(full, G_FILE_TEST_IS_DIR)) {
|
||||
contact_dirs = g_slist_prepend(contact_dirs, full);
|
||||
} else {
|
||||
g_free(full);
|
||||
}
|
||||
}
|
||||
g_dir_close(dir);
|
||||
return contact_dirs;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Permission check
|
||||
// =========================================================================
|
||||
|
||||
static void
|
||||
_check_permissions(const char* filepath, const char* basename, GSList** issues)
|
||||
{
|
||||
struct stat st;
|
||||
if (g_stat(filepath, &st) != 0)
|
||||
return;
|
||||
if ((st.st_mode & 0777) == (S_IRUSR | S_IWUSR))
|
||||
return;
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_WARNING, basename, 0,
|
||||
g_strdup_printf("File permissions are %o, expected 600 (sensitive data)",
|
||||
st.st_mode & 0777)));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// First pass: per-line content checks + id collection
|
||||
// =========================================================================
|
||||
//
|
||||
// For every non-empty, non-comment line:
|
||||
// - validate UTF-8
|
||||
// - flag control characters
|
||||
// - try to parse via ff_parse_line(); if that fails, record the lineno
|
||||
// - check timestamp monotonicity
|
||||
// - flag duplicate stanza-id and duplicate archive-id (separate tables)
|
||||
// - record every stanza-id encountered for LMC pass 2
|
||||
//
|
||||
// Also detects: BOM (informational), CRLF line endings (warning), empty
|
||||
// file (informational).
|
||||
|
||||
static void
|
||||
_first_pass(FILE* fp, const char* basename,
|
||||
GHashTable* seen_stanza_ids, GHashTable* seen_archive_ids,
|
||||
GHashTable* all_stanza_ids, GSList** issues)
|
||||
{
|
||||
if (ff_skip_bom(fp)) {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_INFO, basename, 0,
|
||||
g_strdup("File has UTF-8 BOM — harmless but unnecessary")));
|
||||
}
|
||||
|
||||
char* buf = NULL;
|
||||
int lineno = 0;
|
||||
GDateTime* prev_ts = NULL;
|
||||
gboolean has_crlf = FALSE;
|
||||
gboolean is_empty = TRUE;
|
||||
|
||||
while ((buf = ff_readline(fp, NULL)) != NULL) {
|
||||
lineno++;
|
||||
gsize len = strlen(buf);
|
||||
|
||||
if (len > 0 && buf[len - 1] == '\r') {
|
||||
has_crlf = TRUE;
|
||||
buf[--len] = '\0';
|
||||
}
|
||||
|
||||
if (len == 0 || buf[0] == '#') {
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
is_empty = FALSE;
|
||||
|
||||
const gchar* end;
|
||||
if (!g_utf8_validate(buf, -1, &end)) {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_ERROR, basename, lineno,
|
||||
g_strdup_printf("Invalid UTF-8 at byte offset %ld", (long)(end - buf))));
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (gsize i = 0; i < len; i++) {
|
||||
unsigned char ch = (unsigned char)buf[i];
|
||||
if (ch < 0x20 && ch != '\t') {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_WARNING, basename, lineno,
|
||||
g_strdup_printf("Contains control character 0x%02x", ch)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ff_parsed_line_t* pl = ff_parse_line(buf);
|
||||
if (!pl) {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_ERROR, basename, lineno,
|
||||
g_strdup("Unparsable line")));
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
free(buf);
|
||||
|
||||
if (prev_ts && g_date_time_compare(pl->timestamp, prev_ts) < 0) {
|
||||
auto_gchar gchar* ts_cur = g_date_time_format_iso8601(pl->timestamp);
|
||||
auto_gchar gchar* ts_prev = g_date_time_format_iso8601(prev_ts);
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_WARNING, basename, lineno,
|
||||
g_strdup_printf("Timestamp out of order (%s after %s)", ts_cur, ts_prev)));
|
||||
}
|
||||
if (prev_ts)
|
||||
g_date_time_unref(prev_ts);
|
||||
prev_ts = g_date_time_ref(pl->timestamp);
|
||||
|
||||
if (pl->stanza_id && pl->stanza_id[0] != '\0') {
|
||||
if (g_hash_table_contains(seen_stanza_ids, pl->stanza_id)) {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_WARNING, basename, lineno,
|
||||
g_strdup_printf("Duplicate stanza-id \"%s\"", pl->stanza_id)));
|
||||
} else {
|
||||
g_hash_table_insert(seen_stanza_ids, g_strdup(pl->stanza_id), GINT_TO_POINTER(lineno));
|
||||
}
|
||||
g_hash_table_insert(all_stanza_ids, g_strdup(pl->stanza_id), GINT_TO_POINTER(lineno));
|
||||
}
|
||||
if (pl->archive_id && pl->archive_id[0] != '\0') {
|
||||
if (g_hash_table_contains(seen_archive_ids, pl->archive_id)) {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_WARNING, basename, lineno,
|
||||
g_strdup_printf("Duplicate archive-id \"%s\"", pl->archive_id)));
|
||||
} else {
|
||||
g_hash_table_insert(seen_archive_ids, g_strdup(pl->archive_id), GINT_TO_POINTER(lineno));
|
||||
}
|
||||
}
|
||||
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
|
||||
if (prev_ts)
|
||||
g_date_time_unref(prev_ts);
|
||||
|
||||
if (has_crlf) {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_WARNING, basename, 0,
|
||||
g_strdup("File uses Windows line endings (CRLF) — consider converting to LF")));
|
||||
}
|
||||
if (is_empty) {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_INFO, basename, 0,
|
||||
g_strdup("File is empty (no message lines)")));
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Second pass: LMC reference resolution
|
||||
// =========================================================================
|
||||
//
|
||||
// Walks the file again, and for every line that carries a `corrects:` link,
|
||||
// checks that the referenced stanza-id was seen in pass 1. Broken references
|
||||
// surface as INTEGRITY_ERROR.
|
||||
|
||||
static void
|
||||
_lmc_pass(FILE* fp, const char* basename, GHashTable* all_stanza_ids, GSList** issues)
|
||||
{
|
||||
ff_skip_bom(fp);
|
||||
|
||||
char* buf = NULL;
|
||||
int lineno = 0;
|
||||
|
||||
while ((buf = ff_readline(fp, NULL)) != NULL) {
|
||||
lineno++;
|
||||
gsize len = strlen(buf);
|
||||
if (len > 0 && buf[len - 1] == '\r')
|
||||
buf[--len] = '\0';
|
||||
if (len == 0 || buf[0] == '#') {
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
|
||||
ff_parsed_line_t* pl = ff_parse_line(buf);
|
||||
free(buf);
|
||||
if (!pl)
|
||||
continue;
|
||||
|
||||
if (pl->replace_id && pl->replace_id[0] != '\0'
|
||||
&& !g_hash_table_contains(all_stanza_ids, pl->replace_id)) {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_ERROR, basename, lineno,
|
||||
g_strdup_printf("Broken correction reference: corrects:%s not found",
|
||||
pl->replace_id)));
|
||||
}
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Per-contact verification
|
||||
// =========================================================================
|
||||
|
||||
static void
|
||||
_verify_contact_dir(const char* cdir_path, GSList** issues)
|
||||
{
|
||||
auto_gchar gchar* filepath = g_strdup_printf("%s/history.log", cdir_path);
|
||||
if (!g_file_test(filepath, G_FILE_TEST_EXISTS)) {
|
||||
log_debug("flatfile verify: skipping %s (no history.log)", cdir_path);
|
||||
return;
|
||||
}
|
||||
|
||||
const char* basename = "history.log";
|
||||
|
||||
_check_permissions(filepath, basename, issues);
|
||||
|
||||
FILE* fp = fopen(filepath, "r");
|
||||
if (!fp) {
|
||||
log_warning("flatfile verify: cannot open %s for reading", filepath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Separate tables by id-kind: a stanza-id and an archive-id can legitimately
|
||||
// share the same string without that being a duplicate.
|
||||
GHashTable* seen_stanza_ids = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
|
||||
GHashTable* seen_archive_ids = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
|
||||
GHashTable* all_stanza_ids = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
|
||||
|
||||
_first_pass(fp, basename, seen_stanza_ids, seen_archive_ids, all_stanza_ids, issues);
|
||||
fclose(fp);
|
||||
|
||||
// Second pass: LMC references
|
||||
fp = fopen(filepath, "r");
|
||||
if (fp) {
|
||||
_lmc_pass(fp, basename, all_stanza_ids, issues);
|
||||
fclose(fp);
|
||||
}
|
||||
|
||||
g_hash_table_destroy(seen_stanza_ids);
|
||||
g_hash_table_destroy(seen_archive_ids);
|
||||
g_hash_table_destroy(all_stanza_ids);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Public entry point
|
||||
// =========================================================================
|
||||
|
||||
GSList*
|
||||
ff_verify_integrity(const gchar* const contact_barejid)
|
||||
{
|
||||
GSList* issues = NULL;
|
||||
|
||||
if (!g_flatfile_account_jid) {
|
||||
issues = g_slist_prepend(issues,
|
||||
_issue_new(INTEGRITY_ERROR, "N/A", 0,
|
||||
g_strdup("Flat-file backend not initialized")));
|
||||
return issues;
|
||||
}
|
||||
|
||||
GSList* contact_dirs = _collect_contact_dirs(contact_barejid, &issues);
|
||||
|
||||
for (GSList* cd = contact_dirs; cd; cd = cd->next) {
|
||||
_verify_contact_dir((const char*)cd->data, &issues);
|
||||
}
|
||||
|
||||
g_slist_free_full(contact_dirs, g_free);
|
||||
return g_slist_reverse(issues);
|
||||
}
|
||||
996
src/database_sqlite.c
Normal file
996
src/database_sqlite.c
Normal file
@@ -0,0 +1,996 @@
|
||||
/*
|
||||
* database_sqlite.c
|
||||
* vim: expandtab:ts=4:sts=4:sw=4
|
||||
*
|
||||
* Copyright (C) 2020 - 2025 Michael Vetter <jubalh@iodoru.org>
|
||||
*
|
||||
* This file is part of Profanity.
|
||||
*
|
||||
* Profanity is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Profanity is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Profanity. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* In addition, as a special exception, the copyright holders give permission to
|
||||
* link the code of portions of this program with the OpenSSL library under
|
||||
* certain conditions as described in each individual source file, and
|
||||
* distribute linked combinations including the two.
|
||||
*
|
||||
* You must obey the GNU General Public License in all respects for all of the
|
||||
* code used other than OpenSSL. If you modify file(s) with this exception, you
|
||||
* may extend this exception to your version of the file(s), but you are not
|
||||
* obligated to do so. If you do not wish to do so, delete this exception
|
||||
* statement from your version. If you delete this exception statement from all
|
||||
* source files in the program, then also delete it here.
|
||||
*
|
||||
*/
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <sys/stat.h>
|
||||
#include <sys/statvfs.h>
|
||||
#include <sqlite3.h>
|
||||
#include <glib.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
|
||||
#include "log.h"
|
||||
#include "common.h"
|
||||
#include "config/files.h"
|
||||
#include "database.h"
|
||||
#include "config/preferences.h"
|
||||
#include "ui/ui.h"
|
||||
#include "xmpp/xmpp.h"
|
||||
#include "xmpp/message.h"
|
||||
|
||||
static sqlite3* g_chatlog_database;
|
||||
|
||||
static void _add_to_db(ProfMessage* message, const char* type, const Jid* const from_jid, const Jid* const to_jid);
|
||||
static char* _get_db_filename(ProfAccount* account);
|
||||
static prof_msg_type_t _get_message_type_type(const char* const type);
|
||||
static prof_enc_t _get_message_enc_type(const char* const encstr);
|
||||
static int _get_db_version(void);
|
||||
static gboolean _migrate_to_v2(void);
|
||||
static gboolean _check_available_space_for_db_migration(char* path_to_db);
|
||||
|
||||
static const int latest_version = 2;
|
||||
|
||||
gboolean
|
||||
db_sqlite_is_open(void)
|
||||
{
|
||||
return g_chatlog_database != NULL;
|
||||
}
|
||||
|
||||
// Helper: close DB handle (if any), warn on busy, and shutdown SQLite
|
||||
static void
|
||||
_db_teardown(const char* ctx)
|
||||
{
|
||||
if (g_chatlog_database) {
|
||||
int rc = sqlite3_close_v2(g_chatlog_database);
|
||||
if (rc != SQLITE_OK) {
|
||||
log_warning("sqlite3_close_v2 in %s returned %d; database may still have active statements.",
|
||||
ctx ? ctx : "db_teardown", rc);
|
||||
}
|
||||
g_chatlog_database = NULL;
|
||||
}
|
||||
sqlite3_shutdown();
|
||||
}
|
||||
|
||||
// Helper: prepare a statement and log a contextual error on failure
|
||||
static gboolean
|
||||
_db_prepare_ctx(const char* query, sqlite3_stmt** stmt, const char* ctx)
|
||||
{
|
||||
int rc = sqlite3_prepare_v2(g_chatlog_database, query, -1, stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
log_error("SQLite error in %s: (error code: %d) %s",
|
||||
ctx ? ctx : "sqlite3_prepare_v2",
|
||||
rc,
|
||||
sqlite3_errmsg(g_chatlog_database));
|
||||
return FALSE;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static char*
|
||||
_db_strdup(const char* str)
|
||||
{
|
||||
return str ? strdup(str) : NULL;
|
||||
}
|
||||
|
||||
#define auto_sqlite __attribute__((__cleanup__(auto_free_sqlite)))
|
||||
|
||||
static void
|
||||
auto_free_sqlite(gchar** str)
|
||||
{
|
||||
if (str == NULL)
|
||||
return;
|
||||
sqlite3_free(*str);
|
||||
}
|
||||
|
||||
static char*
|
||||
_get_db_filename(ProfAccount* account)
|
||||
{
|
||||
return files_file_in_account_data_path(DIR_DATABASE, account->jid, "chatlog.db");
|
||||
}
|
||||
|
||||
static gboolean
|
||||
_sqlite_init(ProfAccount* account)
|
||||
{
|
||||
int ret = sqlite3_initialize();
|
||||
if (ret != SQLITE_OK) {
|
||||
log_error("Error initializing SQLite database: %d", ret);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
auto_char char* filename = _get_db_filename(account);
|
||||
if (!filename) {
|
||||
log_error("Error initializing SQLite database: could not derive chatlog.db path for account '%s'.",
|
||||
account && account->jid ? account->jid : "(unknown)");
|
||||
sqlite3_shutdown();
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
ret = sqlite3_open(filename, &g_chatlog_database);
|
||||
if (ret != SQLITE_OK) {
|
||||
const char* err_msg = g_chatlog_database ? sqlite3_errmsg(g_chatlog_database) : "(no handle)";
|
||||
log_error("Error opening SQLite database: %s", err_msg);
|
||||
_db_teardown("_sqlite_init(open)");
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
char* err_msg = NULL;
|
||||
|
||||
int db_version = _get_db_version();
|
||||
if (db_version == latest_version) {
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// ChatLogs schema overview:
|
||||
// id AUTOINCREMENT primary key. Used internally as the
|
||||
// anchor for LMC (Last Message Correction) chains.
|
||||
// from_jid/to_jid Bare JIDs (NOT NULL). Used for window resolution.
|
||||
// from_resource Resource of the sender (NULLable for legacy rows).
|
||||
// to_resource Resource of the recipient (NULLable).
|
||||
// message Plaintext after decryption / format normalisation.
|
||||
// timestamp ISO-8601 (UTC). Indexed for range queries.
|
||||
// type "chat" | "muc" | "mucpm" (mirrors prof_msg_type_t).
|
||||
// stanza_id XEP-0359 unique-and-stable id, used for dedup +
|
||||
// LMC sender lookup.
|
||||
// archive_id MAM (XEP-0313) archive id; dedupes rebroadcast.
|
||||
// encryption "none" | "omemo" | "otr" | "pgp" | "ox"
|
||||
// (mirrors prof_enc_t).
|
||||
// marked_read 0/1 — for unread-message badge bookkeeping.
|
||||
// replace_id XEP-0308 message-correction stanza-id of the
|
||||
// original message this row replaces.
|
||||
// replaces_db_id Local FK back-link: id of the row this row
|
||||
// corrects. Set on insert when replace_id resolves.
|
||||
// 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).
|
||||
const char* query = "CREATE TABLE IF NOT EXISTS `ChatLogs` ("
|
||||
"`id` INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||
"`from_jid` TEXT NOT NULL, "
|
||||
"`to_jid` TEXT NOT NULL, "
|
||||
"`from_resource` TEXT, "
|
||||
"`to_resource` TEXT, "
|
||||
"`message` TEXT, "
|
||||
"`timestamp` TEXT, "
|
||||
"`type` TEXT, "
|
||||
"`stanza_id` TEXT, "
|
||||
"`archive_id` TEXT, "
|
||||
"`encryption` TEXT, "
|
||||
"`marked_read` INTEGER, "
|
||||
"`replace_id` TEXT, "
|
||||
"`replaces_db_id` INTEGER, "
|
||||
"`replaced_by_db_id` INTEGER)";
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
|
||||
goto out;
|
||||
}
|
||||
|
||||
query = "CREATE TRIGGER IF NOT EXISTS update_corrected_message "
|
||||
"AFTER INSERT ON ChatLogs "
|
||||
"FOR EACH ROW "
|
||||
"WHEN NEW.replaces_db_id IS NOT NULL "
|
||||
"BEGIN "
|
||||
"UPDATE ChatLogs "
|
||||
"SET replaced_by_db_id = NEW.id "
|
||||
"WHERE id = NEW.replaces_db_id; "
|
||||
"END;";
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
|
||||
log_error("Unable to add `update_corrected_message` trigger.");
|
||||
goto out;
|
||||
}
|
||||
|
||||
query = "CREATE INDEX IF NOT EXISTS ChatLogs_timestamp_IDX ON `ChatLogs` (`timestamp`)";
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
|
||||
log_error("Unable to create index for timestamp.");
|
||||
goto out;
|
||||
}
|
||||
query = "CREATE INDEX IF NOT EXISTS ChatLogs_to_from_jid_IDX ON `ChatLogs` (`to_jid`, `from_jid`)";
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
|
||||
log_error("Unable to create index for to_jid.");
|
||||
goto out;
|
||||
}
|
||||
|
||||
query = "CREATE TABLE IF NOT EXISTS `DbVersion` (`dv_id` INTEGER PRIMARY KEY, `version` INTEGER UNIQUE)";
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
|
||||
goto out;
|
||||
}
|
||||
|
||||
if (db_version == -1) {
|
||||
query = "INSERT OR IGNORE INTO `DbVersion` (`version`) VALUES ('2')";
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
|
||||
goto out;
|
||||
}
|
||||
db_version = _get_db_version();
|
||||
}
|
||||
|
||||
if (db_version == -1) {
|
||||
cons_show_error("DB Initialization Error: Unable to check DB version.");
|
||||
goto out;
|
||||
}
|
||||
|
||||
if (db_version < latest_version) {
|
||||
cons_show("Migrating database schema. This operation may take a while...");
|
||||
if (db_version < 2 && (!_check_available_space_for_db_migration(filename) || !_migrate_to_v2())) {
|
||||
cons_show_error("Database Initialization Error: Unable to migrate database to version 2. Please, check error logs for details.");
|
||||
goto out;
|
||||
}
|
||||
cons_show("Database schema migration was successful.");
|
||||
}
|
||||
|
||||
log_debug("Initialized SQLite database: %s", filename);
|
||||
return TRUE;
|
||||
|
||||
out:
|
||||
if (err_msg) {
|
||||
log_error("SQLite error in _sqlite_init(): %s", err_msg);
|
||||
sqlite3_free(err_msg);
|
||||
} else {
|
||||
log_error("Unknown SQLite error in _sqlite_init().");
|
||||
}
|
||||
_db_teardown("_sqlite_init(out)");
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
static void
|
||||
_sqlite_close(void)
|
||||
{
|
||||
log_debug("_sqlite_close() called");
|
||||
_db_teardown("_sqlite_close");
|
||||
}
|
||||
|
||||
static void
|
||||
_sqlite_add_incoming(ProfMessage* message)
|
||||
{
|
||||
if (message->to_jid) {
|
||||
_add_to_db(message, NULL, message->from_jid, message->to_jid);
|
||||
} else {
|
||||
_add_to_db(message, NULL, message->from_jid, connection_get_jid());
|
||||
}
|
||||
}
|
||||
|
||||
static void
|
||||
_log_database_add_outgoing(const char* type, const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc)
|
||||
{
|
||||
ProfMessage* msg = message_init();
|
||||
|
||||
msg->id = _db_strdup(id);
|
||||
msg->from_jid = jid_create(barejid);
|
||||
msg->plain = _db_strdup(message);
|
||||
msg->replace_id = _db_strdup(replace_id);
|
||||
msg->timestamp = g_date_time_new_now_local();
|
||||
msg->enc = enc;
|
||||
|
||||
_add_to_db(msg, type, connection_get_jid(), msg->from_jid);
|
||||
|
||||
message_free(msg);
|
||||
}
|
||||
|
||||
static void
|
||||
_sqlite_add_outgoing_chat(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc)
|
||||
{
|
||||
_log_database_add_outgoing("chat", id, barejid, message, replace_id, enc);
|
||||
}
|
||||
|
||||
static void
|
||||
_sqlite_add_outgoing_muc(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc)
|
||||
{
|
||||
_log_database_add_outgoing("muc", id, barejid, message, replace_id, enc);
|
||||
}
|
||||
|
||||
static void
|
||||
_sqlite_add_outgoing_muc_pm(const char* const id, const char* const barejid, const char* const message, const char* const replace_id, prof_enc_t enc)
|
||||
{
|
||||
_log_database_add_outgoing("mucpm", id, barejid, message, replace_id, enc);
|
||||
}
|
||||
|
||||
static ProfMessage*
|
||||
_sqlite_get_limits_info(const gchar* const contact_barejid, gboolean is_last)
|
||||
{
|
||||
sqlite3_stmt* stmt = NULL;
|
||||
const Jid* myjid = connection_get_jid();
|
||||
ProfMessage* msg = message_init();
|
||||
if (!myjid || !myjid->str) {
|
||||
if (is_last) {
|
||||
msg->timestamp = g_date_time_new_now_utc();
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
const char* order = is_last ? "DESC" : "ASC";
|
||||
auto_sqlite char* query = sqlite3_mprintf("SELECT `archive_id`, `timestamp` FROM `ChatLogs` WHERE "
|
||||
"(`from_jid` = %Q AND `to_jid` = %Q) OR "
|
||||
"(`from_jid` = %Q AND `to_jid` = %Q) "
|
||||
"ORDER BY `timestamp` %s LIMIT 1;",
|
||||
contact_barejid, myjid->barejid, myjid->barejid, contact_barejid, order);
|
||||
|
||||
if (!query) {
|
||||
log_error("Could not allocate memory for SQL query in _sqlite_get_limits_info()");
|
||||
if (is_last) {
|
||||
msg->timestamp = g_date_time_new_now_utc();
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
if (!_db_prepare_ctx(query, &stmt, "_sqlite_get_limits_info()")) {
|
||||
if (is_last) {
|
||||
msg->timestamp = g_date_time_new_now_utc();
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
char* archive_id = (char*)sqlite3_column_text(stmt, 0);
|
||||
char* date = (char*)sqlite3_column_text(stmt, 1);
|
||||
|
||||
msg->stanzaid = _db_strdup(archive_id);
|
||||
msg->timestamp = g_date_time_new_from_iso8601(date, NULL);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
if (!msg->timestamp && is_last) {
|
||||
msg->timestamp = g_date_time_new_now_utc();
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
static db_history_result_t
|
||||
_sqlite_get_previous_chat(const gchar* const contact_barejid, const gchar* start_time, const gchar* end_time, gboolean from_start, gboolean flip, GSList** result)
|
||||
{
|
||||
if (!g_chatlog_database) {
|
||||
log_warning("_sqlite_get_previous_chat() called but db is not initialized");
|
||||
return DB_RESPONSE_ERROR;
|
||||
}
|
||||
sqlite3_stmt* stmt = NULL;
|
||||
const Jid* myjid = connection_get_jid();
|
||||
if (!myjid->str) {
|
||||
log_warning("_sqlite_get_previous_chat() called but no connection detected.");
|
||||
return DB_RESPONSE_ERROR;
|
||||
}
|
||||
|
||||
const gchar* sort1 = from_start ? "ASC" : "DESC";
|
||||
const gchar* sort2 = !flip ? "ASC" : "DESC";
|
||||
GDateTime* now = g_date_time_new_now_local();
|
||||
auto_gchar gchar* end_date_fmt = end_time ? g_strdup(end_time) : g_date_time_format_iso8601(now);
|
||||
auto_sqlite gchar* query = sqlite3_mprintf("SELECT * FROM ("
|
||||
"SELECT COALESCE(B.`message`, A.`message`) AS message, "
|
||||
"A.`timestamp`, A.`from_jid`, A.`from_resource`, A.`to_jid`, A.`to_resource`, A.`type`, A.`encryption`, A.`stanza_id` FROM `ChatLogs` AS A "
|
||||
"LEFT JOIN `ChatLogs` AS B ON (A.`replaced_by_db_id` = B.`id` AND A.`from_jid` = B.`from_jid`) "
|
||||
"WHERE (A.`replaces_db_id` IS NULL) "
|
||||
"AND ((A.`from_jid` = %Q AND A.`to_jid` = %Q) OR (A.`from_jid` = %Q AND A.`to_jid` = %Q)) "
|
||||
"AND A.`timestamp` < %Q "
|
||||
"AND (%Q IS NULL OR A.`timestamp` > %Q) "
|
||||
"ORDER BY A.`timestamp` %s LIMIT %d) "
|
||||
"ORDER BY `timestamp` %s;",
|
||||
contact_barejid, myjid->barejid, myjid->barejid, contact_barejid, end_date_fmt, start_time, start_time, sort1, MESSAGES_TO_RETRIEVE, sort2);
|
||||
|
||||
g_date_time_unref(now);
|
||||
|
||||
if (!query) {
|
||||
log_error("Could not allocate memory.");
|
||||
return DB_RESPONSE_ERROR;
|
||||
}
|
||||
|
||||
if (!_db_prepare_ctx(query, &stmt, "_sqlite_get_previous_chat()")) {
|
||||
return DB_RESPONSE_ERROR;
|
||||
}
|
||||
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
char* message = (char*)sqlite3_column_text(stmt, 0);
|
||||
char* date = (char*)sqlite3_column_text(stmt, 1);
|
||||
char* from_jid = (char*)sqlite3_column_text(stmt, 2);
|
||||
char* from_resource = (char*)sqlite3_column_text(stmt, 3);
|
||||
char* to_jid = (char*)sqlite3_column_text(stmt, 4);
|
||||
char* to_resource = (char*)sqlite3_column_text(stmt, 5);
|
||||
char* type = (char*)sqlite3_column_text(stmt, 6);
|
||||
char* encryption = (char*)sqlite3_column_text(stmt, 7);
|
||||
char* id = (char*)sqlite3_column_text(stmt, 8);
|
||||
|
||||
ProfMessage* msg = message_init();
|
||||
msg->id = id ? strdup(id) : NULL;
|
||||
msg->from_jid = jid_create_from_bare_and_resource(from_jid, from_resource);
|
||||
msg->to_jid = jid_create_from_bare_and_resource(to_jid, to_resource);
|
||||
msg->plain = strdup(message ?: "");
|
||||
msg->timestamp = g_date_time_new_from_iso8601(date, NULL);
|
||||
msg->type = _get_message_type_type(type);
|
||||
msg->enc = _get_message_enc_type(encryption);
|
||||
|
||||
*result = g_slist_append(*result, msg);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
return g_slist_length(*result) != 0 ? DB_RESPONSE_SUCCESS : DB_RESPONSE_EMPTY;
|
||||
}
|
||||
|
||||
static GSList*
|
||||
_sqlite_verify_integrity(const gchar* const contact_barejid)
|
||||
{
|
||||
GSList* issues = NULL;
|
||||
|
||||
if (!g_chatlog_database) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_ERROR;
|
||||
issue->file = g_strdup("chatlog.db");
|
||||
issue->line = 0;
|
||||
issue->message = g_strdup("Database not initialized");
|
||||
issues = g_slist_append(issues, issue);
|
||||
return issues;
|
||||
}
|
||||
|
||||
// PRAGMA integrity_check
|
||||
sqlite3_stmt* stmt = NULL;
|
||||
if (_db_prepare_ctx("PRAGMA integrity_check", &stmt, "_sqlite_verify_integrity()")) {
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const char* result = (const char*)sqlite3_column_text(stmt, 0);
|
||||
if (g_strcmp0(result, "ok") != 0) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_ERROR;
|
||||
issue->file = g_strdup("chatlog.db");
|
||||
issue->line = 0;
|
||||
issue->message = g_strdup_printf("SQLite integrity check: %s", result);
|
||||
issues = g_slist_append(issues, issue);
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
// Check timestamp ordering for a specific contact or all
|
||||
const Jid* myjid = connection_get_jid();
|
||||
if (myjid && myjid->barejid) {
|
||||
auto_sqlite char* query = NULL;
|
||||
if (contact_barejid) {
|
||||
query = sqlite3_mprintf(
|
||||
"SELECT A.`id`, A.`timestamp`, B.`id`, B.`timestamp` FROM `ChatLogs` A "
|
||||
"JOIN `ChatLogs` B ON B.`id` = A.`id` + 1 "
|
||||
"WHERE A.`timestamp` > B.`timestamp` "
|
||||
"AND ((A.`from_jid` = %Q AND A.`to_jid` = %Q) OR (A.`from_jid` = %Q AND A.`to_jid` = %Q)) "
|
||||
"LIMIT 50",
|
||||
contact_barejid, myjid->barejid, myjid->barejid, contact_barejid);
|
||||
} else {
|
||||
query = sqlite3_mprintf(
|
||||
"SELECT A.`id`, A.`timestamp`, B.`id`, B.`timestamp` FROM `ChatLogs` A "
|
||||
"JOIN `ChatLogs` B ON B.`id` = A.`id` + 1 "
|
||||
"WHERE A.`timestamp` > B.`timestamp` "
|
||||
"LIMIT 50");
|
||||
}
|
||||
|
||||
if (query && _db_prepare_ctx(query, &stmt, "_sqlite_verify_integrity(timestamp_order)")) {
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
int id_a = sqlite3_column_int(stmt, 0);
|
||||
const char* ts_a = (const char*)sqlite3_column_text(stmt, 1);
|
||||
int id_b = sqlite3_column_int(stmt, 2);
|
||||
const char* ts_b = (const char*)sqlite3_column_text(stmt, 3);
|
||||
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_WARNING;
|
||||
issue->file = g_strdup("chatlog.db");
|
||||
issue->line = id_a;
|
||||
issue->message = g_strdup_printf("Timestamp out of order: row %d (%s) > row %d (%s)",
|
||||
id_a, ts_a ? ts_a : "NULL", id_b, ts_b ? ts_b : "NULL");
|
||||
issues = g_slist_append(issues, issue);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
// Check broken LMC references
|
||||
auto_sqlite char* lmc_query = sqlite3_mprintf(
|
||||
"SELECT A.`id`, A.`replaces_db_id` FROM `ChatLogs` A "
|
||||
"WHERE A.`replaces_db_id` IS NOT NULL "
|
||||
"AND NOT EXISTS (SELECT 1 FROM `ChatLogs` B WHERE B.`id` = A.`replaces_db_id`) "
|
||||
"LIMIT 50");
|
||||
|
||||
if (lmc_query && _db_prepare_ctx(lmc_query, &stmt, "_sqlite_verify_integrity(lmc_check)")) {
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
int id = sqlite3_column_int(stmt, 0);
|
||||
int replaces_id = sqlite3_column_int(stmt, 1);
|
||||
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_ERROR;
|
||||
issue->file = g_strdup("chatlog.db");
|
||||
issue->line = id;
|
||||
issue->message = g_strdup_printf("Broken LMC reference: row %d references non-existent row %d",
|
||||
id, replaces_id);
|
||||
issues = g_slist_append(issues, issue);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
// --- Type conversion helpers ---
|
||||
|
||||
static const char*
|
||||
_get_message_type_str(prof_msg_type_t type)
|
||||
{
|
||||
switch (type) {
|
||||
case PROF_MSG_TYPE_CHAT:
|
||||
return "chat";
|
||||
case PROF_MSG_TYPE_MUC:
|
||||
return "muc";
|
||||
case PROF_MSG_TYPE_MUCPM:
|
||||
return "mucpm";
|
||||
case PROF_MSG_TYPE_UNINITIALIZED:
|
||||
return NULL;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static prof_msg_type_t
|
||||
_get_message_type_type(const char* const type)
|
||||
{
|
||||
if (g_strcmp0(type, "chat") == 0) {
|
||||
return PROF_MSG_TYPE_CHAT;
|
||||
} else if (g_strcmp0(type, "muc") == 0) {
|
||||
return PROF_MSG_TYPE_MUC;
|
||||
} else if (g_strcmp0(type, "mucpm") == 0) {
|
||||
return PROF_MSG_TYPE_MUCPM;
|
||||
} else {
|
||||
return PROF_MSG_TYPE_UNINITIALIZED;
|
||||
}
|
||||
}
|
||||
|
||||
static const char*
|
||||
_get_message_enc_str(prof_enc_t enc)
|
||||
{
|
||||
switch (enc) {
|
||||
case PROF_MSG_ENC_OX:
|
||||
return "ox";
|
||||
case PROF_MSG_ENC_PGP:
|
||||
return "pgp";
|
||||
case PROF_MSG_ENC_OTR:
|
||||
return "otr";
|
||||
case PROF_MSG_ENC_OMEMO:
|
||||
return "omemo";
|
||||
case PROF_MSG_ENC_NONE:
|
||||
return "none";
|
||||
}
|
||||
|
||||
return "none";
|
||||
}
|
||||
|
||||
static prof_enc_t
|
||||
_get_message_enc_type(const char* const encstr)
|
||||
{
|
||||
if (g_strcmp0(encstr, "ox") == 0) {
|
||||
return PROF_MSG_ENC_OX;
|
||||
} else if (g_strcmp0(encstr, "pgp") == 0) {
|
||||
return PROF_MSG_ENC_PGP;
|
||||
} else if (g_strcmp0(encstr, "otr") == 0) {
|
||||
return PROF_MSG_ENC_OTR;
|
||||
} else if (g_strcmp0(encstr, "omemo") == 0) {
|
||||
return PROF_MSG_ENC_OMEMO;
|
||||
}
|
||||
|
||||
return PROF_MSG_ENC_NONE;
|
||||
}
|
||||
|
||||
// --- Core write logic ---
|
||||
|
||||
static void
|
||||
_add_to_db(ProfMessage* message, const char* type, const Jid* const from_jid, const Jid* const to_jid)
|
||||
{
|
||||
auto_gchar gchar* pref_dblog = prefs_get_string(PREF_DBLOG);
|
||||
sqlite_int64 original_message_id = -1;
|
||||
|
||||
if (g_strcmp0(pref_dblog, "off") == 0) {
|
||||
return;
|
||||
} else if (g_strcmp0(pref_dblog, "redact") == 0) {
|
||||
if (message->plain) {
|
||||
free(message->plain);
|
||||
}
|
||||
message->plain = strdup("[REDACTED]");
|
||||
}
|
||||
|
||||
if (!g_chatlog_database) {
|
||||
log_debug("_add_to_db() called but db is not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
char* err_msg;
|
||||
auto_gchar gchar* date_fmt = NULL;
|
||||
|
||||
if (message->timestamp) {
|
||||
date_fmt = g_date_time_format_iso8601(message->timestamp);
|
||||
} else {
|
||||
GDateTime* dt = g_date_time_new_now_local();
|
||||
date_fmt = g_date_time_format_iso8601(dt);
|
||||
g_date_time_unref(dt);
|
||||
}
|
||||
|
||||
const char* enc = _get_message_enc_str(message->enc);
|
||||
|
||||
if (!type) {
|
||||
type = (char*)_get_message_type_str(message->type);
|
||||
}
|
||||
|
||||
// Apply LMC and check its validity (XEP-0308)
|
||||
if (message->replace_id) {
|
||||
auto_sqlite char* replace_check_query = sqlite3_mprintf("SELECT `id`, `from_jid`, `replaces_db_id` FROM `ChatLogs` WHERE `stanza_id` = %Q ORDER BY `timestamp` DESC LIMIT 1",
|
||||
message->replace_id);
|
||||
|
||||
if (!replace_check_query) {
|
||||
log_error("Could not allocate memory for SQL replace query in _add_to_db()");
|
||||
return;
|
||||
}
|
||||
|
||||
sqlite3_stmt* lmc_stmt = NULL;
|
||||
if (!_db_prepare_ctx(replace_check_query, &lmc_stmt, "_add_to_db(replace_check)")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sqlite3_step(lmc_stmt) == SQLITE_ROW) {
|
||||
original_message_id = sqlite3_column_int64(lmc_stmt, 0);
|
||||
const char* from_jid_orig = (const char*)sqlite3_column_text(lmc_stmt, 1);
|
||||
|
||||
sqlite_int64 tmp = sqlite3_column_int64(lmc_stmt, 2);
|
||||
original_message_id = tmp ? tmp : original_message_id;
|
||||
|
||||
if (g_strcmp0(from_jid_orig, from_jid->barejid) != 0) {
|
||||
log_error("Mismatch in sender JIDs when trying to do LMC. Corrected message sender: %s. Original message sender: %s. Replace-ID: %s. Message: %s", from_jid->barejid, from_jid_orig, message->replace_id, message->plain);
|
||||
cons_show_error("%s sent a message correction with mismatched sender. See log for details.", from_jid->barejid);
|
||||
sqlite3_finalize(lmc_stmt);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
log_warning("Got LMC message that does not have original message counterpart in the database from %s", message->from_jid->fulljid);
|
||||
}
|
||||
sqlite3_finalize(lmc_stmt);
|
||||
}
|
||||
|
||||
if (message->stanzaid && !message->is_mam) {
|
||||
auto_sqlite char* duplicate_check_query = sqlite3_mprintf("SELECT 1 FROM `ChatLogs` WHERE (`archive_id` = %Q)",
|
||||
message->stanzaid);
|
||||
|
||||
if (!duplicate_check_query) {
|
||||
log_error("Could not allocate memory for SQL duplicate query in _add_to_db()");
|
||||
return;
|
||||
}
|
||||
|
||||
sqlite3_stmt* stmt;
|
||||
if (_db_prepare_ctx(duplicate_check_query, &stmt, "_add_to_db(duplicate_check)")) {
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
log_error("Duplicate stanza-id found for the message. stanza_id: %s; archive_id: %s; sender: %s; content: %s", message->id, message->stanzaid, from_jid->barejid, message->plain);
|
||||
cons_show_error("Got a message with duplicate (server-generated) stanza-id from %s.", from_jid->fulljid);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
}
|
||||
|
||||
auto_sqlite char* orig_message_id = original_message_id == -1 ? NULL : sqlite3_mprintf("%d", original_message_id);
|
||||
|
||||
auto_sqlite char* query = sqlite3_mprintf("INSERT INTO `ChatLogs` "
|
||||
"(`from_jid`, `from_resource`, `to_jid`, `to_resource`, "
|
||||
"`message`, `timestamp`, `stanza_id`, `archive_id`, "
|
||||
"`replaces_db_id`, `replace_id`, `type`, `encryption`) "
|
||||
"VALUES (%Q, %Q, %Q, %Q, %Q, %Q, %Q, %Q, %Q, %Q, %Q, %Q)",
|
||||
from_jid->barejid,
|
||||
from_jid->resourcepart,
|
||||
to_jid->barejid,
|
||||
to_jid->resourcepart,
|
||||
message->plain,
|
||||
date_fmt,
|
||||
message->id,
|
||||
message->stanzaid,
|
||||
orig_message_id,
|
||||
message->replace_id,
|
||||
type,
|
||||
enc);
|
||||
if (!query) {
|
||||
log_error("Could not allocate memory for SQL insert query in _add_to_db()");
|
||||
return;
|
||||
}
|
||||
|
||||
log_debug("Writing to DB. Query: %s", query);
|
||||
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
|
||||
if (err_msg) {
|
||||
log_error("SQLite error in _add_to_db(): %s", err_msg);
|
||||
sqlite3_free(err_msg);
|
||||
} else {
|
||||
log_error("Unknown SQLite error in _add_to_db().");
|
||||
}
|
||||
} else {
|
||||
int inserted_rows_count = sqlite3_changes(g_chatlog_database);
|
||||
if (inserted_rows_count < 1) {
|
||||
log_error("SQLite did not insert message (rows: %d, id: %s, content: %s)", inserted_rows_count, message->id, message->plain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- DB version and migration ---
|
||||
|
||||
static int
|
||||
_get_db_version(void)
|
||||
{
|
||||
int current_version = -1;
|
||||
const char* query = "SELECT `version` FROM `DbVersion` LIMIT 1";
|
||||
sqlite3_stmt* statement;
|
||||
if (_db_prepare_ctx(query, &statement, "_get_db_version()")) {
|
||||
if (sqlite3_step(statement) == SQLITE_ROW) {
|
||||
current_version = sqlite3_column_int(statement, 0);
|
||||
}
|
||||
sqlite3_finalize(statement);
|
||||
}
|
||||
return current_version;
|
||||
}
|
||||
|
||||
static gboolean
|
||||
_migrate_to_v2(void)
|
||||
{
|
||||
char* err_msg = NULL;
|
||||
|
||||
const char* sql_statements[] = {
|
||||
"BEGIN TRANSACTION",
|
||||
"ALTER TABLE `ChatLogs` ADD COLUMN `replaces_db_id` INTEGER;",
|
||||
"ALTER TABLE `ChatLogs` ADD COLUMN `replaced_by_db_id` INTEGER;",
|
||||
"UPDATE `ChatLogs` AS A "
|
||||
"SET `replaces_db_id` = B.`id` "
|
||||
"FROM `ChatLogs` AS B "
|
||||
"WHERE A.`replace_id` IS NOT NULL AND A.`replace_id` != '' "
|
||||
"AND A.`replace_id` = B.`stanza_id` "
|
||||
"AND A.`from_jid` = B.`from_jid` AND A.`to_jid` = B.`to_jid`;",
|
||||
"UPDATE `ChatLogs` AS A "
|
||||
"SET `replaced_by_db_id` = B.`id` "
|
||||
"FROM `ChatLogs` AS B "
|
||||
"WHERE (A.`replace_id` IS NULL OR A.`replace_id` = '') "
|
||||
"AND A.`id` = B.`replaces_db_id` "
|
||||
"AND A.`from_jid` = B.`from_jid`;",
|
||||
"UPDATE ChatLogs SET "
|
||||
"from_resource = COALESCE(NULLIF(from_resource, ''), NULL), "
|
||||
"to_resource = COALESCE(NULLIF(to_resource, ''), NULL), "
|
||||
"message = COALESCE(NULLIF(message, ''), NULL), "
|
||||
"timestamp = COALESCE(NULLIF(timestamp, ''), NULL), "
|
||||
"stanza_id = COALESCE(NULLIF(stanza_id, ''), NULL), "
|
||||
"archive_id = COALESCE(NULLIF(archive_id, ''), NULL), "
|
||||
"replace_id = COALESCE(NULLIF(replace_id, ''), NULL), "
|
||||
"type = COALESCE(NULLIF(type, ''), NULL), "
|
||||
"encryption = COALESCE(NULLIF(encryption, ''), NULL);",
|
||||
"UPDATE `DbVersion` SET `version` = 2;",
|
||||
"END TRANSACTION"
|
||||
};
|
||||
|
||||
int statements_count = sizeof(sql_statements) / sizeof(sql_statements[0]);
|
||||
|
||||
for (int i = 0; i < statements_count; i++) {
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, sql_statements[i], NULL, 0, &err_msg)) {
|
||||
log_error("SQLite error in _migrate_to_v2() on statement %d: %s", i, err_msg);
|
||||
if (err_msg) {
|
||||
sqlite3_free(err_msg);
|
||||
err_msg = NULL;
|
||||
}
|
||||
goto cleanup;
|
||||
}
|
||||
}
|
||||
|
||||
return TRUE;
|
||||
|
||||
cleanup:
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, "ROLLBACK;", NULL, 0, &err_msg)) {
|
||||
log_error("[DB Migration] Unable to ROLLBACK: %s", err_msg);
|
||||
if (err_msg) {
|
||||
sqlite3_free(err_msg);
|
||||
}
|
||||
}
|
||||
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
static gboolean
|
||||
_check_available_space_for_db_migration(char* path_to_db)
|
||||
{
|
||||
struct stat file_stat;
|
||||
struct statvfs fs_stat;
|
||||
|
||||
if (statvfs(path_to_db, &fs_stat) == 0 && stat(path_to_db, &file_stat) == 0) {
|
||||
unsigned long long file_size = file_stat.st_size / 1024;
|
||||
unsigned long long available_space_kb = fs_stat.f_frsize * fs_stat.f_bavail / 1024;
|
||||
log_debug("_check_available_space_for_db_migration(): Available space on disk: %llu KB; DB size: %llu KB", available_space_kb, file_size);
|
||||
|
||||
return (available_space_kb >= (file_size + (file_size * 10 / 4)));
|
||||
} else {
|
||||
log_error("Error checking available space.");
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Backend vtable ---
|
||||
|
||||
static db_backend_t sqlite_backend = {
|
||||
.name = "sqlite",
|
||||
.init = _sqlite_init,
|
||||
.close = _sqlite_close,
|
||||
.add_incoming = _sqlite_add_incoming,
|
||||
.add_outgoing_chat = _sqlite_add_outgoing_chat,
|
||||
.add_outgoing_muc = _sqlite_add_outgoing_muc,
|
||||
.add_outgoing_muc_pm = _sqlite_add_outgoing_muc_pm,
|
||||
.get_previous_chat = _sqlite_get_previous_chat,
|
||||
.get_limits_info = _sqlite_get_limits_info,
|
||||
.verify_integrity = _sqlite_verify_integrity,
|
||||
};
|
||||
|
||||
db_backend_t*
|
||||
db_backend_sqlite(void)
|
||||
{
|
||||
return &sqlite_backend;
|
||||
}
|
||||
|
||||
void
|
||||
db_sqlite_begin_transaction(void)
|
||||
{
|
||||
if (!g_chatlog_database)
|
||||
return;
|
||||
char* err_msg = NULL;
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, "BEGIN TRANSACTION", NULL, 0, &err_msg)) {
|
||||
if (err_msg) {
|
||||
log_error("SQLite BEGIN TRANSACTION failed: %s", err_msg);
|
||||
sqlite3_free(err_msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
db_sqlite_end_transaction(void)
|
||||
{
|
||||
if (!g_chatlog_database)
|
||||
return;
|
||||
char* err_msg = NULL;
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, "END TRANSACTION", NULL, 0, &err_msg)) {
|
||||
if (err_msg) {
|
||||
log_error("SQLite END TRANSACTION failed: %s", err_msg);
|
||||
sqlite3_free(err_msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
db_sqlite_rollback_transaction(void)
|
||||
{
|
||||
if (!g_chatlog_database)
|
||||
return;
|
||||
char* err_msg = NULL;
|
||||
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, "ROLLBACK", NULL, 0, &err_msg)) {
|
||||
if (err_msg) {
|
||||
log_error("SQLite ROLLBACK failed: %s", err_msg);
|
||||
sqlite3_free(err_msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int
|
||||
db_sqlite_last_changes(void)
|
||||
{
|
||||
if (!g_chatlog_database)
|
||||
return 0;
|
||||
return sqlite3_changes(g_chatlog_database);
|
||||
}
|
||||
|
||||
GSList*
|
||||
db_sqlite_get_all_chat(const gchar* const contact_barejid)
|
||||
{
|
||||
GSList* result = NULL;
|
||||
|
||||
if (!g_chatlog_database)
|
||||
return NULL;
|
||||
|
||||
const Jid* myjid = connection_get_jid();
|
||||
if (!myjid || !myjid->barejid)
|
||||
return NULL;
|
||||
|
||||
sqlite3_stmt* stmt = NULL;
|
||||
auto_sqlite char* query = sqlite3_mprintf(
|
||||
"SELECT COALESCE(B.`message`, A.`message`) AS message, "
|
||||
"A.`timestamp`, A.`from_jid`, A.`from_resource`, A.`to_jid`, A.`to_resource`, "
|
||||
"A.`type`, A.`encryption`, A.`stanza_id`, A.`archive_id`, A.`marked_read` "
|
||||
"FROM `ChatLogs` AS A "
|
||||
"LEFT JOIN `ChatLogs` AS B ON (A.`replaced_by_db_id` = B.`id` AND A.`from_jid` = B.`from_jid`) "
|
||||
"WHERE (A.`replaces_db_id` IS NULL) "
|
||||
"AND ((A.`from_jid` = %Q AND A.`to_jid` = %Q) OR (A.`from_jid` = %Q AND A.`to_jid` = %Q)) "
|
||||
"ORDER BY A.`timestamp` ASC",
|
||||
contact_barejid, myjid->barejid, myjid->barejid, contact_barejid);
|
||||
|
||||
if (!query)
|
||||
return NULL;
|
||||
|
||||
if (!_db_prepare_ctx(query, &stmt, "db_sqlite_get_all_chat()"))
|
||||
return NULL;
|
||||
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
char* message = (char*)sqlite3_column_text(stmt, 0);
|
||||
char* date = (char*)sqlite3_column_text(stmt, 1);
|
||||
char* from_jid = (char*)sqlite3_column_text(stmt, 2);
|
||||
char* from_resource = (char*)sqlite3_column_text(stmt, 3);
|
||||
char* to_jid = (char*)sqlite3_column_text(stmt, 4);
|
||||
char* to_resource = (char*)sqlite3_column_text(stmt, 5);
|
||||
char* type = (char*)sqlite3_column_text(stmt, 6);
|
||||
char* encryption = (char*)sqlite3_column_text(stmt, 7);
|
||||
char* id = (char*)sqlite3_column_text(stmt, 8);
|
||||
char* archive_id = (char*)sqlite3_column_text(stmt, 9);
|
||||
int marked_read_raw = sqlite3_column_type(stmt, 10) == SQLITE_NULL ? -1 : sqlite3_column_int(stmt, 10);
|
||||
|
||||
ProfMessage* msg = message_init();
|
||||
msg->id = _db_strdup(id);
|
||||
msg->stanzaid = _db_strdup(archive_id);
|
||||
msg->from_jid = jid_create_from_bare_and_resource(from_jid, from_resource);
|
||||
msg->to_jid = jid_create_from_bare_and_resource(to_jid, to_resource);
|
||||
msg->plain = _db_strdup(message);
|
||||
if (!msg->plain)
|
||||
msg->plain = strdup("");
|
||||
msg->timestamp = g_date_time_new_from_iso8601(date, NULL);
|
||||
msg->type = _get_message_type_type(type);
|
||||
msg->enc = _get_message_enc_type(encryption);
|
||||
msg->marked_read = marked_read_raw;
|
||||
|
||||
result = g_slist_prepend(result, msg);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
return g_slist_reverse(result);
|
||||
}
|
||||
|
||||
GSList*
|
||||
db_sqlite_list_contacts(void)
|
||||
{
|
||||
GSList* contacts = NULL;
|
||||
if (!g_chatlog_database)
|
||||
return NULL;
|
||||
|
||||
const Jid* myjid = connection_get_jid();
|
||||
if (!myjid || !myjid->barejid)
|
||||
return NULL;
|
||||
|
||||
const char* q = "SELECT DISTINCT `from_jid` FROM `ChatLogs` "
|
||||
"WHERE `from_jid` != ? "
|
||||
"UNION "
|
||||
"SELECT DISTINCT `to_jid` FROM `ChatLogs` "
|
||||
"WHERE `to_jid` != ?";
|
||||
sqlite3_stmt* stmt = NULL;
|
||||
if (!_db_prepare_ctx(q, &stmt, "db_sqlite_list_contacts"))
|
||||
return NULL;
|
||||
|
||||
sqlite3_bind_text(stmt, 1, myjid->barejid, -1, SQLITE_STATIC);
|
||||
sqlite3_bind_text(stmt, 2, myjid->barejid, -1, SQLITE_STATIC);
|
||||
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const char* jid = (const char*)sqlite3_column_text(stmt, 0);
|
||||
if (jid && strlen(jid) > 0) {
|
||||
contacts = g_slist_prepend(contacts, g_strdup(jid));
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
return g_slist_reverse(contacts);
|
||||
}
|
||||
@@ -52,6 +52,7 @@
|
||||
|
||||
#include "config/theme.h"
|
||||
#include "config/preferences.h"
|
||||
#include "database.h"
|
||||
#include "ui/ui.h"
|
||||
#include "ui/statusbar.h"
|
||||
#include "ui/inputwin.h"
|
||||
@@ -83,6 +84,7 @@ static WINDOW* statusbar_win;
|
||||
void _get_range_bounds(int* start, int* end, gboolean is_static);
|
||||
static int _status_bar_draw_time(int pos);
|
||||
static int _status_bar_draw_maintext(int pos);
|
||||
static int _status_bar_draw_dbbackend(int pos);
|
||||
static int _status_bar_draw_bracket(gboolean current, int pos, const char* ch);
|
||||
static int _status_bar_draw_extended_tabs(int pos, gboolean prefix, int start, int end, gboolean is_static);
|
||||
static int _status_bar_draw_tab(StatusBarTab* tab, int pos, int num, gboolean include_brackets);
|
||||
@@ -313,6 +315,7 @@ status_bar_draw(void)
|
||||
|
||||
pos = _status_bar_draw_time(pos);
|
||||
pos = _status_bar_draw_maintext(pos);
|
||||
pos = _status_bar_draw_dbbackend(pos);
|
||||
if (max_tabs != 0)
|
||||
pos = _status_bar_draw_tabs(pos);
|
||||
|
||||
@@ -603,6 +606,17 @@ _status_bar_draw_maintext(int pos)
|
||||
return pos;
|
||||
}
|
||||
|
||||
static int
|
||||
_status_bar_draw_dbbackend(int pos)
|
||||
{
|
||||
if (!active_db_backend || !active_db_backend->name)
|
||||
return pos;
|
||||
|
||||
auto_gchar gchar* label = g_strdup_printf(" [%s]", active_db_backend->name);
|
||||
mvwprintw(statusbar_win, 0, pos, "%s", label);
|
||||
return pos + utf8_display_len(label);
|
||||
}
|
||||
|
||||
static void
|
||||
_destroy_tab(StatusBarTab* tab)
|
||||
{
|
||||
|
||||
@@ -111,6 +111,17 @@ jid_create(const gchar* const str)
|
||||
Jid*
|
||||
jid_create_from_bare_and_resource(const char* const barejid, const char* const resource)
|
||||
{
|
||||
// NULL, empty, or the literal "(null)" (legacy artefact from earlier
|
||||
// builds where g_strdup_printf("%s", NULL) leaked the glibc placeholder
|
||||
// into stored data) all mean "no value" for either part. With no usable
|
||||
// bare jid there is nothing to construct; with no usable resource we
|
||||
// return the bare jid alone.
|
||||
if (!barejid || !*barejid || g_strcmp0(barejid, "(null)") == 0) {
|
||||
return NULL;
|
||||
}
|
||||
if (!resource || !*resource || g_strcmp0(resource, "(null)") == 0) {
|
||||
return jid_create(barejid);
|
||||
}
|
||||
auto_char char* jid = create_fulljid(barejid, resource);
|
||||
return jid_create(jid);
|
||||
}
|
||||
|
||||
@@ -358,6 +358,7 @@ message_init(void)
|
||||
message->enc = PROF_MSG_ENC_NONE;
|
||||
message->trusted = true;
|
||||
message->type = PROF_MSG_TYPE_UNINITIALIZED;
|
||||
message->marked_read = -1;
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
@@ -175,6 +175,7 @@ typedef struct prof_message_t
|
||||
gboolean trusted;
|
||||
gboolean is_mam;
|
||||
prof_msg_type_t type;
|
||||
int marked_read; // -1 = unset, 0 = unread, 1 = read (used by export/import)
|
||||
} ProfMessage;
|
||||
|
||||
void session_init(void);
|
||||
|
||||
Reference in New Issue
Block a user