/* * 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 . * */ #include "config.h" #include #include #include #include #include #include #include #include #include #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" #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 */ // Account JID stored during init for path construction static char* g_flatfile_account_jid = NULL; // --- Type conversion helpers (shared with sqlite backend) --- static 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"; } static 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; } static 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"; } static 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. static char* _ff_jid_to_dir(const char* jid) { if (!jid || jid[0] == '\0') return NULL; // Reject embedded null bytes (strlen already stops, but be explicit) // 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}/ static 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 log file path for a contact on a specific date: // {contact_dir}/{YYYY_MM_DD}.log static char* _ff_get_log_path(const char* contact_barejid, GDateTime* dt) { auto_gchar gchar* contact_dir = _ff_get_contact_dir(contact_barejid); if (!contact_dir) return NULL; auto_gchar gchar* date_str = g_date_time_format(dt, "%Y_%m_%d"); char* result = g_strdup_printf("%s/%s.log", contact_dir, date_str); return result; } // Ensure the directory exists, create if needed. // Refuses to follow symlinks at the final component. static 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 static 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 static 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. static 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 static 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). static 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) return g_strdup(""); } if (truncated) *truncated = FALSE; if (nread > 0 && line[nread - 1] == '\n') { line[--nread] = '\0'; } else if (feof(fp)) { // Line without trailing newline at EOF — likely a partial write if (truncated) *truncated = TRUE; } return line; } // --- Line format --- // // 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. static 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. // JID bare parts (user@domain) never contain ": " but XMPP resources // can contain arbitrary UTF-8 including ": ". 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 atomically with a single write() 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; ssize_t written = fwrite(full_line->str, 1, to_write, fp); if (written != (ssize_t)to_write) { log_error("flatfile: partial write (%zd/%zu)", written, to_write); } g_string_free(full_line, TRUE); g_free(safe_msg); g_string_free(meta, TRUE); g_string_free(sender, TRUE); } // --- Line parser helpers --- // Find the first occurrence of 'ch' that is not preceded by an unescaped backslash. // Returns pointer to 'ch' in 'str', or NULL if not found. static 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. static 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. // Escaped form is "\: " — a backslash before the colon. static 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: "\\" -> '\', "\: " -> ": " static 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 --- 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; } flatfile_parsed_line_t; static void _ff_parsed_line_free(flatfile_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} static flatfile_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; } } flatfile_parsed_line_t* result = g_malloc0(sizeof(flatfile_parsed_line_t)); // Parse timestamp — everything up to first space followed by '[' // Try: find first '[' — everything before it (trimmed) is the timestamp 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 [...] // Use escape-aware bracket finder to handle escaped ']' in values 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. // Resources may contain escaped \: sequences. 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} // Or just {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: unparseable 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 static ProfMessage* _ff_parsed_to_profmessage(flatfile_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; } // --- 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); // 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, dt); g_date_time_unref(dt); 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; } if (is_new) { fprintf(fp, "%s", FLATFILE_HEADER); } _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(fp); g_free(effective_msg); } // --- Backend implementation --- 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; } log_info("Initialized flat-file database backend: %s", base_dir); return TRUE; } static void _flatfile_close(void) { g_free(g_flatfile_account_jid); g_flatfile_account_jid = NULL; log_debug("flatfile: closed"); } 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); _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 --- // List all log files for a contact, sorted by filename (date order) static GSList* _ff_list_log_files(const char* contact_barejid) { auto_gchar gchar* contact_dir = _ff_get_contact_dir(contact_barejid); if (!contact_dir || !g_file_test(contact_dir, G_FILE_TEST_IS_DIR)) { return NULL; } GDir* dir = g_dir_open(contact_dir, 0, NULL); if (!dir) return NULL; GSList* files = NULL; const gchar* fname; while ((fname = g_dir_read_name(dir)) != NULL) { if (g_str_has_suffix(fname, ".log")) { files = g_slist_insert_sorted(files, g_strdup_printf("%s/%s", contact_dir, fname), (GCompareFunc)g_strcmp0); } } g_dir_close(dir); return files; } // Read all parsed lines from a file, applying filters static GSList* _ff_read_file_messages(const char* filepath, const char* contact_barejid, const char* start_time, const char* end_time, const Jid* myjid) { GSList* messages = NULL; FILE* fp = fopen(filepath, "r"); if (!fp) return NULL; // BOM detection int c1 = fgetc(fp); int c2 = fgetc(fp); int c3 = fgetc(fp); if (!(c1 == 0xEF && c2 == 0xBB && c3 == 0xBF)) { // Not a BOM, rewind fseek(fp, 0, SEEK_SET); } char* buf = NULL; gboolean truncated = FALSE; while ((buf = _ff_readline(fp, &truncated)) != NULL) { if (truncated) { log_warning("flatfile: truncated line at EOF in %s, skipping", filepath); free(buf); break; } flatfile_parsed_line_t* pl = _ff_parse_line(buf); free(buf); if (!pl) continue; // Check timestamp bounds if (start_time) { GDateTime* start_dt = g_date_time_new_from_iso8601(start_time, NULL); if (start_dt) { if (g_date_time_compare(pl->timestamp, start_dt) <= 0) { g_date_time_unref(start_dt); _ff_parsed_line_free(pl); continue; } g_date_time_unref(start_dt); } } if (end_time) { GDateTime* end_dt = g_date_time_new_from_iso8601(end_time, NULL); if (end_dt) { if (g_date_time_compare(pl->timestamp, end_dt) >= 0) { g_date_time_unref(end_dt); _ff_parsed_line_free(pl); continue; } g_date_time_unref(end_dt); } } // Check JID filter: message must involve contact and our JID gboolean matches = FALSE; if (myjid && 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; // no filter } if (!matches) { _ff_parsed_line_free(pl); continue; } messages = g_slist_append(messages, pl); } fclose(fp); return messages; } // 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) { flatfile_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) { flatfile_parsed_line_t* pl = l->data; if (pl->replace_id && strlen(pl->replace_id) > 0) { flatfile_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 flatfile_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) { flatfile_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) { flatfile_parsed_line_t* pl = l->data; if (g_hash_table_lookup(corrections, pl)) { continue; // skip correction-only lines } flatfile_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); } 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; } // If no end_time, use now auto_gchar gchar* effective_end = NULL; if (end_time) { effective_end = g_strdup(end_time); } else { GDateTime* now = g_date_time_new_now_local(); effective_end = g_date_time_format_iso8601(now); g_date_time_unref(now); } // Get all log files for this contact GSList* files = _ff_list_log_files(contact_barejid); if (!files) { return DB_RESPONSE_EMPTY; } // Collect all matching parsed lines across files GSList* all_parsed = NULL; for (GSList* f = files; f; f = f->next) { GSList* file_msgs = _ff_read_file_messages(f->data, contact_barejid, start_time, effective_end, myjid); all_parsed = g_slist_concat(all_parsed, file_msgs); } g_slist_free_full(files, g_free); if (!all_parsed) { return DB_RESPONSE_EMPTY; } // Apply LMC corrections and build ProfMessage list GSList* pre_result = NULL; _ff_apply_lmc(all_parsed, &pre_result); // Free parsed lines g_slist_free_full(all_parsed, (GDestroyNotify)_ff_parsed_line_free); if (!pre_result) { return DB_RESPONSE_EMPTY; } // Apply pagination: take first or last MESSAGES_TO_RETRIEVE guint total = g_slist_length(pre_result); if (total > MESSAGES_TO_RETRIEVE) { if (from_start) { // Keep first N, free rest 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 { // Keep last N, free first (total - N) 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; } } // Apply flip (reverse order) 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; } GSList* files = _ff_list_log_files(contact_barejid); if (!files) { if (is_last) msg->timestamp = g_date_time_new_now_utc(); return msg; } // For first message: read first non-comment line of first file // For last message: read last non-comment line of last file const char* target_file = is_last ? (const char*)g_slist_last(files)->data : (const char*)files->data; FILE* fp = fopen(target_file, "r"); if (!fp) { g_slist_free_full(files, g_free); if (is_last) msg->timestamp = g_date_time_new_now_utc(); return msg; } // BOM skip int c1 = fgetc(fp); int c2 = fgetc(fp); int c3 = fgetc(fp); if (!(c1 == 0xEF && c2 == 0xBB && c3 == 0xBF)) { fseek(fp, 0, SEEK_SET); } char* buf = NULL; flatfile_parsed_line_t* found = NULL; if (!is_last) { // Find first valid line while ((buf = _ff_readline(fp, NULL)) != NULL) { found = _ff_parse_line(buf); free(buf); if (found) break; } } else { // Find last valid line — read all and keep last while ((buf = _ff_readline(fp, NULL)) != NULL) { flatfile_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(); } g_slist_free_full(files, g_free); return msg; } // --- Integrity verification --- static GSList* _flatfile_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 for (GSList* cd = contact_dirs; cd; cd = cd->next) { const char* cdir_path = cd->data; GDir* dir = g_dir_open(cdir_path, 0, NULL); if (!dir) continue; GSList* log_files = NULL; const gchar* fname; while ((fname = g_dir_read_name(dir)) != NULL) { if (g_str_has_suffix(fname, ".log")) { log_files = g_slist_insert_sorted(log_files, g_strdup_printf("%s/%s", cdir_path, fname), (GCompareFunc)g_strcmp0); } } g_dir_close(dir); GDateTime* prev_file_last_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); for (GSList* lf = log_files; lf; lf = lf->next) { const char* filepath = lf->data; const char* basename = strrchr(filepath, '/'); basename = basename ? basename + 1 : filepath; // Check file permissions (4i) 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 (4l) 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; GDateTime* first_ts = NULL; GDateTime* last_ts = NULL; gboolean has_crlf = FALSE; gboolean is_empty = TRUE; while ((buf = _ff_readline(fp, NULL)) != NULL) { lineno++; gsize len = strlen(buf); // CRLF check (4m) if (len > 0 && buf[len - 1] == '\r') { has_crlf = TRUE; buf[--len] = '\0'; } // Skip empty lines and comments if (len == 0 || buf[0] == '#') { free(buf); continue; } is_empty = FALSE; // UTF-8 validation (4k/4n) 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; } // Control character check (4o) 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; } } // Parse line (4a, 4b) flatfile_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("Unparseable line"); issues = g_slist_append(issues, issue); free(buf); continue; } free(buf); // done with raw line if (!first_ts) first_ts = g_date_time_ref(pl->timestamp); if (last_ts) g_date_time_unref(last_ts); last_ts = g_date_time_ref(pl->timestamp); // Timestamp order within file (4c) 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 (4f) 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)); } // Also track for LMC checking 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)); } } // Broken LMC reference (4g) — collect for later batch check // (We'll check after reading all files for this contact) _ff_parsed_line_free(pl); } fclose(fp); // CRLF warning for file 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); } // Empty file (4j) 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); } // Cross-file timestamp ordering (4d) if (first_ts && prev_file_last_ts) { if (g_date_time_compare(first_ts, prev_file_last_ts) < 0) { auto_gchar gchar* ts_first = g_date_time_format_iso8601(first_ts); auto_gchar gchar* ts_prev_last = g_date_time_format_iso8601(prev_file_last_ts); 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("First timestamp (%s) is before previous file's last timestamp (%s)", ts_first, ts_prev_last); issues = g_slist_append(issues, issue); } } if (prev_file_last_ts) g_date_time_unref(prev_file_last_ts); prev_file_last_ts = last_ts ? g_date_time_ref(last_ts) : NULL; if (prev_ts) g_date_time_unref(prev_ts); if (first_ts) g_date_time_unref(first_ts); if (last_ts) g_date_time_unref(last_ts); } // Second pass: check LMC references across all files (4g) for (GSList* lf = log_files; lf; lf = lf->next) { const char* filepath = lf->data; const char* basename_lmc = strrchr(filepath, '/'); basename_lmc = basename_lmc ? basename_lmc + 1 : filepath; FILE* fp = fopen(filepath, "r"); if (!fp) continue; // Skip BOM int b1 = fgetc(fp); int b2 = fgetc(fp); int b3 = fgetc(fp); if (!(b1 == 0xEF && b2 == 0xBB && b3 == 0xBF)) { fseek(fp, 0, SEEK_SET); } char* buf = NULL; int lineno = 0; while ((buf = _ff_readline(fp, NULL)) != NULL) { lineno++; gsize len = strlen(buf); if (len > 0 && buf[len - 1] == '\r') buf[--len] = '\0'; if (len == 0 || buf[0] == '#') { free(buf); continue; } flatfile_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_lmc); 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_file_last_ts) g_date_time_unref(prev_file_last_ts); g_hash_table_destroy(seen_ids); g_hash_table_destroy(all_stanza_ids); g_slist_free_full(log_files, g_free); } g_slist_free_full(contact_dirs, g_free); return issues; } // --- 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 = _flatfile_verify_integrity, }; db_backend_t* db_backend_flatfile(void) { return &flatfile_backend; }