Compare commits
5 Commits
test/db-fu
...
8868b79617
| Author | SHA1 | Date | |
|---|---|---|---|
|
8868b79617
|
|||
|
f8ef504a09
|
|||
|
59fe15f4d0
|
|||
|
b8cbe10cf1
|
|||
|
c3ad299e4a
|
13
CHANGELOG
13
CHANGELOG
@@ -1,3 +1,16 @@
|
||||
0.16.0 (unreleased)
|
||||
===================
|
||||
|
||||
Changes:
|
||||
- Add flat-file database backend as alternative to SQLite for message history.
|
||||
Stores messages as human-readable plain text files. Configure with `/privacy logging flatfile`.
|
||||
Files are stored in ~/.local/share/profanity/flatlog/.
|
||||
- Add `/history verify [<jid>]` command to check integrity of stored message
|
||||
history (works with both SQLite and flat-file backends).
|
||||
- Add vtable-based database backend abstraction allowing pluggable storage.
|
||||
- Add `make check-functional-flatfile` target to run functional tests with
|
||||
flat-file backend.
|
||||
|
||||
0.15.0 (2025-03-27)
|
||||
===================
|
||||
|
||||
|
||||
@@ -123,6 +123,20 @@ Test your changes with the following tools to find mistakes.
|
||||
|
||||
Run `make check` to run the unit tests with your current configuration or `./ci-build.sh` to check with different switches passed to configure.
|
||||
|
||||
### flat-file backend tests
|
||||
|
||||
To run functional tests with the flat-file database backend (instead of SQLite):
|
||||
|
||||
```bash
|
||||
make check-functional-flatfile
|
||||
```
|
||||
|
||||
Or manually for a single group:
|
||||
|
||||
```bash
|
||||
PROF_FLATFILE=1 PROF_TEST_GROUP=1 ./tests/functionaltests/functionaltests 1
|
||||
```
|
||||
|
||||
### valgrind
|
||||
We provide a suppressions file `prof.supp`. It is a combination of the suppressions for shipped with glib2, python and custom rules.
|
||||
|
||||
|
||||
30
Makefile.am
30
Makefile.am
@@ -3,6 +3,9 @@ core_sources = \
|
||||
src/log.c src/common.c \
|
||||
src/chatlog.c src/chatlog.h \
|
||||
src/database.h src/database.c \
|
||||
src/database_flatfile.c src/database_flatfile.h \
|
||||
src/database_flatfile_parser.c \
|
||||
src/database_flatfile_verify.c \
|
||||
src/log.h src/profanity.c src/common.h \
|
||||
src/profanity.h src/xmpp/chat_session.c \
|
||||
src/xmpp/chat_session.h src/xmpp/muc.c src/xmpp/muc.h src/xmpp/jid.h src/xmpp/jid.c \
|
||||
@@ -185,7 +188,6 @@ functionaltest_sources = \
|
||||
tests/functionaltests/test_software.c tests/functionaltests/test_software.h \
|
||||
tests/functionaltests/test_muc.c tests/functionaltests/test_muc.h \
|
||||
tests/functionaltests/test_disconnect.c tests/functionaltests/test_disconnect.h \
|
||||
tests/functionaltests/test_history.c tests/functionaltests/test_history.h \
|
||||
tests/functionaltests/test_lastactivity.c tests/functionaltests/test_lastactivity.h \
|
||||
tests/functionaltests/functionaltests.c
|
||||
|
||||
@@ -256,6 +258,12 @@ core_sources += $(omemo_sources)
|
||||
unittest_sources += $(omemo_unittest_sources)
|
||||
endif
|
||||
|
||||
sqlite_sources = src/database_sqlite.c
|
||||
|
||||
if BUILD_SQLITE
|
||||
core_sources += $(sqlite_sources)
|
||||
endif
|
||||
|
||||
all_c_sources = $(core_sources) $(unittest_sources) \
|
||||
$(pgp_sources) $(pgp_unittest_sources) \
|
||||
$(otr4_sources) $(otr_unittest_sources) \
|
||||
@@ -330,6 +338,26 @@ check-functional-parallel: tests/functionaltests/functionaltests
|
||||
grep -E 'PASSED|FAILED|Running' $(builddir)/test-logs/group*.log || true; \
|
||||
if [ $$failed -ne 0 ]; then echo "FUNCTIONAL TESTS FAILED"; exit 1; fi; \
|
||||
echo "All functional test groups passed!"
|
||||
|
||||
# Run functional tests with the flat-file database backend
|
||||
# Usage: make check-functional-flatfile
|
||||
check-functional-flatfile: tests/functionaltests/functionaltests
|
||||
@echo "Running functional tests with flat-file backend ($(words $(FUNC_TEST_GROUPS)) groups)..."
|
||||
@mkdir -p $(builddir)/test-logs $(builddir)/test-files
|
||||
@pids=""; \
|
||||
for g in $(FUNC_TEST_GROUPS); do \
|
||||
PROF_FLATFILE=1 ./tests/functionaltests/functionaltests $$g > $(builddir)/test-logs/group$$g-flatfile.log 2>&1 & \
|
||||
pids="$$pids $$!"; \
|
||||
done; \
|
||||
failed=0; i=1; \
|
||||
for pid in $$pids; do \
|
||||
wait $$pid || { echo "Group $$i FAILED (flatfile)"; cat $(builddir)/test-logs/group$$i-flatfile.log; failed=1; }; \
|
||||
i=$$((i + 1)); \
|
||||
done; \
|
||||
echo "=== Flat-file Test Results Summary ==="; \
|
||||
grep -E 'PASSED|FAILED|Running' $(builddir)/test-logs/group*-flatfile.log || true; \
|
||||
if [ $$failed -ne 0 ]; then echo "FLAT-FILE FUNCTIONAL TESTS FAILED"; exit 1; fi; \
|
||||
echo "All flat-file functional test groups passed!"
|
||||
endif
|
||||
endif
|
||||
|
||||
|
||||
14
configure.ac
14
configure.ac
@@ -94,8 +94,17 @@ PKG_CHECK_MODULES([curl], [libcurl >= 7.62.0], [],
|
||||
[AC_CHECK_LIB([curl], [main], [],
|
||||
[AC_MSG_ERROR([libcurl 7.62.0 or higher is required])])])
|
||||
|
||||
PKG_CHECK_MODULES([SQLITE], [sqlite3 >= 3.22.0], [],
|
||||
[AC_MSG_ERROR([sqlite3 3.22.0 or higher is required])])
|
||||
### sqlite (optional — can be disabled with --without-sqlite)
|
||||
AC_ARG_WITH([sqlite],
|
||||
[AS_HELP_STRING([--without-sqlite], [build without SQLite support (flat-file backend only)])],
|
||||
[], [with_sqlite=yes])
|
||||
|
||||
AS_IF([test "x$with_sqlite" != "xno"],
|
||||
[PKG_CHECK_MODULES([SQLITE], [sqlite3 >= 3.22.0],
|
||||
[AC_DEFINE([HAVE_SQLITE], [1], [SQLite support])],
|
||||
[AC_MSG_ERROR([sqlite3 3.22.0 or higher is required (use --without-sqlite to disable)])])],
|
||||
[AC_MSG_NOTICE([Building without SQLite — flat-file backend only])])
|
||||
AM_CONDITIONAL([BUILD_SQLITE], [test "x$with_sqlite" != "xno"])
|
||||
|
||||
ACX_PTHREAD([], [AC_MSG_ERROR([pthread is required])])
|
||||
AS_IF([test "x$PTHREAD_CC" != x], [ CC="$PTHREAD_CC" ])
|
||||
@@ -416,6 +425,7 @@ AC_OUTPUT
|
||||
AC_MSG_NOTICE([Summary of build options:
|
||||
PLATFORM : $target_os
|
||||
PACKAGE_STATUS : $PACKAGE_STATUS
|
||||
SQLite support : $with_sqlite
|
||||
GTK_VERSION : $GTK_VERSION
|
||||
LIBS : $LIBS
|
||||
Install themes : $THEMES_INSTALL
|
||||
|
||||
@@ -197,7 +197,35 @@ Configuration for
|
||||
.B Profanity
|
||||
is stored in
|
||||
.I $XDG_CONFIG_HOME/profanity/profrc
|
||||
, details on commands for configuring Profanity can be found at <https://profanity-im.github.io/reference.html> or the respective built\-in help or man pages.
|
||||
, details on commands for configuring Profanity can be found at <https://profanity-im.github.io/reference.html> or the respective built\-in help or man pages..SS Message History Storage
|
||||
By default, message history is stored in an SQLite database. An alternative flat-file backend
|
||||
stores messages as human-readable plain text files that can be edited with any text editor.
|
||||
.PP
|
||||
To enable flat-file logging, set in
|
||||
.IR profrc :
|
||||
.PP
|
||||
.EX
|
||||
[logging]
|
||||
dblog=flatfile
|
||||
.EE
|
||||
.PP
|
||||
Or use the command:
|
||||
.B /privacy logging flatfile
|
||||
.PP
|
||||
Flat-file logs are stored under
|
||||
.IR $XDG_DATA_HOME/profanity/flatlog/ ,
|
||||
organized as
|
||||
.IR {account_jid}/{contact_jid}/history.log .
|
||||
.PP
|
||||
Each line has the format:
|
||||
.br
|
||||
.EX
|
||||
{ISO8601} [{type}|{enc}|id:{id}|aid:{archive_id}|corrects:{id}] {sender}: {message}
|
||||
.EE
|
||||
.PP
|
||||
Use
|
||||
.B /history verify
|
||||
to check integrity of stored history (both SQLite and flat-file).
|
||||
.SH BUGS
|
||||
Bugs can either be reported by raising an issue at the Github issue tracker:
|
||||
.br
|
||||
|
||||
@@ -49,6 +49,13 @@ grlog=true
|
||||
maxsize=1048580
|
||||
rotate=true
|
||||
shared=true
|
||||
# Database backend for message history:
|
||||
# on - SQLite database (default)
|
||||
# off - no message logging
|
||||
# redact - store with redacted message content
|
||||
# flatfile - plain text files, editable with any text editor
|
||||
# stored in ~/.local/share/profanity/flatlog/
|
||||
dblog=on
|
||||
|
||||
[otr]
|
||||
warn=true
|
||||
|
||||
@@ -275,6 +275,7 @@ static Autocomplete logging_ac;
|
||||
static Autocomplete logging_group_ac;
|
||||
static Autocomplete privacy_ac;
|
||||
static Autocomplete privacy_log_ac;
|
||||
static Autocomplete history_ac;
|
||||
static Autocomplete color_ac;
|
||||
static Autocomplete correction_ac;
|
||||
static Autocomplete avatar_ac;
|
||||
@@ -429,6 +430,7 @@ static Autocomplete* all_acs[] = {
|
||||
&logging_group_ac,
|
||||
&privacy_ac,
|
||||
&privacy_log_ac,
|
||||
&history_ac,
|
||||
&color_ac,
|
||||
&correction_ac,
|
||||
&avatar_ac,
|
||||
@@ -1138,6 +1140,11 @@ 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, "verify");
|
||||
|
||||
autocomplete_add(logging_group_ac, "on");
|
||||
autocomplete_add(logging_group_ac, "off");
|
||||
@@ -1776,7 +1783,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 +1793,11 @@ _cmd_ac_complete_params(ProfWin* window, const char* const input, gboolean previ
|
||||
}
|
||||
}
|
||||
|
||||
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,21 @@ 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 verify [<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 'verify' to check integrity of stored message history.")
|
||||
CMD_ARGS(
|
||||
{ "on|off", "Enable or disable showing chat history." })
|
||||
{ "on|off", "Enable or disable showing chat history." },
|
||||
{ "verify [<jid>]", "Verify integrity of message history. Optionally specify a JID to check only one contact." })
|
||||
},
|
||||
|
||||
{ CMD_PREAMBLE("/log",
|
||||
@@ -2718,7 +2721,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 +2729,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,11 @@ 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) {
|
||||
cons_show("Using flat-file backend for message logging. Takes effect on next connection.");
|
||||
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;
|
||||
@@ -6730,6 +6735,58 @@ cmd_history(ProfWin* window, const char* const command, gchar** args)
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
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"
|
||||
|
||||
704
src/database.c
704
src/database.c
@@ -35,703 +35,135 @@
|
||||
|
||||
#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 "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, 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
|
||||
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_info("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;
|
||||
}
|
||||
|
||||
void
|
||||
log_database_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());
|
||||
if (active_db_backend && active_db_backend->add_incoming) {
|
||||
active_db_backend->add_incoming(message);
|
||||
}
|
||||
}
|
||||
|
||||
static void
|
||||
_log_database_add_outgoing(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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
gchar* sort1 = from_start ? "ASC" : "DESC";
|
||||
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, 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,6 +48,49 @@ 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);
|
||||
#endif
|
||||
db_backend_t* db_backend_flatfile(void);
|
||||
|
||||
// Public API (dispatches to active_db_backend)
|
||||
gboolean log_database_init(ProfAccount* account);
|
||||
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);
|
||||
@@ -56,5 +99,6 @@ 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);
|
||||
|
||||
#endif // DATABASE_H
|
||||
|
||||
995
src/database_flatfile.c
Normal file
995
src/database_flatfile.c
Normal file
@@ -0,0 +1,995 @@
|
||||
/*
|
||||
* database_flatfile.c
|
||||
* vim: expandtab:ts=4:sts=4:sw=4
|
||||
*
|
||||
* Copyright (C) 2026 Profanity Contributors
|
||||
*
|
||||
* 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/>.
|
||||
*
|
||||
* Flat-file database backend: init/close, message write, message read,
|
||||
* LMC correction logic, and the backend vtable.
|
||||
*
|
||||
* Parser and escape helpers are in database_flatfile_parser.c.
|
||||
* Integrity verification is in database_flatfile_verify.c.
|
||||
* Shared types and prototypes are in database_flatfile.h.
|
||||
*/
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/file.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 "database_flatfile.h"
|
||||
#include "config/preferences.h"
|
||||
#include "ui/ui.h"
|
||||
#include "xmpp/xmpp.h"
|
||||
#include "xmpp/message.h"
|
||||
|
||||
// =========================================================================
|
||||
// Shared global — account JID stored during init for path construction
|
||||
// =========================================================================
|
||||
|
||||
char* g_flatfile_account_jid = NULL;
|
||||
static GHashTable* g_contact_states = NULL;
|
||||
|
||||
// =========================================================================
|
||||
// Per-contact state / sparse index
|
||||
// =========================================================================
|
||||
|
||||
ff_contact_state_t*
|
||||
ff_state_new(const char* filepath)
|
||||
{
|
||||
ff_contact_state_t* state = g_malloc0(sizeof(ff_contact_state_t));
|
||||
state->filepath = g_strdup(filepath);
|
||||
state->cursor_offset = -1;
|
||||
return state;
|
||||
}
|
||||
|
||||
void
|
||||
ff_state_free(ff_contact_state_t* state)
|
||||
{
|
||||
if (!state)
|
||||
return;
|
||||
g_free(state->filepath);
|
||||
g_free(state->entries);
|
||||
g_free(state);
|
||||
}
|
||||
|
||||
static gboolean
|
||||
_ff_state_build(ff_contact_state_t* state)
|
||||
{
|
||||
FILE* fp = fopen(state->filepath, "r");
|
||||
if (!fp)
|
||||
return FALSE;
|
||||
|
||||
int c1 = fgetc(fp);
|
||||
int c2 = fgetc(fp);
|
||||
int c3 = fgetc(fp);
|
||||
if (c1 == 0xEF && c2 == 0xBB && c3 == 0xBF) {
|
||||
state->bom_len = 3;
|
||||
} else {
|
||||
state->bom_len = 0;
|
||||
fseek(fp, 0, SEEK_SET);
|
||||
}
|
||||
|
||||
state->n_entries = 0;
|
||||
state->total_lines = 0;
|
||||
size_t msg_count = 0;
|
||||
|
||||
while (1) {
|
||||
off_t pos = ftell(fp);
|
||||
gboolean truncated = FALSE;
|
||||
char* buf = ff_readline(fp, &truncated);
|
||||
if (!buf)
|
||||
break;
|
||||
if (truncated) {
|
||||
free(buf);
|
||||
break;
|
||||
}
|
||||
|
||||
if (buf[0] == '\0' || buf[0] == '#') {
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
|
||||
state->total_lines++;
|
||||
|
||||
if (msg_count % FF_INDEX_STEP == 0) {
|
||||
char* space = strchr(buf, ' ');
|
||||
if (space) {
|
||||
char* ts_str = g_strndup(buf, space - buf);
|
||||
GDateTime* dt = g_date_time_new_from_iso8601(ts_str, NULL);
|
||||
if (dt) {
|
||||
if (state->n_entries >= state->cap_entries) {
|
||||
state->cap_entries = state->cap_entries ? state->cap_entries * 2 : 64;
|
||||
state->entries = g_realloc(state->entries,
|
||||
state->cap_entries * sizeof(ff_index_entry_t));
|
||||
}
|
||||
state->entries[state->n_entries].byte_offset = pos;
|
||||
state->entries[state->n_entries].timestamp_epoch = g_date_time_to_unix(dt);
|
||||
state->n_entries++;
|
||||
g_date_time_unref(dt);
|
||||
}
|
||||
g_free(ts_str);
|
||||
}
|
||||
}
|
||||
msg_count++;
|
||||
free(buf);
|
||||
}
|
||||
|
||||
fclose(fp);
|
||||
|
||||
struct stat st;
|
||||
if (stat(state->filepath, &st) == 0) {
|
||||
state->stamp.mtime = st.st_mtime;
|
||||
state->stamp.size = st.st_size;
|
||||
state->stamp.inode = st.st_ino;
|
||||
}
|
||||
|
||||
log_debug("flatfile: index built for %s (%zu entries, %zu lines)",
|
||||
state->filepath, state->n_entries, state->total_lines);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static gboolean
|
||||
_ff_state_extend(ff_contact_state_t* state)
|
||||
{
|
||||
struct stat st;
|
||||
if (stat(state->filepath, &st) != 0)
|
||||
return FALSE;
|
||||
|
||||
if (st.st_size <= state->stamp.size)
|
||||
return _ff_state_build(state);
|
||||
|
||||
FILE* fp = fopen(state->filepath, "r");
|
||||
if (!fp)
|
||||
return FALSE;
|
||||
|
||||
fseek(fp, state->stamp.size, SEEK_SET);
|
||||
if (state->stamp.size > 0) {
|
||||
fseek(fp, state->stamp.size - 1, SEEK_SET);
|
||||
int prev = fgetc(fp);
|
||||
if (prev != '\n' && prev != EOF) {
|
||||
int ch;
|
||||
while ((ch = fgetc(fp)) != EOF && ch != '\n') {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use a local counter for new messages so index step alignment
|
||||
// doesn't depend on the total from the initial build.
|
||||
size_t new_count = 0;
|
||||
|
||||
while (1) {
|
||||
off_t pos = ftell(fp);
|
||||
gboolean truncated = FALSE;
|
||||
char* buf = ff_readline(fp, &truncated);
|
||||
if (!buf)
|
||||
break;
|
||||
if (truncated) {
|
||||
free(buf);
|
||||
break;
|
||||
}
|
||||
|
||||
if (buf[0] == '\0' || buf[0] == '#') {
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
|
||||
state->total_lines++;
|
||||
|
||||
if (new_count % FF_INDEX_STEP == 0) {
|
||||
char* space = strchr(buf, ' ');
|
||||
if (space) {
|
||||
char* ts_str = g_strndup(buf, space - buf);
|
||||
GDateTime* dt = g_date_time_new_from_iso8601(ts_str, NULL);
|
||||
if (dt) {
|
||||
if (state->n_entries >= state->cap_entries) {
|
||||
state->cap_entries = state->cap_entries ? state->cap_entries * 2 : 64;
|
||||
state->entries = g_realloc(state->entries,
|
||||
state->cap_entries * sizeof(ff_index_entry_t));
|
||||
}
|
||||
state->entries[state->n_entries].byte_offset = pos;
|
||||
state->entries[state->n_entries].timestamp_epoch = g_date_time_to_unix(dt);
|
||||
state->n_entries++;
|
||||
g_date_time_unref(dt);
|
||||
}
|
||||
g_free(ts_str);
|
||||
}
|
||||
}
|
||||
new_count++;
|
||||
free(buf);
|
||||
}
|
||||
|
||||
fclose(fp);
|
||||
|
||||
state->stamp.mtime = st.st_mtime;
|
||||
state->stamp.size = st.st_size;
|
||||
state->stamp.inode = st.st_ino;
|
||||
|
||||
log_debug("flatfile: index extended for %s (%zu entries, %zu lines)",
|
||||
state->filepath, state->n_entries, state->total_lines);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
ff_state_ensure_fresh(ff_contact_state_t* state)
|
||||
{
|
||||
if (!state || !state->filepath)
|
||||
return FALSE;
|
||||
|
||||
struct stat st;
|
||||
if (stat(state->filepath, &st) != 0) {
|
||||
state->total_lines = 0;
|
||||
state->n_entries = 0;
|
||||
state->stamp.size = 0;
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
if (state->stamp.size == 0 && state->n_entries == 0)
|
||||
return _ff_state_build(state);
|
||||
|
||||
if (st.st_mtime == state->stamp.mtime
|
||||
&& st.st_size == state->stamp.size
|
||||
&& st.st_ino == state->stamp.inode) {
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (st.st_ino == state->stamp.inode && st.st_size > state->stamp.size)
|
||||
return _ff_state_extend(state);
|
||||
|
||||
state->cursor_offset = -1;
|
||||
return _ff_state_build(state);
|
||||
}
|
||||
|
||||
off_t
|
||||
ff_state_offset_for_time(ff_contact_state_t* state, const char* iso_time)
|
||||
{
|
||||
if (!state || !iso_time || state->n_entries == 0)
|
||||
return state ? (off_t)state->bom_len : 0;
|
||||
|
||||
GDateTime* dt = g_date_time_new_from_iso8601(iso_time, NULL);
|
||||
if (!dt)
|
||||
return state->bom_len;
|
||||
|
||||
gint64 target = g_date_time_to_unix(dt);
|
||||
g_date_time_unref(dt);
|
||||
|
||||
size_t lo = 0, hi = state->n_entries;
|
||||
while (lo < hi) {
|
||||
size_t mid = lo + (hi - lo) / 2;
|
||||
if (state->entries[mid].timestamp_epoch <= target)
|
||||
lo = mid + 1;
|
||||
else
|
||||
hi = mid;
|
||||
}
|
||||
|
||||
if (lo == 0)
|
||||
return state->bom_len;
|
||||
return state->entries[lo - 1].byte_offset;
|
||||
}
|
||||
|
||||
static ff_contact_state_t*
|
||||
_ff_get_state(const char* contact_barejid)
|
||||
{
|
||||
if (!g_contact_states || !contact_barejid)
|
||||
return NULL;
|
||||
|
||||
ff_contact_state_t* state = g_hash_table_lookup(g_contact_states, contact_barejid);
|
||||
if (state)
|
||||
return state;
|
||||
|
||||
auto_gchar gchar* filepath = ff_get_log_path(contact_barejid);
|
||||
if (!filepath)
|
||||
return NULL;
|
||||
|
||||
state = ff_state_new(filepath);
|
||||
g_hash_table_insert(g_contact_states, g_strdup(contact_barejid), state);
|
||||
return state;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Core write logic
|
||||
// =========================================================================
|
||||
|
||||
static void
|
||||
_ff_add_message(const char* type, const char* stanza_id, const char* archive_id,
|
||||
const char* replace_id, const char* from_barejid, const char* from_resource,
|
||||
const char* to_barejid, const char* message_text, GDateTime* timestamp,
|
||||
prof_enc_t enc)
|
||||
{
|
||||
auto_gchar gchar* pref_dblog = prefs_get_string(PREF_DBLOG);
|
||||
|
||||
if (g_strcmp0(pref_dblog, "off") == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// "redact" replaces the message content.
|
||||
char* effective_msg = NULL;
|
||||
if (g_strcmp0(pref_dblog, "redact") == 0) {
|
||||
effective_msg = g_strdup("[REDACTED]");
|
||||
} else {
|
||||
effective_msg = g_strdup(message_text ? message_text : "");
|
||||
}
|
||||
|
||||
GDateTime* dt = timestamp ? g_date_time_ref(timestamp) : g_date_time_new_now_local();
|
||||
auto_gchar gchar* date_fmt = g_date_time_format_iso8601(dt);
|
||||
g_date_time_unref(dt);
|
||||
|
||||
// Determine which JID to use for the log file path (the "other" party)
|
||||
const char* contact = NULL;
|
||||
const Jid* myjid = connection_get_jid();
|
||||
if (myjid && myjid->barejid && g_strcmp0(from_barejid, myjid->barejid) == 0) {
|
||||
contact = to_barejid;
|
||||
} else {
|
||||
contact = from_barejid;
|
||||
}
|
||||
|
||||
auto_gchar gchar* log_path = ff_get_log_path(contact);
|
||||
|
||||
if (!log_path) {
|
||||
log_error("flatfile: could not determine log path for %s", contact);
|
||||
g_free(effective_msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
auto_gchar gchar* dir = g_path_get_dirname(log_path);
|
||||
if (!ff_ensure_dir(dir)) {
|
||||
g_free(effective_msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Open the file with O_NOFOLLOW to prevent symlink attacks,
|
||||
// and O_APPEND for safe concurrent appends.
|
||||
// Try O_CREAT|O_EXCL first to detect new files atomically (no TOCTOU).
|
||||
int fd = open(log_path, O_WRONLY | O_APPEND | O_CREAT | O_EXCL | O_NOFOLLOW,
|
||||
S_IRUSR | S_IWUSR);
|
||||
gboolean is_new = (fd >= 0);
|
||||
if (fd < 0) {
|
||||
if (errno == EEXIST) {
|
||||
// File already exists — open for append
|
||||
fd = open(log_path, O_WRONLY | O_APPEND | O_NOFOLLOW);
|
||||
}
|
||||
if (fd < 0) {
|
||||
// ELOOP (symlink) or other error
|
||||
log_error("flatfile: could not open %s for writing (errno=%d: %s)",
|
||||
log_path, errno, strerror(errno));
|
||||
g_free(effective_msg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
FILE* fp = fdopen(fd, "a");
|
||||
if (!fp) {
|
||||
log_error("flatfile: fdopen failed for %s (errno=%d)", log_path, errno);
|
||||
close(fd);
|
||||
g_free(effective_msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Advisory lock to prevent interleaved writes from concurrent instances
|
||||
if (flock(fd, LOCK_EX) != 0) {
|
||||
log_warning("flatfile: flock(%s) failed (errno=%d: %s), writing anyway",
|
||||
log_path, errno, strerror(errno));
|
||||
}
|
||||
|
||||
if (is_new) {
|
||||
if (fprintf(fp, "%s", FLATFILE_HEADER) < 0) {
|
||||
log_error("flatfile: failed to write header to %s (errno=%d)",
|
||||
log_path, errno);
|
||||
}
|
||||
}
|
||||
|
||||
ff_write_line(fp, date_fmt, type, ff_get_message_enc_str(enc),
|
||||
stanza_id, archive_id, replace_id,
|
||||
from_barejid, from_resource, effective_msg);
|
||||
|
||||
fflush(fp);
|
||||
// fclose also releases the flock
|
||||
fclose(fp);
|
||||
g_free(effective_msg);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Backend callbacks: init / close
|
||||
// =========================================================================
|
||||
|
||||
static gboolean
|
||||
_flatfile_init(ProfAccount* account)
|
||||
{
|
||||
if (!account || !account->jid) {
|
||||
log_error("flatfile: cannot init without account JID");
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
g_free(g_flatfile_account_jid);
|
||||
g_flatfile_account_jid = g_strdup(account->jid);
|
||||
|
||||
// Ensure base directory exists
|
||||
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);
|
||||
|
||||
if (!ff_ensure_dir(base_dir)) {
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
if (g_contact_states) {
|
||||
g_hash_table_destroy(g_contact_states);
|
||||
}
|
||||
g_contact_states = g_hash_table_new_full(g_str_hash, g_str_equal,
|
||||
g_free, (GDestroyNotify)ff_state_free);
|
||||
|
||||
log_info("Initialized flat-file database backend: %s", base_dir);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static void
|
||||
_flatfile_close(void)
|
||||
{
|
||||
if (g_contact_states) {
|
||||
g_hash_table_destroy(g_contact_states);
|
||||
g_contact_states = NULL;
|
||||
}
|
||||
g_free(g_flatfile_account_jid);
|
||||
g_flatfile_account_jid = NULL;
|
||||
log_debug("flatfile: closed");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Backend callbacks: add message
|
||||
// =========================================================================
|
||||
|
||||
// =========================================================================
|
||||
// Incoming message validation helpers
|
||||
// =========================================================================
|
||||
|
||||
// Search log file for a line containing aid:{archive_id}, return TRUE if found.
|
||||
static gboolean
|
||||
_ff_has_archive_id(const char* log_path, const char* archive_id)
|
||||
{
|
||||
if (!log_path || !archive_id || strlen(archive_id) == 0)
|
||||
return FALSE;
|
||||
|
||||
FILE* fp = fopen(log_path, "r");
|
||||
if (!fp)
|
||||
return FALSE;
|
||||
|
||||
auto_gchar gchar* needle = g_strdup_printf("aid:%s", archive_id);
|
||||
gboolean found = FALSE;
|
||||
|
||||
while (!found) {
|
||||
gboolean trunc = FALSE;
|
||||
char* line = ff_readline(fp, &trunc);
|
||||
if (!line)
|
||||
break;
|
||||
if (trunc) {
|
||||
free(line);
|
||||
break;
|
||||
}
|
||||
if (strstr(line, needle)) {
|
||||
found = TRUE;
|
||||
}
|
||||
free(line);
|
||||
}
|
||||
|
||||
fclose(fp);
|
||||
return found;
|
||||
}
|
||||
|
||||
// Search log file for a line with stanza id matching replace_id, return the
|
||||
// from_jid of the original message (caller must g_free). Returns NULL if not found.
|
||||
static char*
|
||||
_ff_find_original_sender(const char* log_path, const char* replace_id)
|
||||
{
|
||||
if (!log_path || !replace_id || strlen(replace_id) == 0)
|
||||
return NULL;
|
||||
|
||||
FILE* fp = fopen(log_path, "r");
|
||||
if (!fp)
|
||||
return NULL;
|
||||
|
||||
auto_gchar gchar* needle = g_strdup_printf("id:%s", replace_id);
|
||||
char* sender = NULL;
|
||||
|
||||
while (!sender) {
|
||||
gboolean trunc = FALSE;
|
||||
char* line = ff_readline(fp, &trunc);
|
||||
if (!line)
|
||||
break;
|
||||
if (trunc) {
|
||||
free(line);
|
||||
break;
|
||||
}
|
||||
if (strstr(line, needle)) {
|
||||
ff_parsed_line_t* pl = ff_parse_line(line);
|
||||
if (pl && pl->stanza_id && g_strcmp0(pl->stanza_id, replace_id) == 0) {
|
||||
sender = g_strdup(pl->from_jid);
|
||||
}
|
||||
if (pl)
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
free(line);
|
||||
}
|
||||
|
||||
fclose(fp);
|
||||
return sender;
|
||||
}
|
||||
|
||||
static void
|
||||
_flatfile_add_incoming(ProfMessage* message)
|
||||
{
|
||||
if (!message || !message->from_jid)
|
||||
return;
|
||||
|
||||
const Jid* to_jid = message->to_jid ? message->to_jid : connection_get_jid();
|
||||
const char* type = ff_get_message_type_str(message->type);
|
||||
|
||||
// Determine contact JID for log path (same logic as _ff_add_message)
|
||||
const char* contact = NULL;
|
||||
const Jid* myjid = connection_get_jid();
|
||||
if (myjid && myjid->barejid && g_strcmp0(message->from_jid->barejid, myjid->barejid) == 0) {
|
||||
contact = to_jid->barejid;
|
||||
} else {
|
||||
contact = message->from_jid->barejid;
|
||||
}
|
||||
auto_gchar gchar* log_path = ff_get_log_path(contact);
|
||||
|
||||
// MAM dedup: skip if this stanza-id already exists in the log (XEP-0313 replay)
|
||||
if (message->stanzaid && !message->is_mam && log_path) {
|
||||
if (_ff_has_archive_id(log_path, message->stanzaid)) {
|
||||
log_error("flatfile: duplicate stanza-id '%s' from %s, skipping",
|
||||
message->stanzaid, message->from_jid->barejid);
|
||||
cons_show_error("Got a message with duplicate (server-generated) stanza-id from %s.",
|
||||
message->from_jid->fulljid);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// LMC sender validation: verify the correction comes from the original sender
|
||||
if (message->replace_id && log_path) {
|
||||
auto_gchar gchar* original_sender = _ff_find_original_sender(log_path, message->replace_id);
|
||||
if (original_sender && g_strcmp0(original_sender, message->from_jid->barejid) != 0) {
|
||||
log_error("flatfile: LMC sender mismatch — corrected msg sender: %s, original: %s, replace-id: %s",
|
||||
message->from_jid->barejid, original_sender, message->replace_id);
|
||||
cons_show_error("%s sent a message correction with mismatched sender. See log for details.",
|
||||
message->from_jid->barejid);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_ff_add_message(type, message->id, message->stanzaid, message->replace_id,
|
||||
message->from_jid->barejid, message->from_jid->resourcepart,
|
||||
to_jid->barejid, message->plain,
|
||||
message->timestamp, message->enc);
|
||||
}
|
||||
|
||||
static void
|
||||
_flatfile_add_outgoing_chat(const char* const id, const char* const barejid,
|
||||
const char* const message, const char* const replace_id, prof_enc_t enc)
|
||||
{
|
||||
const Jid* myjid = connection_get_jid();
|
||||
_ff_add_message("chat", id, NULL, replace_id,
|
||||
myjid ? myjid->barejid : "me", myjid ? myjid->resourcepart : NULL,
|
||||
barejid, message, NULL, enc);
|
||||
}
|
||||
|
||||
static void
|
||||
_flatfile_add_outgoing_muc(const char* const id, const char* const barejid,
|
||||
const char* const message, const char* const replace_id, prof_enc_t enc)
|
||||
{
|
||||
const Jid* myjid = connection_get_jid();
|
||||
_ff_add_message("muc", id, NULL, replace_id,
|
||||
myjid ? myjid->barejid : "me", myjid ? myjid->resourcepart : NULL,
|
||||
barejid, message, NULL, enc);
|
||||
}
|
||||
|
||||
static void
|
||||
_flatfile_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)
|
||||
{
|
||||
const Jid* myjid = connection_get_jid();
|
||||
_ff_add_message("mucpm", id, NULL, replace_id,
|
||||
myjid ? myjid->barejid : "me", myjid ? myjid->resourcepart : NULL,
|
||||
barejid, message, NULL, enc);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Read logic
|
||||
// =========================================================================
|
||||
|
||||
// Apply LMC: for messages with corrects:X, find original and replace its text
|
||||
static void
|
||||
_ff_apply_lmc(GSList* parsed_lines, GSList** result)
|
||||
{
|
||||
// Build a hash: stanza_id -> parsed_line for quick lookup
|
||||
GHashTable* id_map = g_hash_table_new(g_str_hash, g_str_equal);
|
||||
for (GSList* l = parsed_lines; l; l = l->next) {
|
||||
ff_parsed_line_t* pl = l->data;
|
||||
if (pl->stanza_id && strlen(pl->stanza_id) > 0) {
|
||||
g_hash_table_insert(id_map, pl->stanza_id, pl);
|
||||
}
|
||||
}
|
||||
|
||||
// Track which lines are corrections (skip them in output, apply to originals)
|
||||
GHashTable* corrections = g_hash_table_new(g_direct_hash, g_direct_equal);
|
||||
// Map: original line ptr -> latest correcting line ptr
|
||||
GHashTable* correction_map = g_hash_table_new(g_direct_hash, g_direct_equal);
|
||||
|
||||
for (GSList* l = parsed_lines; l; l = l->next) {
|
||||
ff_parsed_line_t* pl = l->data;
|
||||
if (pl->replace_id && strlen(pl->replace_id) > 0) {
|
||||
ff_parsed_line_t* original = g_hash_table_lookup(id_map, pl->replace_id);
|
||||
if (original) {
|
||||
// Follow chain to the root original, with cycle/depth guard
|
||||
ff_parsed_line_t* root = original;
|
||||
GHashTable* visited = g_hash_table_new(g_direct_hash, g_direct_equal);
|
||||
g_hash_table_insert(visited, root, root);
|
||||
int depth = 0;
|
||||
while (root->replace_id && strlen(root->replace_id) > 0 && depth < FF_MAX_LMC_DEPTH) {
|
||||
ff_parsed_line_t* parent = g_hash_table_lookup(id_map, root->replace_id);
|
||||
if (parent && !g_hash_table_contains(visited, parent)) {
|
||||
g_hash_table_insert(visited, parent, parent);
|
||||
root = parent;
|
||||
depth++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
g_hash_table_destroy(visited);
|
||||
if (depth >= FF_MAX_LMC_DEPTH) {
|
||||
log_warning("flatfile: LMC correction chain too deep (>%d), ignoring", FF_MAX_LMC_DEPTH);
|
||||
}
|
||||
g_hash_table_insert(correction_map, root, pl);
|
||||
g_hash_table_insert(corrections, pl, pl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build result: for each non-correction line, output it (with corrected text if applicable)
|
||||
for (GSList* l = parsed_lines; l; l = l->next) {
|
||||
ff_parsed_line_t* pl = l->data;
|
||||
if (g_hash_table_lookup(corrections, pl)) {
|
||||
continue; // skip correction-only lines
|
||||
}
|
||||
|
||||
ff_parsed_line_t* latest_correction = g_hash_table_lookup(correction_map, pl);
|
||||
ProfMessage* msg;
|
||||
if (latest_correction) {
|
||||
// Use corrected text with original's metadata
|
||||
msg = ff_parsed_to_profmessage(pl);
|
||||
g_free(msg->plain);
|
||||
msg->plain = g_strdup(latest_correction->message ? latest_correction->message : "");
|
||||
} else {
|
||||
msg = ff_parsed_to_profmessage(pl);
|
||||
}
|
||||
*result = g_slist_append(*result, msg);
|
||||
}
|
||||
|
||||
g_hash_table_destroy(id_map);
|
||||
g_hash_table_destroy(corrections);
|
||||
g_hash_table_destroy(correction_map);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Backend callbacks: query history
|
||||
// =========================================================================
|
||||
|
||||
static db_history_result_t
|
||||
_flatfile_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_flatfile_account_jid) {
|
||||
log_warning("flatfile: get_previous_chat called but not initialized");
|
||||
return DB_RESPONSE_ERROR;
|
||||
}
|
||||
|
||||
const Jid* myjid = connection_get_jid();
|
||||
if (!myjid || !myjid->barejid) {
|
||||
log_warning("flatfile: no connection JID available");
|
||||
return DB_RESPONSE_ERROR;
|
||||
}
|
||||
|
||||
// Get or create per-contact state with sparse index
|
||||
ff_contact_state_t* state = _ff_get_state(contact_barejid);
|
||||
if (!state)
|
||||
return DB_RESPONSE_ERROR;
|
||||
|
||||
// Ensure index is up-to-date (stat check -> build/extend if file changed)
|
||||
if (!ff_state_ensure_fresh(state))
|
||||
return DB_RESPONSE_EMPTY;
|
||||
if (state->total_lines == 0)
|
||||
return DB_RESPONSE_EMPTY;
|
||||
|
||||
// Reset cursor for filtered (non-Page-Up) queries
|
||||
if (start_time)
|
||||
state->cursor_offset = -1;
|
||||
|
||||
// Determine read byte-range using the sparse index
|
||||
off_t read_from = state->bom_len;
|
||||
off_t read_to = state->stamp.size;
|
||||
|
||||
// Use stored cursor for sequential Page Up (skip bisect)
|
||||
if (!start_time && end_time && state->cursor_offset >= 0) {
|
||||
read_to = state->cursor_offset;
|
||||
} else if (end_time) {
|
||||
// Upper-bound: find first index entry AFTER end_time
|
||||
GDateTime* edt = g_date_time_new_from_iso8601(end_time, NULL);
|
||||
if (edt) {
|
||||
gint64 end_epoch = g_date_time_to_unix(edt);
|
||||
g_date_time_unref(edt);
|
||||
size_t lo = 0, hi = state->n_entries;
|
||||
while (lo < hi) {
|
||||
size_t mid = lo + (hi - lo) / 2;
|
||||
if (state->entries[mid].timestamp_epoch <= end_epoch)
|
||||
lo = mid + 1;
|
||||
else
|
||||
hi = mid;
|
||||
}
|
||||
// lo = first entry with timestamp > end_epoch
|
||||
// Use next entry's offset as read_to (+ 1 entry margin)
|
||||
if (lo + 1 < state->n_entries)
|
||||
read_to = state->entries[lo + 1].byte_offset;
|
||||
// else read_to stays at EOF
|
||||
}
|
||||
}
|
||||
|
||||
if (start_time) {
|
||||
read_from = ff_state_offset_for_time(state, start_time);
|
||||
}
|
||||
|
||||
// For "last N messages": back up from read_to using index
|
||||
if (!from_start) {
|
||||
int margin_entries = (MESSAGES_TO_RETRIEVE * 3) / FF_INDEX_STEP + 2;
|
||||
|
||||
// Find index entry closest to (but before) read_to
|
||||
size_t entry_idx = 0;
|
||||
for (size_t i = 0; i < state->n_entries; i++) {
|
||||
if (state->entries[i].byte_offset >= read_to)
|
||||
break;
|
||||
entry_idx = i;
|
||||
}
|
||||
|
||||
size_t start_entry = entry_idx > (size_t)margin_entries
|
||||
? entry_idx - margin_entries
|
||||
: 0;
|
||||
off_t backed = state->entries[start_entry].byte_offset;
|
||||
if (backed > read_from)
|
||||
read_from = backed;
|
||||
}
|
||||
|
||||
// Open file and read lines in [read_from, read_to)
|
||||
FILE* fp = fopen(state->filepath, "r");
|
||||
if (!fp)
|
||||
return DB_RESPONSE_EMPTY;
|
||||
|
||||
fseek(fp, read_from, SEEK_SET);
|
||||
|
||||
// Pre-parse time filter boundaries once (avoid per-line allocation)
|
||||
GDateTime* start_dt = start_time ? g_date_time_new_from_iso8601(start_time, NULL) : NULL;
|
||||
GDateTime* end_dt = end_time ? g_date_time_new_from_iso8601(end_time, NULL) : NULL;
|
||||
|
||||
GSList* all_parsed = NULL;
|
||||
while (1) {
|
||||
off_t pos = ftell(fp);
|
||||
if (pos < 0 || pos >= read_to)
|
||||
break;
|
||||
|
||||
gboolean truncated = FALSE;
|
||||
char* buf = ff_readline(fp, &truncated);
|
||||
if (!buf)
|
||||
break;
|
||||
if (truncated) {
|
||||
free(buf);
|
||||
break;
|
||||
}
|
||||
|
||||
ff_parsed_line_t* pl = ff_parse_line(buf);
|
||||
free(buf);
|
||||
if (!pl)
|
||||
continue;
|
||||
|
||||
pl->file_offset = pos;
|
||||
|
||||
// Time filters
|
||||
if (start_dt && g_date_time_compare(pl->timestamp, start_dt) <= 0) {
|
||||
ff_parsed_line_free(pl);
|
||||
continue;
|
||||
}
|
||||
if (end_dt && g_date_time_compare(pl->timestamp, end_dt) >= 0) {
|
||||
ff_parsed_line_free(pl);
|
||||
continue;
|
||||
}
|
||||
|
||||
// JID filter
|
||||
gboolean matches = FALSE;
|
||||
if (myjid->barejid && contact_barejid) {
|
||||
if ((g_strcmp0(pl->from_jid, contact_barejid) == 0)
|
||||
|| (g_strcmp0(pl->from_jid, myjid->barejid) == 0)) {
|
||||
matches = TRUE;
|
||||
}
|
||||
} else {
|
||||
matches = TRUE;
|
||||
}
|
||||
if (!matches) {
|
||||
ff_parsed_line_free(pl);
|
||||
continue;
|
||||
}
|
||||
|
||||
all_parsed = g_slist_append(all_parsed, pl);
|
||||
}
|
||||
fclose(fp);
|
||||
|
||||
if (start_dt)
|
||||
g_date_time_unref(start_dt);
|
||||
if (end_dt)
|
||||
g_date_time_unref(end_dt);
|
||||
|
||||
if (!all_parsed)
|
||||
return DB_RESPONSE_EMPTY;
|
||||
|
||||
// Update cursor: byte offset of oldest parsed message in this batch
|
||||
ff_parsed_line_t* oldest = all_parsed->data;
|
||||
state->cursor_offset = oldest->file_offset;
|
||||
|
||||
// Apply LMC corrections and build ProfMessage list
|
||||
GSList* pre_result = NULL;
|
||||
_ff_apply_lmc(all_parsed, &pre_result);
|
||||
g_slist_free_full(all_parsed, (GDestroyNotify)ff_parsed_line_free);
|
||||
|
||||
if (!pre_result)
|
||||
return DB_RESPONSE_EMPTY;
|
||||
|
||||
// Paginate: keep first or last MESSAGES_TO_RETRIEVE
|
||||
guint total = g_slist_length(pre_result);
|
||||
if (total > MESSAGES_TO_RETRIEVE) {
|
||||
if (from_start) {
|
||||
GSList* nth = g_slist_nth(pre_result, MESSAGES_TO_RETRIEVE);
|
||||
if (nth) {
|
||||
GSList* prev = g_slist_nth(pre_result, MESSAGES_TO_RETRIEVE - 1);
|
||||
if (prev)
|
||||
prev->next = NULL;
|
||||
g_slist_free_full(nth, (GDestroyNotify)message_free);
|
||||
}
|
||||
} else {
|
||||
guint skip = total - MESSAGES_TO_RETRIEVE;
|
||||
GSList* keep_start = g_slist_nth(pre_result, skip);
|
||||
GSList* prev = g_slist_nth(pre_result, skip - 1);
|
||||
if (prev)
|
||||
prev->next = NULL;
|
||||
g_slist_free_full(pre_result, (GDestroyNotify)message_free);
|
||||
pre_result = keep_start;
|
||||
}
|
||||
}
|
||||
|
||||
if (flip) {
|
||||
pre_result = g_slist_reverse(pre_result);
|
||||
}
|
||||
|
||||
*result = pre_result;
|
||||
return g_slist_length(*result) != 0 ? DB_RESPONSE_SUCCESS : DB_RESPONSE_EMPTY;
|
||||
}
|
||||
|
||||
static ProfMessage*
|
||||
_flatfile_get_limits_info(const gchar* const contact_barejid, gboolean is_last)
|
||||
{
|
||||
ProfMessage* msg = message_init();
|
||||
|
||||
if (!g_flatfile_account_jid || !contact_barejid) {
|
||||
if (is_last)
|
||||
msg->timestamp = g_date_time_new_now_utc();
|
||||
return msg;
|
||||
}
|
||||
|
||||
ff_contact_state_t* state = _ff_get_state(contact_barejid);
|
||||
if (!state) {
|
||||
if (is_last)
|
||||
msg->timestamp = g_date_time_new_now_utc();
|
||||
return msg;
|
||||
}
|
||||
|
||||
if (!ff_state_ensure_fresh(state) || state->total_lines == 0) {
|
||||
if (is_last)
|
||||
msg->timestamp = g_date_time_new_now_utc();
|
||||
return msg;
|
||||
}
|
||||
|
||||
FILE* fp = fopen(state->filepath, "r");
|
||||
if (!fp) {
|
||||
if (is_last)
|
||||
msg->timestamp = g_date_time_new_now_utc();
|
||||
return msg;
|
||||
}
|
||||
|
||||
ff_parsed_line_t* found = NULL;
|
||||
|
||||
if (!is_last) {
|
||||
// First message: start from beginning (skip BOM)
|
||||
fseek(fp, state->bom_len, SEEK_SET);
|
||||
char* buf;
|
||||
while ((buf = ff_readline(fp, NULL)) != NULL) {
|
||||
found = ff_parse_line(buf);
|
||||
free(buf);
|
||||
if (found)
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Last message: seek near end using index for efficiency
|
||||
off_t seek_pos = 0;
|
||||
if (state->n_entries > 0)
|
||||
seek_pos = state->entries[state->n_entries - 1].byte_offset;
|
||||
fseek(fp, seek_pos, SEEK_SET);
|
||||
|
||||
char* buf;
|
||||
while ((buf = ff_readline(fp, NULL)) != NULL) {
|
||||
ff_parsed_line_t* pl = ff_parse_line(buf);
|
||||
free(buf);
|
||||
if (pl) {
|
||||
if (found)
|
||||
ff_parsed_line_free(found);
|
||||
found = pl;
|
||||
}
|
||||
}
|
||||
}
|
||||
fclose(fp);
|
||||
|
||||
if (found) {
|
||||
msg->stanzaid = found->archive_id ? g_strdup(found->archive_id) : NULL;
|
||||
msg->timestamp = g_date_time_ref(found->timestamp);
|
||||
ff_parsed_line_free(found);
|
||||
} else if (is_last) {
|
||||
msg->timestamp = g_date_time_new_now_utc();
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Backend vtable
|
||||
// =========================================================================
|
||||
|
||||
static db_backend_t flatfile_backend = {
|
||||
.name = "flatfile",
|
||||
.init = _flatfile_init,
|
||||
.close = _flatfile_close,
|
||||
.add_incoming = _flatfile_add_incoming,
|
||||
.add_outgoing_chat = _flatfile_add_outgoing_chat,
|
||||
.add_outgoing_muc = _flatfile_add_outgoing_muc,
|
||||
.add_outgoing_muc_pm = _flatfile_add_outgoing_muc_pm,
|
||||
.get_previous_chat = _flatfile_get_previous_chat,
|
||||
.get_limits_info = _flatfile_get_limits_info,
|
||||
.verify_integrity = ff_verify_integrity,
|
||||
};
|
||||
|
||||
db_backend_t*
|
||||
db_backend_flatfile(void)
|
||||
{
|
||||
return &flatfile_backend;
|
||||
}
|
||||
147
src/database_flatfile.h
Normal file
147
src/database_flatfile.h
Normal file
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* database_flatfile.h
|
||||
* vim: expandtab:ts=4:sts=4:sw=4
|
||||
*
|
||||
* Copyright (C) 2026 Profanity Contributors
|
||||
*
|
||||
* 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/>.
|
||||
*
|
||||
* 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_HEADER "# profanity chat log — UTF-8, LF line endings\n# vim: set fileencoding=utf-8 fileformat=unix :\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* message;
|
||||
off_t file_offset;
|
||||
} ff_parsed_line_t;
|
||||
|
||||
// --- Sparse index for single-file lookup ---
|
||||
|
||||
#define FF_INDEX_STEP 500
|
||||
|
||||
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;
|
||||
off_t cursor_offset;
|
||||
int bom_len;
|
||||
} 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 ---
|
||||
|
||||
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* 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) ---
|
||||
|
||||
GSList* ff_verify_integrity(const gchar* const contact_barejid);
|
||||
|
||||
#endif
|
||||
754
src/database_flatfile_parser.c
Normal file
754
src/database_flatfile_parser.c
Normal file
@@ -0,0 +1,754 @@
|
||||
/*
|
||||
* database_flatfile_parser.c
|
||||
* vim: expandtab:ts=4:sts=4:sw=4
|
||||
*
|
||||
* Copyright (C) 2026 Profanity Contributors
|
||||
*
|
||||
* 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/>.
|
||||
*
|
||||
* 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 <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: '/', '\0', '..'
|
||||
// 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 '@' first
|
||||
char* step1 = str_replace(jid, "@", "_at_");
|
||||
if (!step1)
|
||||
return NULL;
|
||||
|
||||
// Replace '/' and '\\' with '_' to prevent path traversal
|
||||
GString* out = g_string_sized_new(strlen(step1));
|
||||
for (const char* p = step1; *p; p++) {
|
||||
if (*p == '/' || *p == '\\') {
|
||||
g_string_append_c(out, '_');
|
||||
} else {
|
||||
g_string_append_c(out, *p);
|
||||
}
|
||||
}
|
||||
free(step1);
|
||||
|
||||
// Collapse any remaining ".." sequences to "__" (belt-and-suspenders)
|
||||
char* result = g_string_free(out, FALSE);
|
||||
char* dotdot;
|
||||
while ((dotdot = strstr(result, "..")) != NULL) {
|
||||
dotdot[0] = '_';
|
||||
dotdot[1] = '_';
|
||||
}
|
||||
|
||||
// Reject empty result
|
||||
if (result[0] == '\0') {
|
||||
g_free(result);
|
||||
return NULL;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 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_DIR)) {
|
||||
// Verify it's not a symlink
|
||||
struct stat st;
|
||||
if (g_lstat(path, &st) == 0 && S_ISLNK(st.st_mode)) {
|
||||
log_error("flatfile: directory path is a symlink, refusing: %s", path);
|
||||
return FALSE;
|
||||
}
|
||||
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)) {
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
g_string_append_c(out, *p);
|
||||
}
|
||||
}
|
||||
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)) {
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
g_string_append_c(out, *p);
|
||||
}
|
||||
}
|
||||
return g_string_free(out, FALSE);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 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).
|
||||
// Use malloc() so callers can always use free() consistently.
|
||||
char* empty = malloc(1);
|
||||
if (empty) empty[0] = '\0';
|
||||
return empty;
|
||||
}
|
||||
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* 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:...]
|
||||
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);
|
||||
}
|
||||
g_string_append_c(meta, ']');
|
||||
|
||||
// Build sender — escape ": " in the resource part to prevent
|
||||
// the parser from splitting at the wrong point.
|
||||
GString* sender = g_string_new(from_jid ? from_jid : "unknown");
|
||||
if (from_resource && strlen(from_resource) > 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
|
||||
char* safe_msg = ff_escape_message(message_text);
|
||||
|
||||
// Build complete line and write with a single fwrite()
|
||||
GString* full_line = g_string_new(NULL);
|
||||
g_string_printf(full_line, "%s %s %s: %s\n",
|
||||
timestamp, meta->str, sender->str, safe_msg);
|
||||
|
||||
size_t to_write = full_line->len;
|
||||
size_t written = fwrite(full_line->str, 1, to_write, fp);
|
||||
if (written != to_write) {
|
||||
log_error("flatfile: partial write (%zu/%zu)", written, to_write);
|
||||
}
|
||||
|
||||
g_string_free(full_line, TRUE);
|
||||
g_free(safe_msg);
|
||||
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)) {
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
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->message);
|
||||
g_free(pl);
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// 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) {
|
||||
char* meta_content = g_strndup(bracket_start + 1, bracket_end - bracket_start - 1);
|
||||
|
||||
// Split by unescaped '|'
|
||||
char** parts = ff_split_meta(meta_content);
|
||||
if (parts) {
|
||||
int i = 0;
|
||||
for (; parts[i]; i++) {
|
||||
if (i == 0) {
|
||||
result->type = g_strdup(parts[i]);
|
||||
} else if (i == 1) {
|
||||
result->enc = g_strdup(parts[i]);
|
||||
} else if (g_str_has_prefix(parts[i], "id:")) {
|
||||
result->stanza_id = ff_unescape_meta_value(parts[i] + 3);
|
||||
} else if (g_str_has_prefix(parts[i], "aid:")) {
|
||||
result->archive_id = ff_unescape_meta_value(parts[i] + 4);
|
||||
} else if (g_str_has_prefix(parts[i], "corrects:")) {
|
||||
result->replace_id = ff_unescape_meta_value(parts[i] + 9);
|
||||
}
|
||||
}
|
||||
g_strfreev(parts);
|
||||
}
|
||||
g_free(meta_content);
|
||||
|
||||
// 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) {
|
||||
char* 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);
|
||||
}
|
||||
g_free(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 {
|
||||
// No closing bracket — malformed metadata
|
||||
ff_parsed_line_free(result);
|
||||
g_free(work);
|
||||
return NULL;
|
||||
}
|
||||
} 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);
|
||||
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;
|
||||
}
|
||||
309
src/database_flatfile_verify.c
Normal file
309
src/database_flatfile_verify.c
Normal file
@@ -0,0 +1,309 @@
|
||||
/*
|
||||
* database_flatfile_verify.c
|
||||
* vim: expandtab:ts=4:sts=4:sw=4
|
||||
*
|
||||
* Copyright (C) 2026 Profanity Contributors
|
||||
*
|
||||
* 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/>.
|
||||
*
|
||||
* Flat-file backend: integrity verification (/history verify).
|
||||
* Checks: parsability, timestamp ordering, duplicate IDs, broken LMC
|
||||
* references, file permissions, BOM, CRLF, UTF-8, control chars.
|
||||
*/
|
||||
|
||||
#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"
|
||||
|
||||
GSList*
|
||||
ff_verify_integrity(const gchar* const contact_barejid)
|
||||
{
|
||||
GSList* issues = NULL;
|
||||
|
||||
if (!g_flatfile_account_jid) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_ERROR;
|
||||
issue->file = g_strdup("N/A");
|
||||
issue->line = 0;
|
||||
issue->message = g_strdup("Flat-file backend not initialized");
|
||||
issues = g_slist_append(issues, issue);
|
||||
return issues;
|
||||
}
|
||||
|
||||
// If contact specified, verify just that contact; otherwise discover all contacts
|
||||
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_append(contact_dirs, g_strdup(cdir));
|
||||
} else {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_INFO;
|
||||
issue->file = g_strdup(contact_barejid);
|
||||
issue->line = 0;
|
||||
issue->message = g_strdup("No log files found for this contact");
|
||||
issues = g_slist_append(issues, issue);
|
||||
return issues;
|
||||
}
|
||||
} else {
|
||||
// Discover all contact directories
|
||||
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) {
|
||||
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_append(contact_dirs, full);
|
||||
} else {
|
||||
g_free(full);
|
||||
}
|
||||
}
|
||||
g_dir_close(dir);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify each contact directory (single history.log per contact)
|
||||
for (GSList* cd = contact_dirs; cd; cd = cd->next) {
|
||||
const char* cdir_path = cd->data;
|
||||
|
||||
auto_gchar gchar* filepath = g_strdup_printf("%s/history.log", cdir_path);
|
||||
if (!g_file_test(filepath, G_FILE_TEST_EXISTS))
|
||||
continue;
|
||||
|
||||
const char* basename = "history.log";
|
||||
|
||||
// Check file permissions
|
||||
struct stat st;
|
||||
if (g_stat(filepath, &st) == 0) {
|
||||
if ((st.st_mode & 0777) != (S_IRUSR | S_IWUSR)) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_WARNING;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = 0;
|
||||
issue->message = g_strdup_printf("File permissions are %o, expected 600 (sensitive data)", st.st_mode & 0777);
|
||||
issues = g_slist_append(issues, issue);
|
||||
}
|
||||
}
|
||||
|
||||
FILE* fp = fopen(filepath, "r");
|
||||
if (!fp)
|
||||
continue;
|
||||
|
||||
// BOM check
|
||||
int c1 = fgetc(fp);
|
||||
int c2 = fgetc(fp);
|
||||
int c3 = fgetc(fp);
|
||||
if (c1 == 0xEF && c2 == 0xBB && c3 == 0xBF) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_INFO;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = 0;
|
||||
issue->message = g_strdup("File has UTF-8 BOM — harmless but unnecessary");
|
||||
issues = g_slist_append(issues, issue);
|
||||
} else {
|
||||
fseek(fp, 0, SEEK_SET);
|
||||
}
|
||||
|
||||
char* buf = NULL;
|
||||
int lineno = 0;
|
||||
GDateTime* prev_ts = NULL;
|
||||
GHashTable* seen_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);
|
||||
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)) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_ERROR;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = lineno;
|
||||
issue->message = g_strdup_printf("Invalid UTF-8 at byte offset %ld", (long)(end - buf));
|
||||
issues = g_slist_append(issues, issue);
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (gsize i = 0; i < len; i++) {
|
||||
unsigned char ch = (unsigned char)buf[i];
|
||||
if (ch < 0x20 && ch != '\t') {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_WARNING;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = lineno;
|
||||
issue->message = g_strdup_printf("Contains control character 0x%02x", ch);
|
||||
issues = g_slist_append(issues, issue);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ff_parsed_line_t* pl = ff_parse_line(buf);
|
||||
if (!pl) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_ERROR;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = lineno;
|
||||
issue->message = g_strdup("Unparsable line");
|
||||
issues = g_slist_append(issues, issue);
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
|
||||
free(buf);
|
||||
|
||||
// Timestamp ordering
|
||||
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);
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_WARNING;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = lineno;
|
||||
issue->message = g_strdup_printf("Timestamp out of order (%s after %s)", ts_cur, ts_prev);
|
||||
issues = g_slist_append(issues, issue);
|
||||
}
|
||||
if (prev_ts)
|
||||
g_date_time_unref(prev_ts);
|
||||
prev_ts = g_date_time_ref(pl->timestamp);
|
||||
|
||||
// Duplicate stanza-id / archive-id
|
||||
if (pl->stanza_id && strlen(pl->stanza_id) > 0) {
|
||||
if (g_hash_table_contains(seen_ids, pl->stanza_id)) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_WARNING;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = lineno;
|
||||
issue->message = g_strdup_printf("Duplicate stanza-id \"%s\"", pl->stanza_id);
|
||||
issues = g_slist_append(issues, issue);
|
||||
} else {
|
||||
g_hash_table_insert(seen_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 && strlen(pl->archive_id) > 0) {
|
||||
if (g_hash_table_contains(seen_ids, pl->archive_id)) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_WARNING;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = lineno;
|
||||
issue->message = g_strdup_printf("Duplicate archive-id \"%s\"", pl->archive_id);
|
||||
issues = g_slist_append(issues, issue);
|
||||
} else {
|
||||
g_hash_table_insert(seen_ids, g_strdup(pl->archive_id), GINT_TO_POINTER(lineno));
|
||||
}
|
||||
}
|
||||
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
|
||||
fclose(fp);
|
||||
|
||||
if (has_crlf) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_WARNING;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = 0;
|
||||
issue->message = g_strdup("File uses Windows line endings (CRLF) — consider converting to LF");
|
||||
issues = g_slist_append(issues, issue);
|
||||
}
|
||||
|
||||
if (is_empty) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_INFO;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = 0;
|
||||
issue->message = g_strdup("File is empty (no message lines)");
|
||||
issues = g_slist_append(issues, issue);
|
||||
}
|
||||
|
||||
// Second pass: check LMC references
|
||||
fp = fopen(filepath, "r");
|
||||
if (fp) {
|
||||
int b1 = fgetc(fp);
|
||||
int b2 = fgetc(fp);
|
||||
int b3 = fgetc(fp);
|
||||
if (!(b1 == 0xEF && b2 == 0xBB && b3 == 0xBF))
|
||||
fseek(fp, 0, SEEK_SET);
|
||||
|
||||
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 && strlen(pl->replace_id) > 0) {
|
||||
if (!g_hash_table_contains(all_stanza_ids, pl->replace_id)) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_ERROR;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = lineno;
|
||||
issue->message = g_strdup_printf("Broken correction reference: corrects:%s not found", pl->replace_id);
|
||||
issues = g_slist_append(issues, issue);
|
||||
}
|
||||
}
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
fclose(fp);
|
||||
}
|
||||
|
||||
if (prev_ts)
|
||||
g_date_time_unref(prev_ts);
|
||||
g_hash_table_destroy(seen_ids);
|
||||
g_hash_table_destroy(all_stanza_ids);
|
||||
}
|
||||
|
||||
g_slist_free_full(contact_dirs, g_free);
|
||||
return issues;
|
||||
}
|
||||
819
src/database_sqlite.c
Normal file
819
src/database_sqlite.c
Normal file
@@ -0,0 +1,819 @@
|
||||
/*
|
||||
* 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, 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)
|
||||
{
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
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(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;
|
||||
}
|
||||
|
||||
gchar* sort1 = from_start ? "ASC" : "DESC";
|
||||
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, 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;
|
||||
}
|
||||
@@ -15,10 +15,10 @@
|
||||
* functional tests run less frequently than unit tests.
|
||||
*
|
||||
* Tests are organized into groups for better maintainability and parallel execution:
|
||||
* Group 1: Connect, Ping, Rooms, Software, Last Activity (19 tests)
|
||||
* Group 2: Message, Receipts, Roster, DB History (22 tests)
|
||||
* Group 3: Chat Session, Presence, Disconnect (22 tests)
|
||||
* Group 4: MUC, Carbons (21 tests)
|
||||
* Group 1: Connect, Ping, Rooms, Software
|
||||
* Group 2: Message, Receipts, Roster, Chat Session
|
||||
* Group 3: Presence, Disconnect
|
||||
* Group 4: MUC, Carbons
|
||||
*
|
||||
* Parallel execution:
|
||||
* ./functionaltests - run all tests sequentially
|
||||
@@ -53,7 +53,6 @@
|
||||
#include "test_muc.h"
|
||||
#include "test_disconnect.h"
|
||||
#include "test_lastactivity.h"
|
||||
#include "test_history.h"
|
||||
|
||||
/* Macro to wrap each test with setup/teardown functions */
|
||||
#define PROF_FUNC_TEST(test) cmocka_unit_test_setup_teardown(test, init_prof_test, close_prof_test)
|
||||
@@ -78,9 +77,8 @@ main(int argc, char* argv[])
|
||||
fprintf(stderr, "[PROF_TEST] Starting functional tests, group=%d\n", group);
|
||||
|
||||
/* ============================================================
|
||||
* GROUP 1: Connect, Ping, Rooms, Software, Last Activity
|
||||
* GROUP 1: Connect, Ping, Rooms, Software
|
||||
* Basic XMPP session establishment and server queries
|
||||
* (19 tests)
|
||||
* ============================================================ */
|
||||
const struct CMUnitTest group1_tests[] = {
|
||||
/* Connection tests - verify login, roster, bookmarks */
|
||||
@@ -114,9 +112,8 @@ main(int argc, char* argv[])
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
* GROUP 2: Message, Receipts, Roster, DB History
|
||||
* GROUP 2: Message, Receipts, Roster, Chat Session
|
||||
* Core messaging and contact management
|
||||
* (23 tests)
|
||||
* ============================================================ */
|
||||
const struct CMUnitTest group2_tests[] = {
|
||||
/* Basic message send/receive */
|
||||
@@ -136,29 +133,6 @@ main(int argc, char* argv[])
|
||||
PROF_FUNC_TEST(sends_remove_item_nick),
|
||||
PROF_FUNC_TEST(sends_nick_change),
|
||||
|
||||
/* Database persistence - message write+read round-trip */
|
||||
PROF_FUNC_TEST(message_db_history_on_reopen),
|
||||
PROF_FUNC_TEST(message_db_history_multiple),
|
||||
PROF_FUNC_TEST(message_db_history_contact_isolation),
|
||||
PROF_FUNC_TEST(message_db_history_special_chars),
|
||||
PROF_FUNC_TEST(message_db_history_outgoing),
|
||||
PROF_FUNC_TEST(message_db_history_dialog),
|
||||
PROF_FUNC_TEST(message_db_history_empty),
|
||||
PROF_FUNC_TEST(message_db_history_long_message),
|
||||
PROF_FUNC_TEST(message_db_history_newline),
|
||||
PROF_FUNC_TEST(message_db_history_service_chars),
|
||||
#ifdef HAVE_HISTORY_VERIFY
|
||||
PROF_FUNC_TEST(message_db_history_verify),
|
||||
#endif
|
||||
PROF_FUNC_TEST(message_db_history_lmc),
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
* GROUP 3: Chat Session, Presence, Disconnect
|
||||
* Session routing, status management, clean teardown
|
||||
* (22 tests)
|
||||
* ============================================================ */
|
||||
const struct CMUnitTest group3_tests[] = {
|
||||
/* Chat session management - bare/full JID routing */
|
||||
PROF_FUNC_TEST(sends_message_to_barejid_when_contact_offline),
|
||||
PROF_FUNC_TEST(sends_message_to_barejid_when_contact_online),
|
||||
@@ -166,8 +140,13 @@ main(int argc, char* argv[])
|
||||
PROF_FUNC_TEST(sends_subsequent_messages_to_fulljid),
|
||||
PROF_FUNC_TEST(resets_to_barejid_after_presence_received),
|
||||
PROF_FUNC_TEST(new_session_when_message_received_from_different_fulljid),
|
||||
};
|
||||
|
||||
/* Presence - online/away/xa/dnd/chat status management */
|
||||
/* ============================================================
|
||||
* GROUP 3: Presence, Disconnect
|
||||
* Online/away/xa/dnd/chat status management
|
||||
* ============================================================ */
|
||||
const struct CMUnitTest group3_tests[] = {
|
||||
PROF_FUNC_TEST(presence_online),
|
||||
PROF_FUNC_TEST(presence_online_with_message),
|
||||
PROF_FUNC_TEST(presence_away),
|
||||
@@ -191,7 +170,6 @@ main(int argc, char* argv[])
|
||||
/* ============================================================
|
||||
* GROUP 4: MUC, Carbons
|
||||
* Multi-user chat and message synchronization
|
||||
* (21 tests)
|
||||
* ============================================================ */
|
||||
const struct CMUnitTest group4_tests[] = {
|
||||
/* MUC room join with various options - XEP-0045 */
|
||||
@@ -234,9 +212,9 @@ main(int argc, char* argv[])
|
||||
const struct CMUnitTest* tests;
|
||||
size_t count;
|
||||
} groups[] = {
|
||||
{ "Group 1: Connect/Ping/Rooms/Software/LastActivity", group1_tests, ARRAY_SIZE(group1_tests) },
|
||||
{ "Group 2: Message/Receipts/Roster/DBHistory", group2_tests, ARRAY_SIZE(group2_tests) },
|
||||
{ "Group 3: Session/Presence/Disconnect", group3_tests, ARRAY_SIZE(group3_tests) },
|
||||
{ "Group 1: Connect/Ping/Rooms/Software", group1_tests, ARRAY_SIZE(group1_tests) },
|
||||
{ "Group 2: Message/Receipts/Roster/Session", group2_tests, ARRAY_SIZE(group2_tests) },
|
||||
{ "Group 3: Presence/Disconnect", group3_tests, ARRAY_SIZE(group3_tests) },
|
||||
{ "Group 4: MUC/Carbons", group4_tests, ARRAY_SIZE(group4_tests) },
|
||||
};
|
||||
const int num_groups = ARRAY_SIZE(groups);
|
||||
|
||||
@@ -315,6 +315,20 @@ init_prof_test(void **state)
|
||||
_create_chatlogs_dir();
|
||||
_create_logs_dir();
|
||||
|
||||
/* If PROF_FLATFILE=1 is set, write a profrc that selects the flat-file backend */
|
||||
const char *flatfile_env = getenv("PROF_FLATFILE");
|
||||
if (flatfile_env && strcmp(flatfile_env, "1") == 0) {
|
||||
char profrc_path[512];
|
||||
snprintf(profrc_path, sizeof(profrc_path),
|
||||
"%s/profanity/profrc", xdg_config_home);
|
||||
FILE *prc = fopen(profrc_path, "w");
|
||||
if (prc) {
|
||||
fprintf(prc, "[logging]\ndblog=flatfile\n");
|
||||
fclose(prc);
|
||||
printf("[PROF_TEST] Wrote profrc with dblog=flatfile: %s\n", profrc_path);
|
||||
}
|
||||
}
|
||||
|
||||
prof_start();
|
||||
int prof_started = prof_output_regex("CProof\\. Type /help for help information\\.");
|
||||
assert_true(prof_started);
|
||||
|
||||
@@ -1,475 +0,0 @@
|
||||
/*
|
||||
* test_history.c
|
||||
*
|
||||
* Functional test for database message persistence.
|
||||
* Verifies that a received chat message is written to the database
|
||||
* and loaded back as history when the chat window is reopened.
|
||||
*
|
||||
* These tests only exercise the public log_database API through normal
|
||||
* profanity UI operations, so they work with any storage backend.
|
||||
*/
|
||||
|
||||
#include <glib.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "prof_cmocka.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <stabber.h>
|
||||
|
||||
#include "proftest.h"
|
||||
|
||||
/*
|
||||
* Test: message written to DB is loaded as history on chat window reopen.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Connect and enable /history on (enables PREF_CHLOG + PREF_HISTORY)
|
||||
* 2. Receive a chat message while on the console window — the message
|
||||
* body is NOT rendered to the terminal (only a console notification
|
||||
* "<< chat message: ... (win N)" appears), but the message IS
|
||||
* written to the database via chat_log_msg_in()
|
||||
* 3. Close the auto-created chat window (destroying its in-memory buffer)
|
||||
* 4. Reopen the chat window with /msg — chatwin_new() calls
|
||||
* _chatwin_history() which reads from log_database_get_previous_chat()
|
||||
* 5. Assert the message body appears in the terminal output, proving
|
||||
* it was persisted to and read back from the database
|
||||
*/
|
||||
void
|
||||
message_db_history_on_reopen(void **state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
/* Enable chat logging and history display */
|
||||
prof_input("/history on");
|
||||
assert_true(prof_output_regex("Chat history enabled\\."));
|
||||
|
||||
/* Receive a message while on the console window.
|
||||
* The body text is NOT printed to the visible terminal — only the
|
||||
* console notification appears. The message is logged to the DB. */
|
||||
stbbr_send(
|
||||
"<message id='hist-db-test-1' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/phone' type='chat'>"
|
||||
"<body>persistence-roundtrip-check-42</body>"
|
||||
"</message>");
|
||||
|
||||
/* Wait for the console notification (proves message was received).
|
||||
* buddy1@localhost is in the roster as "Buddy1", and PREF_RESOURCE_MESSAGE
|
||||
* is on by default, so the display name is "Buddy1/phone". */
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)"));
|
||||
|
||||
/* Close the chat window that was auto-created for the incoming message.
|
||||
* This destroys its in-memory buffer so the text is gone from UI. */
|
||||
prof_input("/close 2");
|
||||
assert_true(prof_output_exact("Closed window 2"));
|
||||
|
||||
/* Reopen the chat window — chatwin_new() will load history from DB.
|
||||
* The message body should now appear on screen for the first time. */
|
||||
prof_input("/msg buddy1@localhost");
|
||||
|
||||
/* The history-loaded message must contain the body we sent earlier.
|
||||
* Since the body was never rendered to the terminal before (it was
|
||||
* only in the hidden window's ncurses buffer which was destroyed),
|
||||
* finding it now proves the database write+read round-trip works. */
|
||||
assert_true(prof_output_exact("persistence-roundtrip-check-42"));
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: multiple messages from the same contact are all preserved.
|
||||
*
|
||||
* Uses different resources so each console notification is unique and
|
||||
* we can reliably synchronise on each delivery before proceeding.
|
||||
*/
|
||||
void
|
||||
message_db_history_multiple(void **state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
prof_input("/history on");
|
||||
assert_true(prof_output_regex("Chat history enabled\\."));
|
||||
|
||||
/* Three messages from different resources → distinct notifications */
|
||||
stbbr_send(
|
||||
"<message id='multi1' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/phone' type='chat'>"
|
||||
"<body>db-multi-first-aaa</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)"));
|
||||
|
||||
stbbr_send(
|
||||
"<message id='multi2' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/laptop' type='chat'>"
|
||||
"<body>db-multi-second-bbb</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/laptop (win 2)"));
|
||||
|
||||
stbbr_send(
|
||||
"<message id='multi3' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/tablet' type='chat'>"
|
||||
"<body>db-multi-third-ccc</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/tablet (win 2)"));
|
||||
|
||||
/* Close and reopen */
|
||||
prof_input("/close 2");
|
||||
assert_true(prof_output_exact("Closed window 2"));
|
||||
|
||||
prof_input("/msg buddy1@localhost");
|
||||
|
||||
/* All three must be present in history */
|
||||
assert_true(prof_output_exact("db-multi-first-aaa"));
|
||||
assert_true(prof_output_exact("db-multi-second-bbb"));
|
||||
assert_true(prof_output_exact("db-multi-third-ccc"));
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: messages are stored per-contact — buddy1's history does not
|
||||
* leak into buddy2's window.
|
||||
*
|
||||
* 1. Receive from buddy1 and buddy2 while on console (bodies hidden)
|
||||
* 2. Close all chat windows
|
||||
* 3. Open buddy1 → verify buddy1's body present, buddy2's body absent
|
||||
*/
|
||||
void
|
||||
message_db_history_contact_isolation(void **state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
prof_input("/history on");
|
||||
assert_true(prof_output_regex("Chat history enabled\\."));
|
||||
|
||||
/* Receive from buddy1 */
|
||||
stbbr_send(
|
||||
"<message id='iso1' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/phone' type='chat'>"
|
||||
"<body>isolation-buddy1-only-xyzzy</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)"));
|
||||
|
||||
/* Receive from buddy2 */
|
||||
stbbr_send(
|
||||
"<message id='iso2' to='stabber@localhost' "
|
||||
"from='buddy2@localhost/phone' type='chat'>"
|
||||
"<body>isolation-buddy2-only-plugh</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy2/phone (win 3)"));
|
||||
|
||||
/* Close all chat windows at once */
|
||||
prof_input("/close all");
|
||||
assert_true(prof_output_exact("Closed 2 windows."));
|
||||
|
||||
/* Open buddy1 — history should contain ONLY buddy1's message */
|
||||
prof_input("/msg buddy1@localhost");
|
||||
assert_true(prof_output_exact("isolation-buddy1-only-xyzzy"));
|
||||
|
||||
/* buddy2's body was never printed to the terminal (received on
|
||||
* console), so finding it now would mean cross-contact leakage */
|
||||
prof_timeout(3);
|
||||
assert_false(prof_output_exact("isolation-buddy2-only-plugh"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: special characters survive the DB write+read round-trip.
|
||||
*
|
||||
* The XMPP body uses XML entities (& < > ") which the
|
||||
* XML parser decodes before storage. The DB must preserve the decoded
|
||||
* characters and return them unchanged.
|
||||
*/
|
||||
void
|
||||
message_db_history_special_chars(void **state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
prof_input("/history on");
|
||||
assert_true(prof_output_regex("Chat history enabled\\."));
|
||||
|
||||
stbbr_send(
|
||||
"<message id='spec1' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/phone' type='chat'>"
|
||||
"<body>chars: & <tag> "quoted"</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)"));
|
||||
|
||||
prof_input("/close 2");
|
||||
assert_true(prof_output_exact("Closed window 2"));
|
||||
|
||||
prof_input("/msg buddy1@localhost");
|
||||
|
||||
/* The decoded text must appear exactly as-is */
|
||||
assert_true(prof_output_exact("chars: & <tag> \"quoted\""));
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: outgoing message (sent via /msg) persists in DB.
|
||||
*
|
||||
* NOTE: The outgoing body IS rendered to the terminal when sent. Since
|
||||
* prof_output_exact searches the cumulative buffer, the assertion alone
|
||||
* does not conclusively prove DB persistence. However, combined with
|
||||
* the incoming tests, this verifies the end-to-end outgoing write path
|
||||
* and catches crashes in the outgoing DB code.
|
||||
*/
|
||||
void
|
||||
message_db_history_outgoing(void **state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
prof_input("/history on");
|
||||
assert_true(prof_output_regex("Chat history enabled\\."));
|
||||
|
||||
/* Send outgoing — opens and focuses chat window */
|
||||
prof_input("/msg buddy1@localhost db-outgoing-sent-42");
|
||||
assert_true(prof_output_regex("me: .+db-outgoing-sent-42"));
|
||||
|
||||
prof_input("/close 2");
|
||||
assert_true(prof_output_exact("Closed window 2"));
|
||||
|
||||
/* Reopen — history should load the outgoing message */
|
||||
prof_input("/msg buddy1@localhost");
|
||||
assert_true(prof_output_exact("db-outgoing-sent-42"));
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: a dialog (outgoing + incoming) is fully preserved in history.
|
||||
*
|
||||
* Sends an outgoing message, then closes the window. A reply arrives
|
||||
* while on the console (body never rendered). After reopen, both sides
|
||||
* of the conversation appear — proving the incoming read from DB works
|
||||
* and the outgoing write path completed without error.
|
||||
*/
|
||||
void
|
||||
message_db_history_dialog(void **state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
prof_input("/history on");
|
||||
assert_true(prof_output_regex("Chat history enabled\\."));
|
||||
|
||||
/* Phase 1: send outgoing (renders to terminal) */
|
||||
prof_input("/msg buddy1@localhost dialog-out-msg-42");
|
||||
assert_true(prof_output_regex("me: .+dialog-out-msg-42"));
|
||||
|
||||
/* Return to console */
|
||||
prof_input("/close 2");
|
||||
assert_true(prof_output_exact("Closed window 2"));
|
||||
|
||||
/* Phase 2: receive reply on console (body NOT in terminal buffer) */
|
||||
stbbr_send(
|
||||
"<message id='dlg-in' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/phone' type='chat'>"
|
||||
"<body>dialog-in-reply-77</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)"));
|
||||
|
||||
/* Close the auto-created window */
|
||||
prof_input("/close 2");
|
||||
assert_true(prof_output_exact("Closed window 2"));
|
||||
|
||||
/* Phase 3: reopen — history should have both directions */
|
||||
prof_input("/msg buddy1@localhost");
|
||||
|
||||
/* Incoming body conclusively proves DB round-trip */
|
||||
assert_true(prof_output_exact("dialog-in-reply-77"));
|
||||
/* Outgoing body is consistent with DB persistence */
|
||||
assert_true(prof_output_exact("dialog-out-msg-42"));
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: opening a chat window for a contact with no prior messages
|
||||
* does not crash and the window is usable.
|
||||
*/
|
||||
void
|
||||
message_db_history_empty(void **state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
prof_input("/history on");
|
||||
assert_true(prof_output_regex("Chat history enabled\\."));
|
||||
|
||||
/* buddy2 has never exchanged messages — history is empty */
|
||||
prof_input("/msg buddy2@localhost");
|
||||
assert_true(prof_output_exact("buddy2@localhost"));
|
||||
|
||||
/* Verify the window is functional by sending a message */
|
||||
prof_input("empty-history-smoke-test");
|
||||
assert_true(prof_output_regex("me: .+empty-history-smoke-test"));
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: a long message (1000+ characters) is not truncated by the DB.
|
||||
*/
|
||||
void
|
||||
message_db_history_long_message(void **state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
prof_input("/history on");
|
||||
assert_true(prof_output_regex("Chat history enabled\\."));
|
||||
|
||||
/* Build a 1020-character body: 1000 x 'A' + unique suffix */
|
||||
char body[1100];
|
||||
memset(body, 'A', 1000);
|
||||
strcpy(body + 1000, "-long-db-end-MARKER");
|
||||
|
||||
char xml[1500];
|
||||
snprintf(xml, sizeof(xml),
|
||||
"<message id='long1' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/phone' type='chat'>"
|
||||
"<body>%s</body>"
|
||||
"</message>", body);
|
||||
stbbr_send(xml);
|
||||
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)"));
|
||||
|
||||
prof_input("/close 2");
|
||||
assert_true(prof_output_exact("Closed window 2"));
|
||||
|
||||
prof_input("/msg buddy1@localhost");
|
||||
assert_true(prof_output_exact("-long-db-end-MARKER"));
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: message body containing embedded newlines (LF) is stored and
|
||||
* loaded correctly.
|
||||
*
|
||||
* Uses XML character reference for literal newline in body.
|
||||
*/
|
||||
void
|
||||
message_db_history_newline(void **state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
prof_input("/history on");
|
||||
assert_true(prof_output_regex("Chat history enabled\\."));
|
||||
|
||||
stbbr_send(
|
||||
"<message id='nl1' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/phone' type='chat'>"
|
||||
"<body>nl-first-line-7k nl-second-line-9m nl-third-line-2p</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)"));
|
||||
|
||||
prof_input("/close 2");
|
||||
assert_true(prof_output_exact("Closed window 2"));
|
||||
|
||||
prof_input("/msg buddy1@localhost");
|
||||
/* Each fragment must survive the round-trip */
|
||||
assert_true(prof_output_exact("nl-first-line-7k"));
|
||||
assert_true(prof_output_exact("nl-second-line-9m"));
|
||||
assert_true(prof_output_exact("nl-third-line-2p"));
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: characters that could break storage format (backslash, pipe,
|
||||
* percent, braces, brackets, equals) survive DB round-trip.
|
||||
*/
|
||||
void
|
||||
message_db_history_service_chars(void **state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
prof_input("/history on");
|
||||
assert_true(prof_output_regex("Chat history enabled\\."));
|
||||
|
||||
stbbr_send(
|
||||
"<message id='svc1' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/phone' type='chat'>"
|
||||
"<body>svc: a\\b c|d e%f {g} [h] i=j</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)"));
|
||||
|
||||
prof_input("/close 2");
|
||||
assert_true(prof_output_exact("Closed window 2"));
|
||||
|
||||
prof_input("/msg buddy1@localhost");
|
||||
assert_true(prof_output_exact("svc: a\\b c|d e%f {g} [h] i=j"));
|
||||
}
|
||||
|
||||
#ifdef HAVE_HISTORY_VERIFY
|
||||
/*
|
||||
* Test: /history verify reports no issues after normal message writes.
|
||||
*/
|
||||
void
|
||||
message_db_history_verify(void **state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
prof_input("/history on");
|
||||
assert_true(prof_output_regex("Chat history enabled\\."));
|
||||
|
||||
/* Write a few messages to create non-trivial DB state */
|
||||
stbbr_send(
|
||||
"<message id='ver1' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/phone' type='chat'>"
|
||||
"<body>verify-msg-one</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)"));
|
||||
|
||||
stbbr_send(
|
||||
"<message id='ver2' to='stabber@localhost' "
|
||||
"from='buddy2@localhost/phone' type='chat'>"
|
||||
"<body>verify-msg-two</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy2/phone (win 3)"));
|
||||
|
||||
/* Run verify — should find no issues */
|
||||
prof_input("/history verify");
|
||||
assert_true(prof_output_exact("Verification complete: no issues found."));
|
||||
}
|
||||
#endif /* HAVE_HISTORY_VERIFY */
|
||||
|
||||
/*
|
||||
* Test: LMC (Last Message Correction, XEP-0308) — incoming correction
|
||||
* replaces the original in DB history.
|
||||
*
|
||||
* Both messages arrive while focused on the console so their bodies
|
||||
* are never rendered to the terminal. After reopen, only the corrected
|
||||
* text should appear — the original should be absent.
|
||||
*/
|
||||
void
|
||||
message_db_history_lmc(void **state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
prof_input("/history on");
|
||||
assert_true(prof_output_regex("Chat history enabled\\."));
|
||||
|
||||
/* Original message (body never displayed — received on console) */
|
||||
stbbr_send(
|
||||
"<message id='lmc-orig-1' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/phone' type='chat'>"
|
||||
"<body>lmc-before-correction-aaa</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)"));
|
||||
|
||||
/* Correction replacing the original (XEP-0308).
|
||||
* Use buddy2 message as sync barrier to ensure correction is processed. */
|
||||
stbbr_send(
|
||||
"<message id='lmc-corr-1' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/phone' type='chat'>"
|
||||
"<body>lmc-after-correction-zzz</body>"
|
||||
"<replace id='lmc-orig-1' xmlns='urn:xmpp:message-correct:0'/>"
|
||||
"</message>");
|
||||
|
||||
/* Sync barrier: send from buddy2, wait for its notification */
|
||||
stbbr_send(
|
||||
"<message id='lmc-sync' to='stabber@localhost' "
|
||||
"from='buddy2@localhost/phone' type='chat'>"
|
||||
"<body>lmc-sync-barrier</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy2/phone (win 3)"));
|
||||
|
||||
/* Close buddy1 and buddy2 windows */
|
||||
prof_input("/close all");
|
||||
assert_true(prof_output_exact("Closed 2 windows."));
|
||||
|
||||
/* Reopen buddy1 — should have corrected text only */
|
||||
prof_input("/msg buddy1@localhost");
|
||||
assert_true(prof_output_exact("lmc-after-correction-zzz"));
|
||||
|
||||
/* Original body was never in the terminal buffer (received on console).
|
||||
* Its absence in history proves the correction was applied in the DB. */
|
||||
prof_timeout(3);
|
||||
assert_false(prof_output_exact("lmc-before-correction-aaa"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
void message_db_history_on_reopen(void **state);
|
||||
void message_db_history_multiple(void **state);
|
||||
void message_db_history_contact_isolation(void **state);
|
||||
void message_db_history_special_chars(void **state);
|
||||
void message_db_history_outgoing(void **state);
|
||||
void message_db_history_dialog(void **state);
|
||||
void message_db_history_empty(void **state);
|
||||
void message_db_history_long_message(void **state);
|
||||
void message_db_history_newline(void **state);
|
||||
void message_db_history_service_chars(void **state);
|
||||
#ifdef HAVE_HISTORY_VERIFY
|
||||
void message_db_history_verify(void **state);
|
||||
#endif
|
||||
void message_db_history_lmc(void **state);
|
||||
@@ -23,8 +23,11 @@
|
||||
#include <glib.h>
|
||||
#include "prof_cmocka.h"
|
||||
|
||||
#include "config.h"
|
||||
#include "database.h"
|
||||
|
||||
db_backend_t* active_db_backend = NULL;
|
||||
|
||||
gboolean
|
||||
log_database_init(ProfAccount* account)
|
||||
{
|
||||
@@ -50,3 +53,29 @@ void
|
||||
log_database_close(void)
|
||||
{
|
||||
}
|
||||
GSList*
|
||||
log_database_verify_integrity(const gchar* const contact_barejid)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
void
|
||||
integrity_issue_free(integrity_issue_t* issue)
|
||||
{
|
||||
if (issue) {
|
||||
g_free(issue->file);
|
||||
g_free(issue->message);
|
||||
g_free(issue);
|
||||
}
|
||||
}
|
||||
#ifdef HAVE_SQLITE
|
||||
db_backend_t*
|
||||
db_backend_sqlite(void)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
#endif
|
||||
db_backend_t*
|
||||
db_backend_flatfile(void)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user