Files
utun2/src/chat/chat_msg.c
T

2044 lines
110 KiB
C

/*
* chat_msg.c — сообщения: отправка, DB-операции для chat_sync
*
* Вынесено из chat_core.c для уменьшения размера модуля.
*/
#include "chat_core_priv.h"
#include "../routing_layer/topo_node_sqlite.h"
#include "chat_attachment.h"
#include "chat_event.h"
#include "chat_feedback.h"
#include "chat_poll.h"
#include "chat_setting.h"
#include "chat_whisper.h"
#include "../utun_instance.h"
#include "../ntp_time.h"
#include "../transport_layer/secure_channel.h"
#include "../media_delivery/media_index.h"
#include "../media_delivery/media_download.h"
#include "../media_delivery/media_delivery.h"
#include "../media_async/media_async.h"
#include "../video/video.h"
#include "../../lib/mem.h"
#include "../../lib/u_async.h"
#include "../../lib/platform_compat.h"
#include "../../lib/file_io.h"
#include <openssl/sha.h>
#include <openssl/rand.h>
#include <stdlib.h>
static void media_queue_channel_backfill(struct UTUN_INSTANCE* inst, const char* channel);
static void media_startup_backfill_timer_cb(void* arg);
static void media_queue_full_backfill(struct UTUN_INSTANCE* inst) {
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
if (!cc || !cc->initialized || !cc->media_backfill_inst || cc->media_backfill_timer) return;
cc->media_backfill_timer = uasync_set_timeout(inst->ua, 20000, inst, media_startup_backfill_timer_cb, "chat_media_bl");
if (!cc->media_backfill_timer) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "media backfill: complete scan timer allocation failed");
}
static void wh_transcribe_done_cb(void* arg, const char* ch_id, const char* text, int err,
const uint8_t reply_id[32]);
static void chat_media_cache_cleanup_orphans(struct UTUN_INSTANCE* inst);
static void chat_media_cache_reconcile(struct UTUN_INSTANCE* inst);
/* ─── счётчик объёма медиакеша (сумма файлов, соответствующих сообщениям) ─── */
static void cache_bytes_add(struct chat_core_ctx* cc, int64_t sz) {
if (sz > 0) cc->media_cache_bytes += (uint64_t)sz;
}
static void cache_bytes_sub(struct chat_core_ctx* cc, int64_t sz) {
uint64_t v = sz > 0 ? (uint64_t)sz : 0;
cc->media_cache_bytes = cc->media_cache_bytes >= v ? cc->media_cache_bytes - v : 0;
}
/* Единый разбор JSON для live/manual/startup/cache:полные строки без скрытого NUL и усечения.
* 0 — body принадлежит caller/u_free,1 — обычный текст без manifest,-1 — SQL/формат,-2 — память. */
static int msg_media_fields(sqlite3* db, const char* data, size_t bytes, const char* attributes,
char** body, char content_type[32], char filename[256]) {
*body = NULL; content_type[0] = filename[0] = 0;
if (!data || !bytes || bytes > DB_SYNC_MESSAGE_MAX || memchr(data, 0, bytes)) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "media fields: invalid JSON bytes=%zu", bytes); return -1;
}
sqlite3_stmt* st = NULL; int result = -1;
int rc = sqlite3_prepare_v2(db, "SELECT json_extract(?1,'$.d'),json_extract(?1,'$.ct'),"
"CASE WHEN ?2 IS NULL OR ?2='' THEN NULL ELSE json_extract(?2,'$.fp') END", -1, &st, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 1, data, (int)bytes, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = attributes ? sqlite3_bind_text(st, 2, attributes, -1, SQLITE_STATIC) : sqlite3_bind_null(st, 2);
if (rc == SQLITE_OK) rc = sqlite3_step(st);
if (rc == SQLITE_ROW) {
const size_t limits[] = {DB_SYNC_MESSAGE_MAX, 31, 255};
char* values[3] = {NULL, content_type, filename};
for (int i = 0; i < 3; i++) {
int type = sqlite3_column_type(st, i); const char* value = (const char*)sqlite3_column_text(st, i);
int length = sqlite3_column_bytes(st, i);
if (type == SQLITE_NULL) continue;
if (type != SQLITE_TEXT || !value || length < 0 || (size_t)length > limits[i] || strlen(value) != (size_t)length) {
rc = SQLITE_CORRUPT; break;
}
if (!i) values[0] = (char*)value;
else memcpy(values[i], value, (size_t)length + 1);
}
if (rc == SQLITE_ROW) {
if (!values[0] || !strcmp(content_type, "text") || !strcmp(content_type, "text/plain") ||
!strcmp(content_type, "voice_transcription") || !strchr(values[0], '|')) result = 1;
else {
*body = u_strdup(values[0]); result = *body ? 0 : -2;
}
}
}
int final = sqlite3_finalize(st);
if (rc != SQLITE_ROW || final != SQLITE_OK || result < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "media fields: rc=%d finalize=%d result=%d: %s", rc, final, result, sqlite3_errmsg(db));
u_free(*body); *body = NULL; return result == -2 ? -2 : -1;
}
return result;
}
/* ─── отправка сообщения (GUI → uasync) ─── */
/* Локальный ответ ссылается только на существующий оригинал своего канала; wire хранит общий ID. */
static int msg_reply_hex(struct chat_core_ctx* cc, const char* channel, const uint8_t reply[32], char hex[65]) {
static const uint8_t empty[32] = {0};
hex[0] = 0;
if (!memcmp(reply, empty, 32)) return 0;
for (unsigned i = 0; i < 32; i++) snprintf(hex + i * 2, 3, "%02x", reply[i]);
char table[80], sql[160]; msg_table_name(channel, table, sizeof(table));
snprintf(sql, sizeof(sql), "SELECT 1 FROM \"%s\" WHERE message_id=?", table);
sqlite3_stmt* st = NULL;
int rc = sqlite3_prepare_v2(cc->db, sql, -1, &st, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 1, reply, 32, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_step(st);
int final = sqlite3_finalize(st);
if (rc != SQLITE_ROW || final != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "reply: original unavailable ch=%s id=%s rc=%d: %s",
channel, hex, rc, sqlite3_errmsg(cc->db)); return -1;
}
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "reply: original resolved ch=%s id=%s", channel, hex);
return 0;
}
int chat_core_submit_message(struct UTUN_INSTANCE* inst, struct chat_msg_submit* req) {
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
if (!cc || !cc->initialized || !req || (req->data_len && !req->data) || req->data_len > 4000 ||
!memchr(req->channel_id, 0, sizeof(req->channel_id)) || !chat_channel_id(req->channel_id) ||
!memchr(req->content_type, 0, sizeof(req->content_type)) || (req->data_len && memchr(req->data, 0, req->data_len))) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: invalid message submission", CC_ID); return -1;
}
memset(req->message_id, 0, sizeof(req->message_id));
if (!topo_node_sqlite_member_allowed(cc->db, req->channel_id, inst->node_id)) {
DEBUG_WARN(DEBUG_CATEGORY_CHAT, "message denied: local member excluded ch=%s", req->channel_id); return -1;
}
struct DB_SYNC_INSTANCE* si = si_find(inst, req->channel_id);
if (!si) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: no history for ch=%s", CC_ID, req->channel_id); return -1; }
/* SQLite формирует JSON без обрезки и с экранированием текста/Unicode. */
char reply_hex[65];
if (msg_reply_hex(cc, req->channel_id, req->reply_to, reply_hex) < 0) return -1;
const char* query = reply_hex[0]
? "SELECT json_object('n',?1,'ch',?2,'ct',?3,'d',?4,'reply_to',?5)"
: "SELECT json_object('n',?1,'ch',?2,'ct',?3,'d',?4)";
sqlite3_stmt* st = NULL;
if (sqlite3_prepare_v2(cc->db, query, -1, &st, NULL) != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: message JSON: %s", CC_ID, sqlite3_errmsg(cc->db)); return -1;
}
int rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)cc->my_node_id);
if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 2, req->channel_id, -1, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 3, req->content_type, -1, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 4, req->data ? (const char*)req->data : "", req->data_len, SQLITE_STATIC);
if (rc == SQLITE_OK && reply_hex[0]) rc = sqlite3_bind_text(st, 5, reply_hex, -1, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_step(st);
int bytes = rc == SQLITE_ROW ? sqlite3_column_bytes(st, 0) : 0;
const char* encoded = rc == SQLITE_ROW ? (const char*)sqlite3_column_text(st, 0) : NULL;
char json[4001];
if (rc != SQLITE_ROW || !encoded || bytes <= 0 || bytes >= (int)sizeof(json)) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: message JSON rejected ch=%s bytes=%d rc=%d: %s",
CC_ID, req->channel_id, bytes, rc, sqlite3_errmsg(cc->db)); sqlite3_finalize(st); return -1;
}
memcpy(json, encoded, bytes); json[bytes] = 0;
rc = sqlite3_finalize(st);
if (rc != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "message JSON finalize failed rc=%d", rc); return -1; }
uint64_t ts = db_sync_next_timestamp(si);
uint8_t signature[64], message_id[32]; struct db_message_revision revision = {.ver = 1};
if (RAND_bytes(revision.nonce, sizeof(revision.nonce)) != 1 ||
db_sync_sign_message(db_sync_instance_group_id(si), ts, cc->my_node_id, &revision, json, bytes,
inst->my_ed25519_privkey, signature) < 0 ||
chat_message_id(db_sync_instance_group_id(si), ts, cc->my_node_id, revision.nonce, message_id) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: message signing failed ch=%s", CC_ID, req->channel_id); return -1;
}
int result = db_sync_insert_signed(si, json, bytes, signature, 64, ts, NULL, &revision);
if (result != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: message insert failed ch=%s result=%d", CC_ID, req->channel_id, result); return -1;
}
req->timestamp = ts; memcpy(req->message_id, message_id, sizeof(message_id));
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: message saved ch=%s ct=%s ts=%llu bytes=%d",
CC_ID, req->channel_id, req->content_type, (unsigned long long)ts, bytes);
return 0;
}
/* Правим только d существующего text; reply_to и остальные поля остаются в подписанном JSON. */
int chat_core_edit_message(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message_id[32],
uint64_t expected_ver, const char* text, size_t len) {
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
struct DB_SYNC_INSTANCE* si = cc && channel ? si_find(inst, channel) : NULL;
if (!cc || !cc->initialized || !si || !message_id || !text || !len || len > DB_SYNC_MESSAGE_MAX || !expected_ver) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "edit: invalid request channel=%s bytes=%zu", channel ? channel : "", len); return -1;
}
char table[80], sql[768], json[DB_SYNC_MESSAGE_MAX + 1]; msg_table_name(channel, table, sizeof(table));
snprintf(sql, sizeof(sql), "SELECT timestamp,node_id,nonce,ver,edited_at,"
"json_extract(CAST(data AS TEXT),'$.ct'),json_extract(CAST(data AS TEXT),'$.d'),"
"json_set(CAST(data AS TEXT),'$.d',?2) FROM \"%s\" WHERE message_id=?1", table);
sqlite3_stmt* st = NULL; struct db_message_revision revision = {0}; uint64_t ts = 0, observed_ver = 0; int result = -1;
const char* stage = "lookup";
int rc = sqlite3_prepare_v2(cc->db, sql, -1, &st, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 1, message_id, 32, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 2, text, (int)len, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_step(st);
if (rc != SQLITE_ROW) goto done;
ts = (uint64_t)sqlite3_column_int64(st, 0);
uint64_t author = (uint64_t)sqlite3_column_int64(st, 1), ver = (uint64_t)sqlite3_column_int64(st, 3);
observed_ver = ver;
const char* ct = (const char*)sqlite3_column_text(st, 5);
if (author != cc->my_node_id) { stage = "foreign author"; goto done; }
if (!ct || (strcmp(ct, "text") && strcmp(ct, "text/plain"))) { stage = "unsupported content type"; goto done; }
if (ver != expected_ver) { stage = "stale editor version"; result = -2; goto done; }
revision.ver = ver; revision.edited_at = (uint64_t)sqlite3_column_int64(st, 4);
const char* old = (const char*)sqlite3_column_text(st, 6);
if (old && (size_t)sqlite3_column_bytes(st, 6) == len && !memcmp(old, text, len)) { stage = "unchanged"; result = 0; goto done; }
const void* nonce = sqlite3_column_blob(st, 2); int bytes = sqlite3_column_bytes(st, 7);
const char* body = (const char*)sqlite3_column_text(st, 7);
if (!nonce || sqlite3_column_bytes(st, 2) != 16) { stage = "invalid stored nonce"; goto done; }
if (!body || bytes <= 0 || bytes > DB_SYNC_MESSAGE_MAX) { stage = "encoded message exceeds limit"; goto done; }
if (ver == INT64_MAX) { stage = "version exhausted"; goto done; }
memcpy(revision.nonce, nonce, 16); revision.ver = ver + 1;
uint64_t edited = (uint64_t)sqlite3_column_int64(st, 4);
revision.edited_at = db_sync_next_timestamp(si);
if (revision.edited_at <= ts) revision.edited_at = ts + 1;
if (revision.edited_at <= edited) revision.edited_at = edited + 1;
memcpy(json, body, bytes); json[bytes] = 0;
rc = sqlite3_finalize(st); st = NULL;
uint8_t signature[64];
stage = "sign/store";
if (rc == SQLITE_OK && db_sync_sign_message(db_sync_instance_group_id(si), ts, author, &revision, json, bytes,
inst->my_ed25519_privkey, signature) == 0 &&
db_sync_insert_signed(si, json, bytes, signature, 64, ts, NULL, &revision) == 0) result = 0;
done:
{
int final = sqlite3_finalize(st);
if (final != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "edit finalize failed rc=%d", final); result = -1; }
}
if (result) DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "edit: rejected ch=%s stage=%s expected=%llu stored=%llu result=%d sql=%d: %s",
channel, stage, (unsigned long long)expected_ver, (unsigned long long)observed_ver, result, rc, sqlite3_errmsg(cc->db));
else DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "edit: %s ch=%s id=%02x%02x%02x%02x ver=%llu edited=%llu",
!strcmp(stage, "unchanged") ? "unchanged" : "saved", channel, message_id[0], message_id[1], message_id[2], message_id[3],
(unsigned long long)(revision.ver ? revision.ver : expected_ver), (unsigned long long)revision.edited_at);
return result;
}
void chat_core_submit_trampoline(void* arg) {
struct chat_msg_submit* req = (struct chat_msg_submit*)arg;
if (!req) return;
if (!memchr(req->channel_id, 0, sizeof(req->channel_id))) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "message trampoline: unterminated channel");
chat_event_submitted(req->inst, req->request_id, 0); u_free(req); return;
}
if (req->expected_ver) {
int result = chat_core_edit_message(req->inst, req->channel_id, req->message_id,
req->expected_ver, (const char*)req->data, req->data_len);
chat_event_submitted(req->inst, req->request_id, result == 0);
} else if (req->media_src[0] != '\0')
chat_core_submit_media_message(req->inst, req, NULL, NULL);
else {
int result = chat_core_submit_message(req->inst, req);
chat_event_submitted(req->inst, req->request_id, result == 0);
}
u_free(req);
}
/* ─── Медиа-сообщения: async регистрация в media_files + отправка ─── */
struct media_submit_ctx {
struct media_submit_ctx* next;
struct media_destination* destination; // Общая с загрузчиком резервация пути до завершения workers.
int delete_pending;
struct UTUN_INSTANCE* inst;
char channel_id[64];
char content_type[32];
char media_dest[1024];
uint8_t msg_data[4096];
uint32_t msg_data_len;
uint64_t timestamp;
int64_t duration_ms; /* видео: длительность после транскода */
int width; /* видео: размеры после транскода */
int height;
char reply_hex[65];
uint64_t request_id;
chat_media_submitted_fn complete;
void* complete_arg;
};
/* Завершение владеющей асинхронной отправки, включая все ошибки подготовки/регистрации. */
static void media_submit_finish(struct media_submit_ctx* ctx, int success) {
struct UTUN_INSTANCE* inst = ctx->inst;
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
chat_media_submitted_fn complete = ctx->complete; void* complete_arg = ctx->complete_arg;
char channel[64]; snprintf(channel, sizeof(channel), "%s", ctx->channel_id);
/* Синхронный обработчик события может запросить удаление; ctx пока остаётся в списке владельца. */
chat_event_submitted(ctx->inst, ctx->request_id, success);
int delete_pending = ctx->delete_pending;
struct media_submit_ctx** slot = &cc->media_submissions;
while (*slot && *slot != ctx) slot = &(*slot)->next;
if (*slot) *slot = ctx->next;
else DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "media submission missing from owner ch=%s", channel);
media_delivery_release(inst, ctx->destination);
u_free(ctx);
if (delete_pending) {
int waiting = 0;
for (struct media_submit_ctx* pending = cc->media_submissions; pending; pending = pending->next)
if (!strcmp(pending->channel_id, channel)) { waiting = 1; break; }
if (!waiting) {
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "media submissions drained; continuing channel deletion ch=%s", channel);
chat_core_delete_channel(inst, channel);
}
}
if (complete) complete(complete_arg, success);
}
int chat_media_defer_channel_delete(struct UTUN_INSTANCE* inst, const char* channel) {
struct chat_core_ctx* cc = inst ? CC(inst) : NULL; int pending = 0;
for (struct media_submit_ctx* ctx = cc->media_submissions; ctx; ctx = ctx->next) {
if (strcmp(ctx->channel_id, channel)) continue;
ctx->delete_pending = 1; pending++;
}
if (pending) DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "channel deletion waiting for outgoing media ch=%s tasks=%d", channel, pending);
return pending != 0;
}
static void on_media_registered(void* arg, int err, const struct media_index_result* result) {
struct media_submit_ctx* mctx = (struct media_submit_ctx*)arg;
struct chat_core_ctx* cc = CC(mctx->inst);
if (err || !result) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: media registration failed ch=%s err=%d",
CC_ID, mctx->channel_id, err);
media_submit_finish(mctx, 0);
return;
}
if (mctx->delete_pending) {
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "prepared media retained for pending deletion ch=%s", mctx->channel_id);
media_submit_finish(mctx, 0); return;
}
struct DB_SYNC_INSTANCE* si = si_find(mctx->inst, mctx->channel_id);
if (!si) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: no db_sync instance for ch=%s", CC_ID, mctx->channel_id);
media_submit_finish(mctx, 0);
return;
}
/* build full_data: <base>|<file_size>|<block_size>|<num_blocks>|<media_id_hex>|<content_hash_hex>|<id0,sig0,...> */
size_t base_len = mctx->msg_data_len;
int nb = result->num_blocks;
size_t pairs_len = (size_t)nb * (32 + 1 + 128 + 1); /* id_hex, sig_hex, commas */
size_t extra = 128 + 32 + 64 + pairs_len + 256;
size_t fdcap = base_len + extra;
char* full_data = u_malloc(fdcap);
if (!full_data) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: data alloc fail", CC_ID); media_submit_finish(mctx, 0); return; }
size_t off = 0;
if (base_len > 0) {
char b64[2048]; size_t b64len = b64_encode(mctx->msg_data, base_len, b64, sizeof(b64));
full_data[off++] = '*';
memcpy(full_data + off, b64, b64len); off += b64len;
}
off += snprintf(full_data + off, fdcap - off, "|%lld|%lld|%d|",
(long long)result->file_size, (long long)result->block_size, nb);
for (int i = 0; i < 16; i++) off += snprintf(full_data + off, fdcap - off, "%02x", result->media_id[i]);
off += snprintf(full_data + off, fdcap - off, "|");
for (int i = 0; i < 32; i++) off += snprintf(full_data + off, fdcap - off, "%02x", result->content_hash[i]);
off += snprintf(full_data + off, fdcap - off, "|");
for (int n = 0; n < nb; n++) {
if (n > 0) full_data[off++] = ',';
for (int i = 0; i < 16; i++)
off += snprintf(full_data + off, fdcap - off, "%02x", result->block_ids[n * 16 + i]);
full_data[off++] = ',';
for (int i = 0; i < 64; i++)
off += snprintf(full_data + off, fdcap - off, "%02x", result->block_sigs[n * 64 + i]);
}
/* Длительность/размеры входят в подписанное тело после списка блоков. */
if (mctx->content_type[0] && strcmp(mctx->content_type, "video/mp4") == 0 && mctx->duration_ms > 0) {
off += snprintf(full_data + off, fdcap - off, "|dur=%lld|w=%d|h=%d",
(long long)mctx->duration_ms, mctx->width, mctx->height);
}
/* для фото дописываем разрешение в конец */
else if (mctx->content_type[0] && strncmp(mctx->content_type, "image/", 6) == 0
&& (mctx->width > 0 || mctx->height > 0)) {
off += snprintf(full_data + off, fdcap - off, "|w=%d|h=%d", mctx->width, mctx->height);
}
size_t full_data_len = off;
size_t json_cap = full_data_len + 384;
char* json = u_malloc(json_cap);
if (!json) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: json alloc fail", CC_ID); u_free(full_data); media_submit_finish(mctx, 0); return; }
snprintf(json, json_cap,
"{\"n\":%llu,\"ch\":\"%s\",\"ct\":\"%s\",\"d\":\"%.*s\"%s%s%s}",
(unsigned long long)cc->my_node_id, mctx->channel_id,
mctx->content_type, (int)full_data_len, full_data,
mctx->reply_hex[0] ? ",\"reply_to\":\"" : "", mctx->reply_hex, mctx->reply_hex[0] ? "\"" : "");
size_t json_len = strlen(json);
if (json_len > DB_SYNC_MESSAGE_MAX) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "media manifest exceeds history limit ch=%s blocks=%d bytes=%zu limit=%u",
mctx->channel_id, nb, json_len, (unsigned)DB_SYNC_MESSAGE_MAX);
u_free(json); u_free(full_data); media_submit_finish(mctx, 0); return;
}
uint64_t ts = db_sync_next_timestamp(si);
struct db_message_revision revision = {.ver = 1}; uint8_t json_sig[64], message_id[32];
if (RAND_bytes(revision.nonce, sizeof(revision.nonce)) != 1 ||
db_sync_sign_message(db_sync_instance_group_id(si), ts, cc->my_node_id, &revision, json, json_len,
mctx->inst->my_ed25519_privkey, json_sig) < 0 ||
chat_message_id(db_sync_instance_group_id(si), ts, cc->my_node_id, revision.nonce, message_id) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: Ed25519 sign JSON failed", CC_ID);
u_free(json); u_free(full_data); media_submit_finish(mctx, 0); return;
}
/* local_attrs готовим заранее и пишем атомарно при вставке — иначе GUI по
MSG_RECEIVED может прочитать сообщение раньше отдельного UPDATE (гонка),
и своё голосовое покажется «без медиа» (серый media bar + попытка скачать) */
const char* base = strrchr(mctx->media_dest, '/');
const char* filename = base ? base + 1 : mctx->media_dest;
char attrs[1100]; snprintf(attrs, sizeof(attrs), "{\"st\":\"fl\",\"fp\":\"%s\"}", filename);
if (!strcmp(mctx->content_type, ENCRYPTED_ATTACHMENT_TYPE)) {
uint8_t crypto_id[16];
if (chat_attachment_identity(full_data, full_data_len, crypto_id) ||
chat_attachment_bind(mctx->inst, mctx->channel_id, crypto_id, message_id, result->content_hash)) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "encrypted media: cannot publish without bound author key ch=%s", mctx->channel_id);
u_free(json); u_free(full_data); media_submit_finish(mctx, 0); return;
}
}
int ret = db_sync_insert_signed(si, json, json_len, json_sig, 64, ts, attrs, &revision);
if (ret != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: media insert failed ret=%d", CC_ID, ret);
} else {
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: media message inserted ch=%s ts=%llu",
CC_ID, mctx->channel_id, (unsigned long long)ts);
/* анонсируем блоки суперузлам — иначе первый скачивающий не найдёт держателя */
media_delivery_announce_media(mctx->inst, strtoull(mctx->channel_id, NULL, 10),
result->media_id, result->block_ids, result->num_blocks);
/* учесть локально отправленный файл в счётчике и применить лимит медиакеша */
cache_bytes_add(cc, result->file_size);
chat_media_cache_enforce(mctx->inst);
/* whisper транскрипция для своих голосовых сообщений */
if (mctx->content_type[0] && strncmp(mctx->content_type, "voice", 5) == 0
&& chat_setting_get_int(mctx->inst, "whisper_enabled", 0)) {
chat_whisper_transcribe(mctx->inst, mctx->media_dest, mctx->channel_id,
message_id,
wh_transcribe_done_cb, mctx->inst);
}
}
u_free(json); u_free(full_data); media_submit_finish(mctx, ret == 0);
}
static void on_video_prepared(void* arg, int err, const struct video_info* vi) {
struct media_submit_ctx* mctx = (struct media_submit_ctx*)arg;
struct chat_core_ctx* cc = CC(mctx->inst);
if (err) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: video prepare failed ch=%s err=%d",
CC_ID, mctx->channel_id, err);
media_submit_finish(mctx, 0);
return;
}
/* после транскода знаем итоговую длительность и размеры — для тела сообщения */
if (vi && vi->is_video) {
mctx->duration_ms = vi->duration_ms;
mctx->width = vi->width;
mctx->height = vi->height;
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: video prepared %dx%d dur=%lldms",
CC_ID, vi->width, vi->height, (long long)vi->duration_ms);
}
char media_base[MEDIA_BASE_MAX];
if (media_index_get_base(cc->db, media_base)) { media_submit_finish(mctx, 0); return; }
media_index_register_async(
mctx->inst->media_async, mctx->inst->ua, cc->db,
cc->my_node_id, mctx->inst->my_ed25519_privkey,
mctx->channel_id,
mctx->media_dest, mctx->media_dest, 0,
media_base,
on_media_registered, mctx);
}
void chat_core_submit_media_message(struct UTUN_INSTANCE* inst, struct chat_msg_submit* req,
chat_media_submitted_fn complete, void* arg) {
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
if (!cc || !cc->initialized || !req || !req->media_src[0] || !memchr(req->channel_id, 0, sizeof(req->channel_id)) ||
!memchr(req->content_type, 0, sizeof(req->content_type)) || !memchr(req->media_src, 0, sizeof(req->media_src)) ||
!memchr(req->media_dest, 0, sizeof(req->media_dest)) || !chat_channel_id(req->channel_id) || req->data_len >= 4096 ||
(req->data_len && !req->data)) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: invalid media submit/stopped core", CC_ID);
if (req) chat_event_submitted(inst, req->request_id, 0);
if (complete) complete(arg, 0);
return;
}
char media_base[MEDIA_BASE_MAX], location[MEDIA_PATH_MAX], destination[MEDIA_PATH_MAX];
if (media_index_get_base(cc->db, media_base) || media_index_location(req->media_dest, media_base, location)
|| media_index_path(media_base, location, destination)) {
chat_event_submitted(inst, req->request_id, 0); if (complete) complete(arg, 0); return;
}
for (struct media_submit_ctx* pending = cc->media_submissions; pending; pending = pending->next) {
if ((!strcmp(pending->channel_id, req->channel_id) && pending->delete_pending)
|| !strcmp(pending->media_dest, destination)) {
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "media submit rejected: channel deleting/destination busy ch=%s path=%s",
req->channel_id, req->media_dest);
chat_event_submitted(inst, req->request_id, 0); if (complete) complete(arg, 0); return;
}
}
if (!si_find(inst, req->channel_id)) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "media submit without channel ch=%s", req->channel_id);
chat_event_submitted(inst, req->request_id, 0); if (complete) complete(arg, 0); return;
}
char reply_hex[65];
if (msg_reply_hex(cc, req->channel_id, req->reply_to, reply_hex) < 0) {
chat_event_submitted(inst, req->request_id, 0); if (complete) complete(arg, 0); return;
}
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: MEDIA SUBMIT ch=%s ct=%s src=%s dst=%s copy=%d video=%d",
CC_ID, req->channel_id, req->content_type,
req->media_src, req->media_dest, req->media_copy, req->media_video);
if (!inst->media_async) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: media_async not initialized", CC_ID);
chat_event_submitted(inst, req->request_id, 0); if (complete) complete(arg, 0);
return;
}
/* copy fields for async callback (req is freed by trampoline immediately) */
struct media_submit_ctx* mctx = u_calloc(1, sizeof(*mctx));
if (!mctx) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: media context allocation failed", CC_ID);
chat_event_submitted(inst, req->request_id, 0); if (complete) complete(arg, 0); return;
}
mctx->destination = media_delivery_reserve(inst, destination);
if (!mctx->destination) { u_free(mctx); chat_event_submitted(inst, req->request_id, 0); if (complete) complete(arg, 0); return; }
if (media_index_track_preparation(cc->db, req->channel_id, destination, media_base)) {
media_delivery_release(inst, mctx->destination); u_free(mctx);
chat_event_submitted(inst, req->request_id, 0); if (complete) complete(arg, 0); return;
}
mctx->inst = inst;
mctx->request_id = req->request_id;
mctx->complete = complete; mctx->complete_arg = arg;
mctx->next = cc->media_submissions; cc->media_submissions = mctx;
memcpy(mctx->reply_hex, reply_hex, strlen(reply_hex) + 1);
snprintf(mctx->channel_id, sizeof(mctx->channel_id), "%s", req->channel_id);
snprintf(mctx->content_type, sizeof(mctx->content_type), "%s", req->content_type);
snprintf(mctx->media_dest, sizeof(mctx->media_dest), "%s", destination);
mctx->timestamp = req->timestamp;
mctx->duration_ms = req->duration_ms;
mctx->width = req->width;
mctx->height = req->height;
if (req->data && req->data_len > 0) {
mctx->msg_data_len = req->data_len < sizeof(mctx->msg_data) ? req->data_len : sizeof(mctx->msg_data) - 1;
memcpy(mctx->msg_data, req->data, mctx->msg_data_len);
}
if (req->media_video) {
if (video_transcode_start(inst->media_async, inst->ua, req->media_src, destination, on_video_prepared, mctx) < 0)
on_video_prepared(mctx, -1, NULL);
return;
}
media_index_register_async(
inst->media_async, inst->ua, cc->db,
cc->my_node_id, inst->my_ed25519_privkey,
req->channel_id,
req->media_src, destination, req->media_copy ? 1 : 0,
media_base,
on_media_registered, mctx);
}
/* Локальные атрибуты не подписаны: отдельный commit, JSON меняется целиком либо через json_set. */
static int msg_local_attrs_write(struct UTUN_INSTANCE* inst, const char* channel, uint64_t timestamp,
const uint8_t* signature, uint64_t author, const char* attributes) {
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
const char* action = signature ? "replace" : "voice played";
if (!cc || !cc->initialized || !chat_channel_id(channel) || !timestamp || timestamp > INT64_MAX ||
(signature ? !attributes : !author) || !sqlite3_get_autocommit(cc->db)) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "local attrs: invalid state/arguments/transaction action=%s", action); return -1;
}
sqlite3* db = cc->db;
int rc = sqlite3_exec(db, "BEGIN IMMEDIATE /* local attrs */", NULL, NULL, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "local attrs: begin rc=%d: %s", rc, sqlite3_errmsg(db)); return -1;
}
char table[80], sql[768]; msg_table_name(channel, table, sizeof(table));
if (signature) snprintf(sql, sizeof(sql), "UPDATE \"%s\" SET local_attrs=?3 WHERE timestamp=?1 AND author_signature=?2"
" AND CASE WHEN json_valid(?3) THEN json_type(?3)='object' ELSE 0 END RETURNING id", table);
else snprintf(sql, sizeof(sql), "UPDATE \"%s\" SET local_attrs=json_set(CASE WHEN local_attrs IS NULL OR local_attrs=''"
" THEN '{}' ELSE local_attrs END,'$.pl',1) WHERE id=(SELECT id FROM \"%s\" WHERE timestamp=?1 AND node_id=?2"
" ORDER BY id LIMIT 1) AND CASE WHEN local_attrs IS NULL OR local_attrs='' THEN 1 WHEN json_valid(local_attrs)"
" THEN json_type(local_attrs)='object' ELSE 0 END RETURNING id", table, table);
sqlite3_stmt* st = NULL; int rows = 0;
rc = sqlite3_prepare_v2(db, sql, -1, &st, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)timestamp);
if (rc == SQLITE_OK) rc = signature ? sqlite3_bind_blob(st, 2, signature, DB_SIG_SIZE, SQLITE_STATIC)
: sqlite3_bind_int64(st, 2, (sqlite3_int64)author);
if (rc == SQLITE_OK && signature) rc = sqlite3_bind_text(st, 3, attributes, -1, SQLITE_STATIC);
if (rc == SQLITE_OK) while ((rc = sqlite3_step(st)) == SQLITE_ROW) rows++;
int final = sqlite3_finalize(st);
if (rc == SQLITE_DONE && final == SQLITE_OK && rows > 0) rc = sqlite3_exec(db, "COMMIT /* local attrs */", NULL, NULL, NULL);
else if (rc == SQLITE_DONE) rc = final != SQLITE_OK ? final : SQLITE_NOTFOUND;
if (rc != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "local attrs: action=%s channel=%s rows=%d rc=%d finalize=%d: %s",
action, channel, rows, rc, final, sqlite3_errmsg(db));
if (!sqlite3_get_autocommit(db)) {
int rollback = sqlite3_exec(db, "ROLLBACK /* local attrs */", NULL, NULL, NULL);
if (rollback != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "local attrs: rollback rc=%d", rollback);
if (!sqlite3_get_autocommit(db)) utun_instance_stop(inst);
}
return -1;
}
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "local attrs: committed action=%s channel=%s timestamp=%llu rows=%d",
action, channel, (unsigned long long)timestamp, rows); return 0;
}
int chat_core_update_local_attrs(struct UTUN_INSTANCE* inst, const char* channel, uint64_t timestamp,
const uint8_t* signature, const char* attributes) {
if (!signature || !attributes) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "local attrs: signature/JSON required"); return -1; }
return msg_local_attrs_write(inst, channel, timestamp, signature, 0, attributes);
}
void chat_core_mark_voice_played(struct UTUN_INSTANCE* inst, const char* channel, uint64_t timestamp, uint64_t author) {
msg_local_attrs_write(inst, channel, timestamp, NULL, author, NULL);
}
void chat_core_mark_voice_played_trampoline(void* arg) {
struct voice_played_req* req = (struct voice_played_req*)arg;
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "voice played: null request"); return; }
if (!memchr(req->channel_id, 0, sizeof(req->channel_id))) DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "voice played: unterminated channel");
else chat_core_mark_voice_played(req->inst, req->channel_id, req->ts, req->author_node_id);
u_free(req);
}
/* ─── DB-операции для chat_sync ─── */
uint32_t chat_core_count(struct UTUN_INSTANCE* inst, const char* channel) {
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
if (!cc || !cc->initialized || !chat_channel_id(channel)) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "message count: invalid context/channel"); return 0;
}
char table[80], sql[128]; msg_table_name(channel, table, sizeof(table));
snprintf(sql, sizeof(sql), "SELECT COUNT(*) FROM \"%s\"", table);
sqlite3_stmt* st = NULL; sqlite3_int64 count = -1;
int rc = sqlite3_prepare_v2(cc->db, sql, -1, &st, NULL);
if (rc == SQLITE_OK) rc = sqlite3_step(st);
if (rc == SQLITE_ROW) count = sqlite3_column_int64(st, 0);
int final = sqlite3_finalize(st);
if (rc != SQLITE_ROW || final != SQLITE_OK || count < 0 || count > UINT32_MAX) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "message count: channel=%s rc=%d finalize=%d count=%lld: %s",
channel, rc, final, (long long)count, sqlite3_errmsg(cc->db)); return 0;
}
return (uint32_t)count;
}
/* Ограниченный буфер даёт только целые первые записи;SQL всё равно читается до конца для проверки ошибок. */
static int msg_list_ids(struct UTUN_INSTANCE* inst, const char* channel, uint8_t* buf, size_t capacity, size_t* length) {
if (length) *length = 0;
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
if (!cc || !cc->initialized || !buf || !length || capacity < 2 || (channel && !chat_channel_id(channel))) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "ID list: invalid context/channel/buffer capacity=%zu", capacity); return -1;
}
size_t maximum = 2 + (size_t)UINT16_MAX * (channel ? 8 : 21);
if (capacity > maximum) capacity = maximum;
uint8_t* temporary = u_malloc(capacity);
if (!temporary) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "ID list: allocation bytes=%zu failed", capacity); return -1; }
char sql[160];
if (channel) snprintf(sql, sizeof(sql), "SELECT node_id FROM peers_%s WHERE deleted=0 AND admitted=1 ORDER BY node_id<0,node_id", channel);
else strcpy(sql, "SELECT channel_id FROM channels ORDER BY created_at,channel_id");
sqlite3_stmt* st = NULL; size_t used = 2; uint16_t count = 0; int full = 0;
int rc = sqlite3_prepare_v2(cc->db, sql, -1, &st, NULL);
if (rc == SQLITE_OK) while ((rc = sqlite3_step(st)) == SQLITE_ROW) {
uint64_t node = 0; const char* id = NULL; size_t bytes = 8;
if (channel) {
if (sqlite3_column_type(st, 0) != SQLITE_INTEGER || !(node = (uint64_t)sqlite3_column_int64(st, 0))) {
rc = SQLITE_CORRUPT; break;
}
} else {
int type = sqlite3_column_type(st, 0); id = (const char*)sqlite3_column_text(st, 0);
int size = sqlite3_column_bytes(st, 0);
if (type != SQLITE_TEXT || !id || size < 1 || size > 19 || strlen(id) != (size_t)size || !chat_channel_id(id)) {
rc = SQLITE_CORRUPT; break;
}
bytes = (size_t)size + 1;
}
if (full || count == UINT16_MAX || bytes > capacity - used) { full = 1; continue; }
if (channel) memcpy(temporary + used, &node, 8);
else { temporary[used] = (uint8_t)(bytes - 1); memcpy(temporary + used + 1, id, bytes - 1); }
used += bytes; count++;
}
int final = sqlite3_finalize(st);
if (rc != SQLITE_DONE || final != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "ID list: channel=%s rc=%d finalize=%d: %s",
channel ? channel : "all", rc, final, sqlite3_errmsg(cc->db)); u_free(temporary); return -1;
}
memcpy(temporary, &count, 2); memcpy(buf, temporary, used); *length = used; u_free(temporary);
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "ID list: channel=%s count=%u bytes=%zu limited=%d", channel ? channel : "all", count, used, full);
return 0;
}
int chat_core_list_channels(struct UTUN_INSTANCE* inst, uint8_t* buf, size_t capacity, size_t* length) {
return msg_list_ids(inst, NULL, buf, capacity, length);
}
int chat_core_list_peers(struct UTUN_INSTANCE* inst, const char* channel, uint8_t* buf, size_t capacity, size_t* length) {
if (!channel) { if (length) *length = 0; DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "peer list: channel required"); return -1; }
return msg_list_ids(inst, channel, buf, capacity, length);
}
int chat_core_load_nodeinfo(struct UTUN_INSTANCE* inst, uint64_t node, uint8_t* buf, size_t capacity, size_t* length) {
if (length) *length = 0;
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
if (!cc || !cc->initialized || !node || !buf || !length || capacity < 65) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "node info: invalid arguments/capacity=%zu", capacity); return -1;
}
uint8_t temporary[65 + 255 * 23] = {0}; size_t used = 65; unsigned count = 0; int full = 0;
sqlite3_stmt* st = NULL;
int rc = sqlite3_prepare_v2(cc->db, "SELECT x25519_pubkey,ed25519_pubkey FROM nodes WHERE node_id=?", -1, &st, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)node);
if (rc == SQLITE_OK) rc = sqlite3_step(st);
if (rc == SQLITE_ROW) {
for (int i = 0; i < 2; i++) {
int type = sqlite3_column_type(st, i); const void* key = sqlite3_column_blob(st, i);
int bytes = sqlite3_column_bytes(st, i);
if (type == SQLITE_NULL) continue;
if (type != SQLITE_BLOB || !key || bytes != 32) { rc = SQLITE_CORRUPT; break; }
memcpy(temporary + i * 32, key, 32);
}
}
int final = sqlite3_finalize(st); st = NULL;
if (rc != SQLITE_ROW || final != SQLITE_OK) goto fail;
rc = sqlite3_prepare_v2(cc->db, "SELECT family,protocol,address,port,rtt FROM node_addresses WHERE node_id=?"
" ORDER BY family,protocol,socket_id,address_kind,address,port", -1, &st, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)node);
if (rc == SQLITE_OK) while ((rc = sqlite3_step(st)) == SQLITE_ROW) {
int family_type = sqlite3_column_type(st, 0), proto_type = sqlite3_column_type(st, 1);
int address_type = sqlite3_column_type(st, 2), port_type = sqlite3_column_type(st, 3), rtt_type = sqlite3_column_type(st, 4);
int family = sqlite3_column_int(st, 0), protocol = sqlite3_column_int(st, 1), port = sqlite3_column_int(st, 3);
int rtt = sqlite3_column_int(st, 4), bytes = sqlite3_column_bytes(st, 2);
const void* address = sqlite3_column_blob(st, 2);
if (family_type != SQLITE_INTEGER || proto_type != SQLITE_INTEGER || address_type != SQLITE_BLOB ||
port_type != SQLITE_INTEGER || (rtt_type != SQLITE_INTEGER && rtt_type != SQLITE_NULL) ||
(family != 4 && family != 6) || bytes != (family == 4 ? 4 : 16) || !address || port < 0 || port > UINT16_MAX ||
(protocol != 1 && protocol != 2 && protocol != 6)) { rc = SQLITE_CORRUPT; break; }
if (full || count == 255 || (size_t)bytes + 7 > capacity - used) { full = 1; continue; }
temporary[used++] = (uint8_t)family; temporary[used++] = (uint8_t)protocol; temporary[used++] = (uint8_t)bytes;
memcpy(temporary + used, address, (size_t)bytes); used += (size_t)bytes;
uint16_t wire_port = (uint16_t)port; int16_t wire_rtt = rtt < INT16_MIN ? INT16_MIN : rtt > INT16_MAX ? INT16_MAX : (int16_t)rtt;
memcpy(temporary + used, &wire_port, 2); used += 2; memcpy(temporary + used, &wire_rtt, 2); used += 2; count++;
}
final = sqlite3_finalize(st);
if (rc != SQLITE_DONE || final != SQLITE_OK) goto fail;
temporary[64] = (uint8_t)count; memcpy(buf, temporary, used); *length = used;
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "node info: node=%016llx addresses=%u bytes=%zu limited=%d", (unsigned long long)node, count, used, full);
return 0;
fail:
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "node info: node=%016llx rc=%d finalize=%d: %s",
(unsigned long long)node, rc, final, sqlite3_errmsg(cc->db)); return -1;
}
/* ─── download completion ─── */
struct md_done_ctx {
struct UTUN_INSTANCE* inst;
char channel_id[64];
uint64_t ts;
int64_t msg_id;
uint8_t author_sig[64];
uint8_t message_id[32];
char dest_relpath[512];
char dest_abs_path[1024];
char content_type[32];
uint64_t author_node_id;
};
/* Локальный номер не является сетевым ID; асинхронная загрузка сохраняет постоянный ключ из БД. */
static int msg_read_id(struct chat_core_ctx* cc, const char* channel, int64_t id, uint8_t message_id[32]) {
char table[80], sql[160]; msg_table_name(channel, table, sizeof(table));
snprintf(sql, sizeof(sql), "SELECT message_id FROM \"%s\" WHERE id=?", table);
sqlite3_stmt* st = NULL; int rc = sqlite3_prepare_v2(cc->db, sql, -1, &st, NULL), result = -1;
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, id);
if (rc == SQLITE_OK) rc = sqlite3_step(st);
if (rc == SQLITE_ROW && sqlite3_column_bytes(st, 0) == 32) {
const void* key = sqlite3_column_blob(st, 0);
if (key) { memcpy(message_id, key, 32); result = 0; }
}
int final_rc = sqlite3_finalize(st);
if (final_rc != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "media identity finalize failed ch=%s id=%lld rc=%d", channel, (long long)id, final_rc);
result = -1;
}
if (result) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "media identity missing ch=%s id=%lld rc=%d: %s",
channel, (long long)id, rc, sqlite3_errmsg(cc->db));
return result;
}
static void md_download_progress_cb(void* arg, int blocks_done, int num_blocks) {
struct md_done_ctx* ctx = (struct md_done_ctx*)arg;
uint8_t evt[256];
uint8_t cl = (uint8_t)strlen(ctx->channel_id);
evt[0] = cl; memcpy(evt + 1, ctx->channel_id, cl);
memcpy(evt + 1 + cl, &ctx->msg_id, 8);
uint32_t bd = (uint32_t)blocks_done, nb = (uint32_t)num_blocks;
memcpy(evt + 1 + cl + 8, &bd, 4); memcpy(evt + 1 + cl + 12, &nb, 4);
chat_event_post(ctx->inst, CHAT_EVT_DOWNLOAD_PROGRESS, evt, 1 + cl + 16);
}
static void md_download_done_cb(void* arg, int err) {
struct md_done_ctx* ctx = (struct md_done_ctx*)arg;
struct chat_core_ctx* cc = CC(ctx->inst);
if (!err) {
if (!strcmp(ctx->content_type, ENCRYPTED_ATTACHMENT_TYPE)) {
if (chat_attachment_downloaded(ctx->inst, ctx->channel_id, ctx->message_id, 1))
media_queue_channel_backfill(ctx->inst, ctx->channel_id);
}
char attrs[1024];
snprintf(attrs, sizeof(attrs), "{\"st\":\"fl\",\"fp\":\"%s\"}", ctx->dest_relpath);
chat_core_update_local_attrs(ctx->inst, ctx->channel_id, ctx->ts, ctx->author_sig, attrs);
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "%s: media downloaded ch=%s", CC_ID, ctx->channel_id);
/* whisper транскрипция для голосовых сообщений */
if (ctx->content_type[0] && strncmp(ctx->content_type, "voice", 5) == 0
&& chat_setting_get_int(ctx->inst, "whisper_enabled", 0)) {
chat_whisper_transcribe(ctx->inst, ctx->dest_abs_path, ctx->channel_id,
ctx->message_id,
wh_transcribe_done_cb, ctx->inst);
}
/* после докачки — учесть файл в счётчике и применить лимит медиакеша */
cache_bytes_add(cc, ma_file_size(ctx->dest_abs_path));
chat_media_cache_enforce(ctx->inst);
} else {
DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "%s: media download failed ch=%s err=%d", CC_ID, ctx->channel_id, err);
/* пометить сообщение ошибкой, чтобы UI показал «повторить» */
char attrs[64];
snprintf(attrs, sizeof(attrs), "{\"st\":\"er\"}");
chat_core_update_local_attrs(ctx->inst, ctx->channel_id, ctx->ts, ctx->author_sig, attrs);
}
uint8_t evt[80]; uint8_t cl = (uint8_t)strlen(ctx->channel_id);
evt[0] = cl; memcpy(evt + 1, ctx->channel_id, cl);
memcpy(evt + 1 + cl, &ctx->msg_id, 8);
chat_event_post(ctx->inst, CHAT_EVT_ATTACHMENT_DOWNLOADED, evt, 1 + cl + 8);
u_free(ctx);
}
static void wh_transcribe_done_cb(void* arg, const char* ch_id, const char* text, int err,
const uint8_t reply_id[32]) {
struct UTUN_INSTANCE* inst = (struct UTUN_INSTANCE*)arg;
if (err || !text || !text[0]) {
DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "%s: transcription failed ch=%s err=%d", CC_ID, ch_id, err);
return;
}
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
if (!cc || !cc->initialized || !chat_channel_id(ch_id) || !reply_id) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "transcription: inactive core/invalid identity"); return;
}
/* Не публикуем результат после удаления исходного сообщения/канала. */
char table[80], sql[192]; msg_table_name(ch_id, table, sizeof(table));
snprintf(sql, sizeof(sql), "SELECT timestamp,node_id FROM \"%s\" WHERE message_id=?", table);
sqlite3_stmt* st = NULL;
int rc = sqlite3_prepare_v2(cc->db, sql, -1, &st, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 1, reply_id, 32, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_step(st);
if (rc != SQLITE_ROW) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "transcription: original unavailable ch=%s rc=%d", ch_id, rc);
int final = sqlite3_finalize(st);
if (final != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "transcription: cleanup finalize rc=%d", final);
return;
}
uint64_t ts = (uint64_t)sqlite3_column_int64(st, 0), author = (uint64_t)sqlite3_column_int64(st, 1);
rc = sqlite3_finalize(st);
if (rc != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "transcription: read finalize rc=%d", rc); return; }
struct chat_msg_submit req = {.inst = inst, .data = (uint8_t*)text, .data_len = (uint32_t)strlen(text)};
memcpy(req.channel_id, ch_id, strlen(ch_id) + 1); strcpy(req.content_type, "voice_transcription"); memcpy(req.reply_to, reply_id, 32);
if (chat_core_submit_message(inst, &req)) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "transcription: message save failed ch=%s bytes=%u", ch_id, req.data_len); return;
}
DEBUG_INFO(DEBUG_CATEGORY_CHAT, "transcription: saved ch=%s bytes=%u", ch_id, req.data_len);
chat_core_mark_voice_played(inst, ch_id, ts, author);
}
static void sanitize_filename(char* buf, size_t size) {
char orig[256]; snprintf(orig, sizeof(orig), "%s", buf);
size_t w = 0;
for (size_t r = 0; buf[r]; r++) {
char c = buf[r];
if ((unsigned char)c < 0x20) continue;
if (c == '/' || c == '\\' || c == ':' || c == '*' || c == '?'
|| c == '"' || c == '<' || c == '>' || c == '|') continue;
if (w < size - 1) buf[w++] = c;
}
buf[w] = '\0';
if (w == 0) snprintf(buf, size, "file");
if (strcmp(orig, buf) != 0)
DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "%s: filename sanitized [%s] -> [%s]", CC_ID, orig, buf);
}
/* Извлекает имя файла из тела "d"-поля медиасообщения: берёт сегмент до '|',
декодирует base64url-префикс '*', если он есть, и санитизирует. */
static void extract_base_filename(const char* body, char* out, size_t out_sz) {
size_t bfi = 0;
while (body[bfi] && body[bfi] != '|' && bfi < out_sz - 1)
out[bfi++] = body[bfi];
out[bfi] = '\0';
if (out[0] == '*' && bfi > 1) {
uint8_t dec[256];
int dlen = (int)b64_decode(out + 1, bfi - 1, dec, sizeof(dec));
if (dlen > 0 && dlen < (int)out_sz) { memcpy(out, dec, (size_t)dlen); out[dlen] = '\0'; }
}
if (bfi == 0) snprintf(out, out_sz, "file");
sanitize_filename(out, out_sz);
}
/* Беззнаковое десятичное число до delimiter; delimiter=0 требует точного конца строки. */
static int msg_media_number(const char** cursor, const char* end, uint64_t* value, char delimiter) {
const char* p = *cursor; uint64_t number = 0;
if (p == end || *p < '0' || *p > '9') return -1;
while (p < end && *p >= '0' && *p <= '9') {
unsigned digit = (unsigned)(*p++ - '0');
if (number > ((uint64_t)INT64_MAX - digit) / 10) return -1;
number = number * 10 + digit;
}
if (delimiter ? (p == end || *p++ != delimiter) : p != end) return -1;
*cursor = p; *value = number; return 0;
}
static int msg_media_hex(const char** cursor, const char* end, uint8_t* out, size_t bytes, char delimiter) {
const char* p = *cursor;
if ((size_t)(end - p) < bytes * 2 + (delimiter ? 1 : 0)) return -1;
for (size_t i = 0; i < bytes; i++) {
unsigned value = 0;
for (unsigned j = 0; j < 2; j++) {
unsigned c = (unsigned char)*p++, digit;
if (c >= '0' && c <= '9') digit = c - '0';
else if (c >= 'a' && c <= 'f') digit = c - 'a' + 10;
else if (c >= 'A' && c <= 'F') digit = c - 'A' + 10;
else return -1;
value = value * 16 + digit;
}
out[i] = (uint8_t)value;
}
if (delimiter && *p++ != delimiter) return -1;
*cursor = p; return 0;
}
/* Проверяет блоки и необязательный хвост |[dur=<ms>|]w=<width>|h=<height>; памяти хвоста не выделяет. */
int chat_msg_parse_media_body(const char* body, struct media_index_result* output) {
if (!body || !output) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "media manifest: body/result required"); return -1; }
memset(output, 0, sizeof(*output));
const char* stage = "length/name";
size_t length = strnlen(body, DB_SYNC_MESSAGE_MAX + 1);
if (length > DB_SYNC_MESSAGE_MAX) goto invalid;
const char* end = body + length; const char* p = memchr(body, '|', length);
if (!p) return -1; /* Обычный текст также проходит через scans. */
if (p == body) goto invalid;
p++;
uint64_t size, block, count;
stage = "block geometry";
if (msg_media_number(&p, end, &size, '|') || msg_media_number(&p, end, &block, '|') ||
msg_media_number(&p, end, &count, '|') || !size || !block || !count ||
count > 100 || (size - 1) / block + 1 != count) goto invalid;
struct media_index_result result = {.file_size = (int64_t)size, .block_size = (int64_t)block, .num_blocks = (int)count};
stage = "media identity";
if (msg_media_hex(&p, end, result.media_id, 16, '|') || msg_media_hex(&p, end, result.content_hash, 32, '|')) goto invalid;
const char* pairs_end = memchr(p, '|', (size_t)(end - p));
if (!pairs_end) pairs_end = end;
stage = "block signature length";
if ((size_t)(pairs_end - p) != (size_t)count * 162 - 1) goto invalid;
if (pairs_end < end) {
const char* metadata = pairs_end + 1; uint64_t duration, width, height;
stage = "media dimensions/duration";
if (end - metadata >= 4 && !memcmp(metadata, "dur=", 4)) {
metadata += 4;
if (msg_media_number(&metadata, end, &duration, '|') || !duration) goto invalid;
}
if (end - metadata < 2 || memcmp(metadata, "w=", 2)) goto invalid;
metadata += 2;
if (msg_media_number(&metadata, end, &width, '|') || width > INT_MAX || end - metadata < 2 ||
memcmp(metadata, "h=", 2)) goto invalid;
metadata += 2;
if (msg_media_number(&metadata, end, &height, 0) || height > INT_MAX || (!width && !height)) goto invalid;
}
result.block_ids = u_malloc((size_t)count * 16); result.block_sigs = u_malloc((size_t)count * 64);
if (!result.block_ids || !result.block_sigs) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "media manifest: allocation failed blocks=%llu", (unsigned long long)count);
media_index_result_free(&result); return -2;
}
stage = "block signature encoding";
for (unsigned i = 0; i < count; i++) {
if (msg_media_hex(&p, pairs_end, result.block_ids + i * 16, 16, ',') ||
msg_media_hex(&p, pairs_end, result.block_sigs + i * 64, 64, i + 1 < count ? ',' : 0)) {
media_index_result_free(&result); goto invalid;
}
}
*output = result; return 0;
invalid:
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "media manifest: invalid %s bytes=%zu", stage, length); return -1;
}
/* Имя файла на диске: голосовое — voice_<media_id>.opus, иначе — базовое имя из "d". */
static void media_final_name(const struct media_index_result* result, const char* content_type,
const char* base_filename, char* out, size_t out_sz) {
if (content_type && strcmp(content_type, "audio/opus") == 0) {
char hex[33];
for (int i = 0; i < 16; i++) snprintf(hex + i * 2, 3, "%02x", result->media_id[i]);
snprintf(out, out_sz, "voice_%s.opus", hex);
} else {
snprintf(out, out_sz, "%s", base_filename);
}
}
static int md_start_download(struct UTUN_INSTANCE* inst,
const char* data_str, size_t data_len,
const char* ch_id, const char* base_filename,
uint64_t ts, const uint8_t* author_sig,
uint64_t author_node_id, int64_t msg_id,
const char* content_type) {
(void)data_len;
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
struct media_index_result result;
if (chat_msg_parse_media_body(data_str, &result) != 0) return -1;
int nb = result.num_blocks;
long long fsize = result.file_size;
/* для голосовых имя генерируем из media_id: msg_data — это wf-строка, а не имя файла */
char final_name[256];
media_final_name(&result, content_type, base_filename, final_name, sizeof(final_name));
char dest[MEDIA_PATH_MAX], media_base[MEDIA_BASE_MAX], relative[MEDIA_PATH_MAX];
int length = snprintf(relative, sizeof(relative), "media/%s/%s", ch_id, final_name);
if (length < 0 || length >= (int)sizeof(relative) || media_index_get_base(cc->db, media_base)
|| media_index_path(media_base, relative, dest)) { media_index_result_free(&result); return -1; }
struct md_done_ctx* ctx = u_calloc(1, sizeof(*ctx));
if (!ctx) { media_index_result_free(&result); return -1; }
ctx->inst = inst;
snprintf(ctx->channel_id, sizeof(ctx->channel_id), "%s", ch_id);
ctx->ts = ts; ctx->msg_id = msg_id;
if (msg_read_id(cc, ch_id, msg_id, ctx->message_id) < 0) {
media_index_result_free(&result); u_free(ctx); return -1;
}
snprintf(ctx->dest_relpath, sizeof(ctx->dest_relpath), "%s", final_name);
snprintf(ctx->dest_abs_path, sizeof(ctx->dest_abs_path), "%s", dest);
ctx->author_node_id = author_node_id;
if (content_type) snprintf(ctx->content_type, sizeof(ctx->content_type), "%s", content_type);
else ctx->content_type[0] = '\0';
if (author_sig) memcpy(ctx->author_sig, author_sig, 64);
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "%s: download start ch=%s ct=%s file=%s blocks=%d size=%lld dest=%s",
CC_ID, ch_id, content_type ? content_type : "?", final_name, nb, (long long)fsize, dest);
uint64_t gid = strtoull(ch_id, NULL, 10);
int started = media_download_start(inst, gid, &result, dest, media_base, author_node_id,
md_download_done_cb, ctx, md_download_progress_cb, ctx);
media_index_result_free(&result);
if (started < 0) {
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "media download request declined ch=%s path=%s; see download reason", ch_id, dest); u_free(ctx);
}
return started;
}
static int md_auto_download(struct UTUN_INSTANCE* inst,
const char* data_str, size_t data_len,
const char* ch_id, const char* base_filename,
uint64_t ts, const uint8_t* author_sig,
uint64_t author, int64_t msg_id,
const char* content_type) {
if (!chat_setting_get_int(inst, "storage_autoload", 1)) return 0;
uint64_t max_size = inst->config->global.chatserver_storage_unit_size;
const char* p = data_str;
while (*p && *p != '|') p++;
if (!*p) return 0; p++;
long long fsize = strtoll(p, (char**)&p, 10);
if (max_size > 0 && fsize > (long long)max_size) {
DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "%s: media too large (%lld > %llu bytes), skip auto-download ch=%s file=%s",
CC_ID, fsize, (unsigned long long)max_size, ch_id, base_filename);
return 0;
}
return md_start_download(inst, data_str, data_len, ch_id, base_filename, ts, author_sig, author, msg_id, content_type) == 0 ? 1 : 0;
}
/* ─── db_sync callback ─── */
void on_msg_inserted(struct DB_SYNC_INSTANCE* si, const uint8_t message_id[32], uint64_t record_ts,
const char* data, size_t len, uint64_t author, int initial_sync, int updated, void* arg) {
struct UTUN_INSTANCE* inst = arg;
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
if (!cc) return;
char ch_id[32]; snprintf(ch_id, sizeof(ch_id), "%llu", (unsigned long long)db_sync_instance_group_id(si));
if (updated) {
uint8_t evt[65]; uint8_t size = (uint8_t)strlen(ch_id); evt[0] = size; memcpy(evt + 1, ch_id, size);
memcpy(evt + 1 + size, message_id, 32);
chat_event_post(inst, CHAT_EVT_MSG_UPDATED, evt, 1 + size + 32);
return;
}
int attachment_result = chat_attachment_observe(inst, ch_id, message_id, author, data, len);
if (attachment_result) {
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "encrypted media: manifest indexing deferred/rejected ch=%s result=%d", ch_id, attachment_result);
if (attachment_result == -1) media_queue_channel_backfill(inst, ch_id);
}
sqlite3_stmt* touched = NULL;
int touch = sqlite3_prepare_v2(cc->db, "UPDATE channels SET last_msg_at=max(last_msg_at,?1) WHERE channel_id=?2", -1, &touched, NULL);
if (touch == SQLITE_OK) touch = sqlite3_bind_int64(touched, 1, (sqlite3_int64)record_ts);
if (touch == SQLITE_OK) touch = sqlite3_bind_text(touched, 2, ch_id, -1, SQLITE_STATIC);
if (touch == SQLITE_OK) touch = sqlite3_step(touched);
int touch_final = sqlite3_finalize(touched);
if (touch != SQLITE_DONE || touch_final != SQLITE_OK)
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "message: channel timestamp failed ch=%s rc=%d finalize=%d", ch_id, touch, touch_final);
chat_feedback_check_message(inst, ch_id, message_id);
uint8_t evt[73]; uint8_t cl = (uint8_t)strlen(ch_id); evt[0] = cl; memcpy(evt + 1, ch_id, cl);
memcpy(evt + 1 + cl, &author, 8);
evt[1 + cl + 8] = initial_sync != 0;
chat_event_post(inst, CHAT_EVT_MSG_RECEIVED, evt, 1 + cl + 9);
if (attachment_result) return; // История уже committed; ошибка производного индекса не отменяет её уведомление.
/* Поля JSON и постоянный ID одинаковы для live-PUSH и повторной загрузки. */
if (data && len && author != cc->my_node_id && !initial_sync) {
char* body = NULL; char content_type[32], filename[256];
int fields = msg_media_fields(cc->db, data, len, NULL, &body, content_type, filename);
if (!fields) {
extract_base_filename(body, filename, sizeof(filename));
char table[80], sql[192]; msg_table_name(ch_id, table, sizeof(table));
snprintf(sql, sizeof(sql), "SELECT author_signature,id FROM \"%s\" WHERE message_id=?", table);
sqlite3_stmt* st = NULL; uint8_t signature[64]; int64_t id = 0;
int rc = sqlite3_prepare_v2(cc->db, sql, -1, &st, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 1, message_id, 32, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_step(st);
if (rc == SQLITE_ROW && sqlite3_column_bytes(st, 0) == 64 && sqlite3_column_blob(st, 0)) {
memcpy(signature, sqlite3_column_blob(st, 0), 64); id = sqlite3_column_int64(st, 1);
}
int final = sqlite3_finalize(st);
if (rc == SQLITE_ROW && id > 0 && final == SQLITE_OK)
md_auto_download(inst, body, strlen(body), ch_id, filename, record_ts, signature, author, id, content_type);
else DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "live media: identity read failed ch=%s rc=%d finalize=%d", ch_id, rc, final);
} else if (fields < 0) media_queue_channel_backfill(inst, ch_id);
u_free(body);
}
}
/* ─── manual attachment download (called from JNI bridge via uasync post) ─── */
void chat_core_attachment_download(struct UTUN_INSTANCE* inst, const char* channel_id, int64_t msg_id) {
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
if (!cc || !cc->initialized || !chat_channel_id(channel_id) || msg_id <= 0 || !sqlite3_get_autocommit(cc->db)) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "manual media download: invalid state/arguments or external transaction"); return;
}
char tbl[80]; msg_table_name(channel_id, tbl, sizeof(tbl));
char sql[256];
snprintf(sql, sizeof(sql), "SELECT data, timestamp, author_signature, node_id FROM \"%s\" WHERE id=?", tbl);
sqlite3_stmt* st = NULL;
if (sqlite3_prepare_v2(cc->db, sql, -1, &st, NULL) != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "%s: download sql prep failed ch=%s", CC_ID, channel_id);
return;
}
int read = sqlite3_bind_int64(st, 1, (sqlite3_int64)msg_id);
if (read == SQLITE_OK) read = sqlite3_step(st);
if (read != SQLITE_ROW) {
DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "%s: download msg not found ch=%s id=%lld", CC_ID, channel_id, (long long)msg_id);
sqlite3_finalize(st);
return;
}
const uint8_t* jdata = sqlite3_column_text(st, 0);
int jlen = sqlite3_column_bytes(st, 0);
int64_t db_ts = sqlite3_column_int64(st, 1);
const uint8_t* sig_blob = (const uint8_t*)sqlite3_column_blob(st, 2);
int sig_len = sqlite3_column_bytes(st, 2);
int64_t author_node_id = sqlite3_column_int64(st, 3);
if (!jdata || jlen <= 0 || jlen > DB_SYNC_MESSAGE_MAX || !sig_blob || sig_len != 64) {
DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "%s: download bad msg data ch=%s id=%lld", CC_ID, channel_id, (long long)msg_id);
sqlite3_finalize(st);
return;
}
char* body = NULL; char content_type[32], ignored_filename[256];
int fields = msg_media_fields(cc->db, (const char*)jdata, (size_t)jlen, NULL, &body, content_type, ignored_filename);
if (fields) { sqlite3_finalize(st); return; }
size_t bi = strlen(body); char body_storage[DB_SYNC_MESSAGE_MAX + 1];
memcpy(body_storage, body, bi + 1); u_free(body); body = body_storage;
char base_filename[256];
extract_base_filename(body, base_filename, sizeof(base_filename));
uint8_t author_sig[64]; memcpy(author_sig, sig_blob, 64);
uint8_t message[32]; int encrypted_attachment = !strcmp(content_type, ENCRYPTED_ATTACHMENT_TYPE);
if (encrypted_attachment) {
int observed = msg_read_id(cc, channel_id, msg_id, message) ? -1 :
chat_attachment_observe(inst, channel_id, message, (uint64_t)author_node_id, (const char*)jdata, (size_t)jlen);
if (observed) {
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "manual encrypted download: cannot index manifest ch=%s result=%d", channel_id, observed);
if (observed == -1) media_queue_channel_backfill(inst, channel_id);
sqlite3_finalize(st); return;
}
}
int final_rc = sqlite3_finalize(st);
if (final_rc != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "manual media download: history finalize failed ch=%s rc=%d", channel_id, final_rc); return;
}
if (encrypted_attachment) {
if (chat_attachment_want(inst, channel_id, message)) return;
sqlite3_stmt* encrypted = NULL;
int rc = sqlite3_prepare_v2(cc->db, "SELECT cipher_path FROM attachment_keys WHERE channel=? AND message_id=? AND downloaded=1",
-1, &encrypted, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_text(encrypted, 1, channel_id, -1, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(encrypted, 2, message, 32, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_step(encrypted);
const char* path = rc == SQLITE_ROW ? (const char*)sqlite3_column_text(encrypted, 0) : NULL;
int present = path && ma_file_size(path) > 0;
int finish = sqlite3_finalize(encrypted);
if ((rc != SQLITE_ROW && rc != SQLITE_DONE) || finish != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "encrypted media: local file lookup failed ch=%s step=%d finalize=%d db=%s",
channel_id, rc, finish, sqlite3_errmsg(cc->db)); return;
}
if (present) {
if (chat_attachment_downloaded(inst, channel_id, message, 0)) media_queue_channel_backfill(inst, channel_id);
return;
}
}
/* сбрасываем пометку «файл есть»: файл мог быть вычищен кешем, а local_attrs ещё "fl".
по завершении md_download_done_cb снова запишет st=fl (успех) или st=er (ошибка). */
chat_core_update_local_attrs(inst, channel_id, (uint64_t)db_ts, author_sig, "{}");
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "%s: manual download ch=%s id=%lld ct=%s file=%s",
CC_ID, channel_id, (long long)msg_id, content_type, base_filename);
md_start_download(inst, body, bi, channel_id, base_filename, (uint64_t)db_ts, author_sig, (uint64_t)author_node_id, msg_id, content_type);
}
static void chat_core_attachment_download_trampoline_impl(void* arg) {
struct attachment_dl_req* req = (struct attachment_dl_req*)arg;
if (!req) return;
chat_core_attachment_download(req->inst, req->channel_id, req->msg_id);
u_free(req);
}
void chat_core_attachment_download_trampoline(void* arg) {
chat_core_attachment_download_trampoline_impl(arg);
}
/* ─── Backfill: регистрация уже скачанных медиафайлов в media_files ─── */
void chat_media_backfill(struct UTUN_INSTANCE* inst) {
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
if (!cc || !cc->initialized || !cc->db) return;
char media_base[MEDIA_BASE_MAX];
if (media_index_get_base(cc->db, media_base)) return;
int total = 0;
sqlite3_stmt* cs = NULL; int rc, failed = 0;
rc = sqlite3_prepare_v2(cc->db, "SELECT channel_id FROM channels WHERE EXISTS(SELECT 1 FROM sqlite_master"
" WHERE type='table' AND name='msg_'||channel_id)", -1, &cs, NULL);
if (rc != SQLITE_OK) goto finish;
while ((rc = sqlite3_step(cs)) == SQLITE_ROW) {
const char* ch_id = (const char*)sqlite3_column_text(cs, 0);
if (!chat_channel_id(ch_id) || strlen(ch_id) != (size_t)sqlite3_column_bytes(cs, 0)) { failed = 1; break; }
char tbl[80]; msg_table_name(ch_id, tbl, sizeof(tbl));
char sql[256];
snprintf(sql, sizeof(sql), "SELECT data,message_id,node_id,local_attrs FROM \"%s\"", tbl);
sqlite3_stmt* ms = NULL;
int mrc = sqlite3_prepare_v2(cc->db, sql, -1, &ms, NULL);
if (mrc != SQLITE_OK) { sqlite3_finalize(ms); media_queue_channel_backfill(inst, ch_id); failed = 1; break; }
while ((mrc = sqlite3_step(ms)) == SQLITE_ROW) {
const char* jdata = (const char*)sqlite3_column_text(ms, 0);
if (!jdata) continue;
char* body = NULL; char content_type[32], fp[256];
const char* attributes = (const char*)sqlite3_column_text(ms, 3);
int fields = msg_media_fields(cc->db, jdata, (size_t)sqlite3_column_bytes(ms, 0), attributes, &body, content_type, fp);
if (fields == 1) continue;
if (fields < 0) { failed = 1; break; }
struct media_index_result result;
int parsed = chat_msg_parse_media_body(body, &result);
if (parsed) { u_free(body); if (parsed == -2) { failed = 1; break; } continue; }
int encrypted_attachment = !strcmp(content_type, ENCRYPTED_ATTACHMENT_TYPE);
uint8_t message[32];
if (encrypted_attachment) {
if (sqlite3_column_bytes(ms, 1) != 32 || !sqlite3_column_blob(ms, 1)) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "media startup: missing encrypted message identity ch=%s", ch_id);
media_index_result_free(&result); u_free(body); continue;
}
memcpy(message, sqlite3_column_blob(ms, 1), 32);
int observed = chat_attachment_observe(inst, ch_id, message, (uint64_t)sqlite3_column_int64(ms, 2), jdata,
(size_t)sqlite3_column_bytes(ms, 0));
if (observed) {
if (observed == -1) media_queue_channel_backfill(inst, ch_id);
media_index_result_free(&result); u_free(body); continue;
}
}
char base_filename[256];
extract_base_filename(body, base_filename, sizeof(base_filename));
char final_name[256];
if (fp[0]) strcpy(final_name, fp);
else media_final_name(&result, content_type, base_filename, final_name, sizeof(final_name));
char path[MEDIA_PATH_MAX], relative[MEDIA_PATH_MAX];
snprintf(relative, sizeof(relative), "media/%s/%s", ch_id, final_name);
if (media_index_path(media_base, relative, path)) { u_free(body); media_index_result_free(&result); failed = 1; break; }
int64_t fsz = ma_file_size(path);
if (fsz <= 0 || fsz != result.file_size) {
u_free(body); media_index_result_free(&result);
continue;
}
int registered = media_index_commit(cc->db, &result, cc->my_node_id, NULL, ch_id, path, media_base);
if (!registered) total += result.num_blocks;
else {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "media backfill registration failed ch=%s file=%s", ch_id, path);
media_queue_channel_backfill(inst, ch_id);
}
if (encrypted_attachment && !registered && chat_attachment_downloaded(inst, ch_id, message, 0))
media_queue_channel_backfill(inst, ch_id);
u_free(body);
media_index_result_free(&result);
}
int final = sqlite3_finalize(ms);
if (mrc != SQLITE_DONE || final != SQLITE_OK || failed) {
media_queue_channel_backfill(inst, ch_id); failed = 1; break;
}
}
finish:
{
int final = sqlite3_finalize(cs);
if (rc != SQLITE_DONE || final != SQLITE_OK || failed) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "media startup: incomplete snapshot rc=%d finalize=%d: %s", rc, final, sqlite3_errmsg(cc->db));
media_queue_full_backfill(inst);
}
}
if (total > 0)
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "%s: media backfill done, %d blocks registered", CC_ID, total);
}
/* ─── Докачка недостающего медиа + вычистка старых файлов (единый проход) ─── */
struct bf_entry {
uint64_t ts_ms;
char full_path[1536];
int64_t claimed_size; /* размер из тела сообщения */
int64_t actual_size; /* >0 — файл на диске */
uint64_t author;
int64_t msg_id;
char ch_id[64];
char content_type[32];
char base_filename[256];
uint8_t author_sig[64];
char* body; /* "d"-поле (NULL для present) */
size_t body_len;
};
struct bf_list { struct bf_entry* items; int count, cap; };
static int bf_cmp_ts_desc(const void* a, const void* b) {
const struct bf_entry* x = (const struct bf_entry*)a;
const struct bf_entry* y = (const struct bf_entry*)b;
if (x->ts_ms > y->ts_ms) return -1;
if (x->ts_ms < y->ts_ms) return 1;
return 0;
}
/* Собирает медиасообщения (presence + размеры + контекст докачки). only_ch_id != NULL — только этот канал. */
static int bf_collect(struct chat_core_ctx* cc, const char* media_base, const char* only_ch_id, struct bf_list* l) {
sqlite3_stmt* cs = NULL; int rc, failed = 0, incomplete = 0;
rc = sqlite3_prepare_v2(cc->db, "SELECT channel_id FROM channels WHERE EXISTS(SELECT 1 FROM sqlite_master"
" WHERE type='table' AND name='msg_'||channel_id)", -1, &cs, NULL);
if (rc != SQLITE_OK) goto finish;
while ((rc = sqlite3_step(cs)) == SQLITE_ROW) {
const char* ch_id = (const char*)sqlite3_column_text(cs, 0);
if (!chat_channel_id(ch_id) || strlen(ch_id) != (size_t)sqlite3_column_bytes(cs, 0)) { failed = 1; break; }
if (only_ch_id && strcmp(only_ch_id, ch_id) != 0) continue;
char tbl[80]; msg_table_name(ch_id, tbl, sizeof(tbl));
char sql[256];
snprintf(sql, sizeof(sql),
"SELECT id, timestamp, node_id, data, author_signature, local_attrs, message_id FROM \"%s\"", tbl);
sqlite3_stmt* ms = NULL;
int mrc = sqlite3_prepare_v2(cc->db, sql, -1, &ms, NULL);
if (mrc != SQLITE_OK) { sqlite3_finalize(ms); failed = 1; break; }
while ((mrc = sqlite3_step(ms)) == SQLITE_ROW) {
int64_t msg_id = sqlite3_column_int64(ms, 0);
uint64_t ts = (uint64_t)sqlite3_column_int64(ms, 1);
uint64_t author = (uint64_t)sqlite3_column_int64(ms, 2);
const char* jdata = (const char*)sqlite3_column_text(ms, 3);
const uint8_t* sig_blob = (const uint8_t*)sqlite3_column_blob(ms, 4);
int sig_len = sqlite3_column_bytes(ms, 4);
const char* la = (const char*)sqlite3_column_text(ms, 5);
if (!jdata || !sig_blob || sig_len != 64) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "backfill snapshot: invalid stored message ch=%s id=%lld", ch_id, (long long)msg_id);
failed = 1; break;
}
char* body = NULL; char content_type[32], fp[256];
int fields = msg_media_fields(cc->db, jdata, (size_t)sqlite3_column_bytes(ms, 3), la, &body, content_type, fp);
if (fields == 1) continue;
if (fields < 0) { failed = 1; break; }
size_t dlen = strlen(body); struct media_index_result result;
int parsed = chat_msg_parse_media_body(body, &result);
if (parsed) { u_free(body); failed = 1; break; }
int encrypted_attachment = !strcmp(content_type, ENCRYPTED_ATTACHMENT_TYPE);
uint8_t message[32];
if (encrypted_attachment) {
if (sqlite3_column_bytes(ms, 6) != 32 || !sqlite3_column_blob(ms, 6)) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "media backfill: missing encrypted message identity ch=%s", ch_id);
media_index_result_free(&result); u_free(body); continue;
}
memcpy(message, sqlite3_column_blob(ms, 6), 32);
int observed = chat_attachment_observe(cc->inst, ch_id, message, author, jdata, (size_t)sqlite3_column_bytes(ms, 3));
if (observed) {
if (observed == -1) media_queue_channel_backfill(cc->inst, ch_id);
incomplete = 1;
media_index_result_free(&result); u_free(body); continue;
}
}
char base_filename[256];
extract_base_filename(body, base_filename, sizeof(base_filename));
char final_name[256];
if (fp[0]) snprintf(final_name, sizeof(final_name), "%s", fp);
else media_final_name(&result, content_type, base_filename, final_name, sizeof(final_name));
char full_path[MEDIA_PATH_MAX], relative[MEDIA_PATH_MAX];
snprintf(relative, sizeof(relative), "media/%s/%s", ch_id, final_name);
if (media_index_path(media_base, relative, full_path)) { u_free(body); media_index_result_free(&result); failed = 1; break; }
int64_t actual = ma_file_size(full_path);
if (actual < 0) actual = 0;
if (encrypted_attachment && actual > 0 && chat_attachment_downloaded(cc->inst, ch_id, message, 0))
media_queue_channel_backfill(cc->inst, ch_id);
int slot = -1;
for (int i = 0; i < l->count; i++) if (!strcmp(l->items[i].full_path, full_path)) { slot = i; break; }
if (slot >= 0 && l->items[slot].ts_ms >= ts) { media_index_result_free(&result); u_free(body); continue; }
if (slot < 0 && l->count == l->cap) {
if ((size_t)l->cap > UINT32_MAX / (2 * sizeof(struct bf_entry))) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "backfill snapshot exceeds allocator capacity");
media_index_result_free(&result); u_free(body); failed = 1; break;
}
int nc = l->cap ? l->cap * 2 : 64;
struct bf_entry* ni = u_realloc(l->items, (size_t)nc * sizeof(struct bf_entry));
if (!ni) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "backfill snapshot allocation failed");
media_index_result_free(&result); u_free(body); failed = 1; break; }
l->items = ni; l->cap = nc;
}
struct bf_entry* e = &l->items[slot < 0 ? l->count++ : slot];
if (slot >= 0) u_free(e->body);
memset(e, 0, sizeof(*e));
e->ts_ms = ts;
snprintf(e->full_path, sizeof(e->full_path), "%s", full_path);
e->claimed_size = result.file_size;
e->actual_size = actual;
e->author = author;
e->msg_id = msg_id;
snprintf(e->ch_id, sizeof(e->ch_id), "%s", ch_id);
snprintf(e->content_type, sizeof(e->content_type), "%s", content_type);
snprintf(e->base_filename, sizeof(e->base_filename), "%s", base_filename);
memcpy(e->author_sig, sig_blob, 64);
if (actual <= 0) { e->body = body; e->body_len = dlen; }
else u_free(body);
media_index_result_free(&result);
}
int final = sqlite3_finalize(ms);
if (mrc != SQLITE_DONE || final != SQLITE_OK || failed) { failed = 1; break; }
}
finish:
{
int final = sqlite3_finalize(cs);
if (rc != SQLITE_DONE || final != SQLITE_OK || failed || incomplete) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "backfill snapshot failed channel=%s rc=%d finalize=%d: %s",
only_ch_id ? only_ch_id : "all", rc, final, sqlite3_errmsg(cc->db)); return -1;
}
}
return 0;
}
void chat_media_autodownload_backfill(struct UTUN_INSTANCE* inst) {
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
if (!cc || !cc->initialized || !cc->db || !inst) return;
uint64_t limit = inst->config->global.chatserver_storage_total_size;
int autoload = chat_setting_get_int(inst, "storage_autoload", 1);
int days = chat_setting_get_int(inst, "storage_backfill_days", 7);
uint64_t now_ms = (uint64_t)ntp_time_get_seconds(inst) * 1000ULL;
uint64_t age_ms = (uint64_t)days * 86400ULL * 1000ULL;
uint64_t cutoff_ms = now_ms > age_ms ? now_ms - age_ms : 0;
char media_base[MEDIA_BASE_MAX];
if (media_index_get_base(cc->db, media_base)) return;
struct bf_list list = {0};
int collected = bf_collect(cc, media_base, NULL, &list);
if (collected) { media_queue_full_backfill(inst); goto release; }
if (list.count > 1) qsort(list.items, (size_t)list.count, sizeof(struct bf_entry), bf_cmp_ts_desc);
/* инициализация счётчика: сумма всех present-файлов */
cc->media_cache_bytes = 0;
for (int i = 0; i < list.count; i++)
if (list.items[i].actual_size > 0) cc->media_cache_bytes += (uint64_t)list.items[i].actual_size;
uint64_t used = 0;
int present = 0, downloaded = 0, evicted = 0, skipped = 0;
uint64_t evicted_bytes = 0;
for (int i = 0; i < list.count; i++) {
struct bf_entry* e = &list.items[i];
if (e->actual_size > 0) {
if (limit == 0 || used + (uint64_t)e->actual_size <= limit) {
used += (uint64_t)e->actual_size;
present++;
} else {
if (remove(e->full_path) == 0) {
cache_bytes_sub(cc, e->actual_size);
evicted++; evicted_bytes += (uint64_t)e->actual_size;
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "%s: cache evict: %s ts=%llu size=%lld",
CC_ID, e->full_path, (unsigned long long)e->ts_ms, (long long)e->actual_size);
} else {
DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "%s: cache evict failed: %s", CC_ID, e->full_path);
}
}
} else {
if (!autoload || e->author == cc->my_node_id) { skipped++; continue; }
if (e->ts_ms < cutoff_ms) { skipped++; continue; }
if (limit > 0 && used + (uint64_t)e->claimed_size > limit) { skipped++; continue; }
if (md_auto_download(inst, e->body, e->body_len, e->ch_id, e->base_filename,
e->ts_ms, e->author_sig, e->author, e->msg_id, e->content_type)) {
downloaded++;
used += (uint64_t)e->claimed_size;
} else {
skipped++;
}
}
}
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "%s: cache backfill done present=%d downloaded=%d skipped=%d evicted=%d evicted_bytes=%llu bytes=%llu limit=%llu",
CC_ID, present, downloaded, skipped, evicted, (unsigned long long)evicted_bytes,
(unsigned long long)cc->media_cache_bytes, (unsigned long long)limit);
release:
for (int i = 0; i < list.count; i++) if (list.items[i].body) u_free(list.items[i].body);
u_free(list.items);
chat_media_cache_reconcile(inst);
}
/* Докачка медиа одного канала после завершения его chat_sync.
Порядок — от последних сообщений назад (список сортируется desc по ts). */
void chat_media_autodownload_backfill_channel(struct UTUN_INSTANCE* inst, const char* ch_id) {
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
if (!cc || !cc->initialized || !cc->db || !inst || !ch_id || !ch_id[0]) return;
uint64_t limit = inst->config->global.chatserver_storage_total_size;
int autoload = chat_setting_get_int(inst, "storage_autoload", 1);
int days = chat_setting_get_int(inst, "storage_backfill_days", 7);
uint64_t now_ms = (uint64_t)ntp_time_get_seconds(inst) * 1000ULL;
uint64_t age_ms = (uint64_t)days * 86400ULL * 1000ULL;
uint64_t cutoff_ms = now_ms > age_ms ? now_ms - age_ms : 0;
char media_base[MEDIA_BASE_MAX];
if (media_index_get_base(cc->db, media_base)) return;
struct bf_list list = {0};
int collected = bf_collect(cc, media_base, ch_id, &list);
if (collected) { media_queue_channel_backfill(inst, ch_id); goto release; }
if (list.count > 1) qsort(list.items, (size_t)list.count, sizeof(struct bf_entry), bf_cmp_ts_desc);
uint64_t used = cc->media_cache_bytes;
int downloaded = 0, skipped = 0;
for (int i = 0; i < list.count; i++) {
struct bf_entry* e = &list.items[i];
if (e->actual_size > 0) continue;
if (!autoload || e->author == cc->my_node_id) { skipped++; continue; }
if (e->ts_ms < cutoff_ms) { skipped++; continue; }
if (limit > 0 && used + (uint64_t)e->claimed_size > limit) { skipped++; continue; }
if (md_auto_download(inst, e->body, e->body_len, e->ch_id, e->base_filename,
e->ts_ms, e->author_sig, e->author, e->msg_id, e->content_type)) {
downloaded++;
used += (uint64_t)e->claimed_size;
} else {
skipped++;
}
}
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "%s: channel backfill done ch=%s downloaded=%d skipped=%d",
CC_ID, ch_id, downloaded, skipped);
release:
for (int i = 0; i < list.count; i++) if (list.items[i].body) u_free(list.items[i].body);
u_free(list.items);
}
/* ─── Кеш медиафайлов: очистка сирот + лимит storage_total_size ─── */
struct media_msg_file {
char rel_path[1536]; // media/<ch_id>/<final_name>
char full_path[1536]; // <media_base>/media/<ch_id>/<final_name>
uint64_t ts_ms;
};
typedef int (*media_msg_file_cb)(const struct media_msg_file* f, void* arg);
/* Итератор по всем медиасообщениям всех каналов (без фильтра по node_id/времени). */
static int chat_media_foreach_media_message(struct chat_core_ctx* cc, const char* media_base, media_msg_file_cb cb, void* arg) {
if (!cc->db || !cb) return -1;
sqlite3_stmt* cs = NULL; int rc, failed = 0;
rc = sqlite3_prepare_v2(cc->db, "SELECT channel_id FROM channels WHERE EXISTS(SELECT 1 FROM sqlite_master"
" WHERE type='table' AND name='msg_'||channel_id)", -1, &cs, NULL);
if (rc != SQLITE_OK) goto finish;
while ((rc = sqlite3_step(cs)) == SQLITE_ROW) {
const char* ch_id = (const char*)sqlite3_column_text(cs, 0);
if (!chat_channel_id(ch_id) || strlen(ch_id) != (size_t)sqlite3_column_bytes(cs, 0)) { failed = 1; break; }
char tbl[80]; msg_table_name(ch_id, tbl, sizeof(tbl));
char sql[256];
snprintf(sql, sizeof(sql), "SELECT timestamp, data, local_attrs FROM \"%s\"", tbl);
sqlite3_stmt* ms = NULL;
int mrc = sqlite3_prepare_v2(cc->db, sql, -1, &ms, NULL);
if (mrc != SQLITE_OK) { sqlite3_finalize(ms); failed = 1; break; }
while ((mrc = sqlite3_step(ms)) == SQLITE_ROW) {
uint64_t ts = (uint64_t)sqlite3_column_int64(ms, 0);
const char* jdata = (const char*)sqlite3_column_text(ms, 1);
const char* la = (const char*)sqlite3_column_text(ms, 2);
if (!jdata) continue;
char* body = NULL; char content_type[32], fp[256];
int fields = msg_media_fields(cc->db, jdata, (size_t)sqlite3_column_bytes(ms, 1), la, &body, content_type, fp);
if (fields == 1) continue;
if (fields < 0) { failed = 1; break; }
size_t dlen = strlen(body); struct media_index_result result;
int parsed = chat_msg_parse_media_body(body, &result);
if (parsed) { u_free(body); if (parsed == -2 || (strcmp(content_type, "text") && strcmp(content_type, "text/plain"))) {
failed = 1; break;
} continue;
}
char final_name[256];
if (fp[0]) {
snprintf(final_name, sizeof(final_name), "%s", fp);
} else {
char base_filename[256];
extract_base_filename(body, base_filename, sizeof(base_filename));
media_final_name(&result, content_type, base_filename, final_name, sizeof(final_name));
}
struct media_msg_file f;
snprintf(f.rel_path, sizeof(f.rel_path), "media/%s/%s", ch_id, final_name);
if (media_index_path(media_base, f.rel_path, f.full_path)) {
u_free(body); media_index_result_free(&result); failed = 1; break;
}
f.ts_ms = ts;
if (cb(&f, arg)) failed = 1;
if (cc->attachment_initialized && !strcmp(content_type, ENCRYPTED_ATTACHMENT_TYPE)) {
uint8_t crypto_id[16];
sqlite3_stmt* local = NULL; int lrc = SQLITE_ERROR;
if (!chat_attachment_identity(body, dlen, crypto_id)) lrc = sqlite3_prepare_v2(cc->db,
"SELECT plain_path FROM attachment_keys WHERE channel=? AND crypto_id=? AND ready=1 AND decrypting=0",
-1, &local, NULL);
if (lrc == SQLITE_OK) lrc = sqlite3_bind_text(local, 1, ch_id, -1, SQLITE_STATIC);
if (lrc == SQLITE_OK) lrc = sqlite3_bind_blob(local, 2, crypto_id, 16, SQLITE_STATIC);
if (lrc == SQLITE_OK) lrc = sqlite3_step(local);
if (lrc == SQLITE_ROW) {
const char* path = (const char*)sqlite3_column_text(local, 0);
int bytes = sqlite3_column_bytes(local, 0);
if (!path || bytes <= 0 || bytes >= sizeof(f.full_path) || strlen(path) != (size_t)bytes ||
media_index_location(path, media_base, f.rel_path)) lrc = SQLITE_CORRUPT;
else { strcpy(f.full_path, path); if (cb(&f, arg)) failed = 1; }
}
int final = sqlite3_finalize(local);
if ((lrc != SQLITE_ROW && lrc != SQLITE_DONE) || final != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "encrypted cache: metadata rc=%d finalize=%d ch=%s", lrc, final, ch_id);
failed = 1;
}
}
u_free(body);
media_index_result_free(&result);
if (failed) break;
}
int final = sqlite3_finalize(ms);
if (mrc != SQLITE_DONE || final != SQLITE_OK || failed) { failed = 1; break; }
}
finish:
{
int final = sqlite3_finalize(cs);
if (rc != SQLITE_DONE || final != SQLITE_OK || failed) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "cache snapshot incomplete rc=%d finalize=%d: %s", rc, final, sqlite3_errmsg(cc->db)); return -1;
}
}
return 0;
}
struct cache_file { char full_path[1536]; uint64_t ts_ms; int64_t size; };
struct cache_rel_list { char** items; int count, cap; };
struct cache_file_list { struct cache_file* files; int count, cap; };
static int cache_str_cmp(const void* a, const void* b) {
return strcmp(*(const char* const*)a, *(const char* const*)b);
}
static int cache_file_cmp_ts(const void* a, const void* b) {
const struct cache_file* x = (const struct cache_file*)a;
const struct cache_file* y = (const struct cache_file*)b;
if (x->ts_ms < y->ts_ms) return -1;
if (x->ts_ms > y->ts_ms) return 1;
return 0;
}
static int cache_rel_add(struct cache_rel_list* l, const char* rel) {
if (l->count == l->cap) {
if ((size_t)l->cap > UINT32_MAX / (2 * sizeof(char*))) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "cache references exceed allocator capacity"); return -1;
}
int nc = l->cap ? l->cap * 2 : 64;
char** ni = u_realloc(l->items, (size_t)nc * sizeof(char*));
if (!ni) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "%s: cache rel list realloc failed (%d)", CC_ID, nc); return -1; }
l->items = ni; l->cap = nc;
}
l->items[l->count] = u_strdup(rel);
if (!l->items[l->count]) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "%s: cache rel strdup failed", CC_ID); return -1; }
l->count++;
return 0;
}
static int cache_file_add(struct cache_file_list* l, const char* full_path, uint64_t ts_ms, int64_t size) {
for (int i = 0; i < l->count; i++) if (!strcmp(l->files[i].full_path, full_path)) {
if (ts_ms > l->files[i].ts_ms) l->files[i].ts_ms = ts_ms;
return 0;
}
if (l->count == l->cap) {
if ((size_t)l->cap > UINT32_MAX / (2 * sizeof(struct cache_file))) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "cache files exceed allocator capacity"); return -1;
}
int nc = l->cap ? l->cap * 2 : 64;
struct cache_file* ni = u_realloc(l->files, (size_t)nc * sizeof(struct cache_file));
if (!ni) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "%s: cache file list realloc failed (%d)", CC_ID, nc); return -1; }
l->files = ni; l->cap = nc;
}
snprintf(l->files[l->count].full_path, sizeof(l->files[l->count].full_path), "%s", full_path);
l->files[l->count].ts_ms = ts_ms;
l->files[l->count].size = size;
l->count++;
return 0;
}
static int cache_rel_collect_cb(const struct media_msg_file* f, void* arg) {
return cache_rel_add((struct cache_rel_list*)arg, f->rel_path);
}
static int cache_file_collect_cb(const struct media_msg_file* f, void* arg) {
int64_t fsz = ma_file_size(f->full_path);
return fsz > 0 ? cache_file_add((struct cache_file_list*)arg, f->full_path, f->ts_ms, fsz) : 0;
}
/* Фактическая сумма размеров файлов, соответствующих сообщениям. */
static int chat_media_cache_scan_sum(struct chat_core_ctx* cc, uint64_t* out) {
char media_base[MEDIA_BASE_MAX];
if (media_index_get_base(cc->db, media_base)) return -1;
struct cache_file_list files = {0};
if (chat_media_foreach_media_message(cc, media_base, cache_file_collect_cb, &files)) {
u_free(files.files); return -1;
}
uint64_t sum = 0;
for (int i = 0; i < files.count; i++)
if (files.files[i].size > 0) sum += (uint64_t)files.files[i].size;
u_free(files.files);
*out = sum; return 0;
}
/* Сверка счётчика с фактической суммой: при расхождении — WARN + самокоррекция. */
static void chat_media_cache_reconcile(struct UTUN_INSTANCE* inst) {
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
if (!cc || !cc->initialized || !cc->db || !inst) return;
uint64_t actual;
if (chat_media_cache_scan_sum(cc, &actual)) return;
if (actual != cc->media_cache_bytes) {
DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "%s: cache counter drift: counter=%llu actual=%llu diff=%lld",
CC_ID, (unsigned long long)cc->media_cache_bytes, (unsigned long long)actual,
(long long)((int64_t)actual - (int64_t)cc->media_cache_bytes));
cc->media_cache_bytes = actual;
} else {
DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "%s: cache reconcile ok bytes=%llu",
CC_ID, (unsigned long long)cc->media_cache_bytes);
}
}
struct orphan_walk_ctx {
const char* media_base;
size_t mb_len;
char** rels; int rel_count;
int removed; uint64_t removed_bytes;
};
static void orphan_walk_cb(void* arg, const char* full_path, int64_t size) {
struct orphan_walk_ctx* ow = (struct orphan_walk_ctx*)arg;
char relative[MEDIA_PATH_MAX];
if (media_index_location(full_path, ow->media_base, relative)) return;
const char* rel = relative;
char* found = ow->rel_count ? bsearch(&rel, ow->rels, (size_t)ow->rel_count, sizeof(char*), cache_str_cmp) : NULL;
if (found) return;
int removed = file_io_remove_orphan(full_path);
if (removed > 0) {
ow->removed++;
ow->removed_bytes += (uint64_t)(size > 0 ? size : 0);
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "%s: cache orphan removed: %s (%lld bytes)",
CC_ID, rel, (long long)size);
} else if (removed < 0) {
DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "%s: cache orphan remove failed: %s", CC_ID, full_path);
}
}
static void chat_media_cache_cleanup_orphans(struct UTUN_INSTANCE* inst) {
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
if (!cc || !cc->initialized || !cc->db || !inst) return;
char media_base[MEDIA_BASE_MAX];
if (media_index_get_base(cc->db, media_base)) return;
char media_dir[600]; snprintf(media_dir, sizeof(media_dir), "%s/media", media_base);
struct file_io_info directory;
int directory_status = file_io_inspect(media_dir, 0, &directory);
if (directory_status == 1) return; // До первого вложения каталога может не быть.
if (directory_status < 0) return;
struct cache_rel_list rels = {0};
if (chat_media_foreach_media_message(cc, media_base, cache_rel_collect_cb, &rels)) goto release;
sqlite3_stmt* preparations = NULL;
int prep_rc = sqlite3_prepare_v2(cc->db, "SELECT location FROM media_preparations UNION SELECT location FROM media_files",
-1, &preparations, NULL);
if (prep_rc == SQLITE_OK) {
while ((prep_rc = sqlite3_step(preparations)) == SQLITE_ROW) {
const char* location = (const char*)sqlite3_column_text(preparations, 0);
int bytes = sqlite3_column_bytes(preparations, 0);
if (!location || bytes <= 0 || strlen(location) != (size_t)bytes || cache_rel_add(&rels, location)) {
prep_rc = SQLITE_NOMEM; break;
}
}
}
int prep_finish = preparations ? sqlite3_finalize(preparations) : SQLITE_OK;
if (prep_rc != SQLITE_DONE || prep_finish != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "media orphan scan cannot protect indexed/preparing files rc=%d", prep_rc);
goto release;
}
/* Конечные контейнеры принадлежат attachment_keys; активные temp защищены file_io-реестром. */
if (cc->attachment_initialized) {
sqlite3_stmt* active = NULL;
int rc = sqlite3_prepare_v2(cc->db, "SELECT cipher_path FROM attachment_keys", -1, &active, NULL);
if (rc == SQLITE_OK) {
while ((rc = sqlite3_step(active)) == SQLITE_ROW) {
const char* path = (const char*)sqlite3_column_text(active, 0);
char relative[MEDIA_PATH_MAX]; int bytes = sqlite3_column_bytes(active, 0);
if (path && !bytes) continue; /* Ключ мог прийти раньше manifest: контейнеру ещё не назначен путь. */
if (!path || bytes <= 0 || strlen(path) != (size_t)bytes || media_index_location(path, media_base, relative) ||
cache_rel_add(&rels, relative)) {
rc = SQLITE_CORRUPT; break;
}
}
}
int final = sqlite3_finalize(active);
if (rc != SQLITE_DONE || final != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "encrypted cache: active scan failed rc=%d finalize=%d: %s", rc, final, sqlite3_errmsg(cc->db));
goto release;
}
}
if (rels.count > 1) qsort(rels.items, (size_t)rels.count, sizeof(char*), cache_str_cmp);
struct orphan_walk_ctx ow;
ow.media_base = media_base; ow.mb_len = strlen(media_base);
ow.rels = rels.items; ow.rel_count = rels.count;
ow.removed = 0; ow.removed_bytes = 0;
int walk_result = ma_dir_walk(media_dir, orphan_walk_cb, &ow);
if (walk_result) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "%s: cache orphan cleanup incomplete removed=%d bytes=%llu",
CC_ID, ow.removed, (unsigned long long)ow.removed_bytes);
else DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "%s: cache orphan cleanup done removed=%d bytes=%llu",
CC_ID, ow.removed, (unsigned long long)ow.removed_bytes);
release:
for (int i = 0; i < rels.count; i++) u_free(rels.items[i]);
u_free(rels.items);
}
void chat_media_cache_enforce(struct UTUN_INSTANCE* inst) {
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
if (!cc || !cc->initialized || !cc->db || !inst) return;
uint64_t limit = inst->config->global.chatserver_storage_total_size;
if (cc->attachment_initialized) {
uint64_t actual;
if (chat_media_cache_scan_sum(cc, &actual)) { media_queue_full_backfill(inst); return; }
cc->media_cache_bytes = actual;
}
if (limit == 0 || cc->media_cache_bytes <= limit) {
DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "%s: cache limit ok bytes=%llu limit=%llu",
CC_ID, (unsigned long long)cc->media_cache_bytes, (unsigned long long)limit);
chat_media_cache_reconcile(inst);
return;
}
char media_base[MEDIA_BASE_MAX];
if (media_index_get_base(cc->db, media_base)) return;
struct cache_file_list files = {0};
if (chat_media_foreach_media_message(cc, media_base, cache_file_collect_cb, &files)) { u_free(files.files); return; }
if (files.count > 1) qsort(files.files, (size_t)files.count, sizeof(struct cache_file), cache_file_cmp_ts);
int deleted = 0; uint64_t deleted_bytes = 0;
for (int i = 0; i < files.count && cc->media_cache_bytes > limit; i++) {
if (files.files[i].size <= 0) continue;
if (remove(files.files[i].full_path) == 0) {
cache_bytes_sub(cc, files.files[i].size);
deleted++; deleted_bytes += (uint64_t)files.files[i].size;
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "%s: cache evict: %s ts=%llu size=%lld",
CC_ID, files.files[i].full_path,
(unsigned long long)files.files[i].ts_ms, (long long)files.files[i].size);
} else {
DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "%s: cache evict failed: %s", CC_ID, files.files[i].full_path);
}
}
if (cc->media_cache_bytes > limit)
DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "%s: cache still over limit bytes=%llu limit=%llu",
CC_ID, (unsigned long long)cc->media_cache_bytes, (unsigned long long)limit);
else
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "%s: cache limit enforced bytes=%llu limit=%llu deleted=%d bytes=%llu",
CC_ID, (unsigned long long)cc->media_cache_bytes, (unsigned long long)limit,
deleted, (unsigned long long)deleted_bytes);
u_free(files.files);
chat_media_cache_reconcile(inst);
}
/* ─── После первой подтверждённой Merkle-сверки (+ дебаунс) — анонс локальных блоков и докачка ─── */
static void media_startup_backfill_timer_cb(void* arg) {
struct UTUN_INSTANCE* inst = (struct UTUN_INSTANCE*)arg;
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
cc->media_backfill_timer = NULL;
cc->media_backfill_ran = 1;
/* 1) переанонс локальных блоков суперузлам (block_availability),
* 2) чистка сирот, 3) докачка недостающего + вычистка старого (единый проход) */
media_delivery_announce_local_blocks(inst);
chat_media_cache_cleanup_orphans(inst);
chat_media_autodownload_backfill(inst);
}
static void media_startup_backfill_done_cb(struct DB_SYNC_INSTANCE* si, uint64_t peer_node_id, void* arg) {
struct UTUN_INSTANCE* inst = (struct UTUN_INSTANCE*)arg;
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
(void)si; (void)peer_node_id;
if (!cc || cc->media_backfill_ran || !cc->media_backfill_inst) return;
if (cc->media_backfill_timer) uasync_cancel_timeout(inst->ua, cc->media_backfill_timer);
cc->media_backfill_timer = uasync_set_timeout(inst->ua, 20000, inst,
media_startup_backfill_timer_cb, "chat_media_bl");
if (!cc->media_backfill_timer) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "media startup: backfill timer allocation failed");
}
/* После подтверждённой Merkle-сверки канала — докачка его медиа (дебаунс 2с, от новых к старым). */
static void media_channel_backfill_timer_cb(void* arg) {
struct UTUN_INSTANCE* inst = (struct UTUN_INSTANCE*)arg;
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
if (!cc) return;
cc->media_channel_backfill_timer = NULL;
char channels[CHAT_MEDIA_BACKFILL_CHANNELS][sizeof(cc->media_channel_backfill[0])]; int count = cc->media_channel_backfill_pending;
memcpy(channels, cc->media_channel_backfill, (size_t)count * sizeof(channels[0]));
cc->media_channel_backfill_pending = 0;
for (int i = 0; i < count; i++) chat_media_autodownload_backfill_channel(inst, channels[i]);
}
/* Один ограниченный batch; отказ индекса во время scan ставит следующий timer, не меняя текущий batch. */
static void media_queue_channel_backfill(struct UTUN_INSTANCE* inst, const char* ch_id) {
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
if (!cc || !cc->initialized || !cc->media_backfill_inst) return;
for (int i = 0; i < cc->media_channel_backfill_pending; i++)
if (strcmp(cc->media_channel_backfill[i], ch_id) == 0) goto schedule;
if (cc->media_channel_backfill_pending == (int)(sizeof(cc->media_channel_backfill) / sizeof(cc->media_channel_backfill[0]))) {
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "media backfill: channel batch full, scheduling complete scan ch=%s", ch_id);
media_queue_full_backfill(inst);
return;
}
snprintf(cc->media_channel_backfill[cc->media_channel_backfill_pending++], sizeof(cc->media_channel_backfill[0]), "%s", ch_id);
schedule:
if (!cc->media_channel_backfill_timer) cc->media_channel_backfill_timer = uasync_set_timeout(inst->ua, 20000, inst,
media_channel_backfill_timer_cb, "chat_md_chbl");
if (!cc->media_channel_backfill_timer) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "media backfill: channel timer allocation failed ch=%s", ch_id);
else DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "media backfill: channel queued ch=%s pending=%d", ch_id, cc->media_channel_backfill_pending);
}
static void media_channel_backfill_done_cb(struct DB_SYNC_INSTANCE* si, uint64_t peer_node_id, void* arg) {
(void)peer_node_id;
if (!si) return;
char ch_id[32]; snprintf(ch_id, sizeof(ch_id), "%llu", (unsigned long long)db_sync_instance_group_id(si));
media_queue_channel_backfill(arg, ch_id);
}
int chat_media_startup_backfill_init(struct UTUN_INSTANCE* inst) {
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
if (!cc || !cc->initialized) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "media backfill: active chat required"); return -1; }
if (cc->media_backfill_inst) return 0;
if (db_sync_add_done_cbk(inst, media_startup_backfill_done_cb, inst) ||
db_sync_add_done_cbk(inst, media_channel_backfill_done_cb, inst)) {
db_sync_remove_done_cbk(inst, media_startup_backfill_done_cb, inst);
db_sync_remove_done_cbk(inst, media_channel_backfill_done_cb, inst);
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "media backfill: history completion subscription failed"); return -1;
}
cc->media_backfill_inst = inst;
cc->media_backfill_ran = 0;
cc->media_channel_backfill_pending = 0;
cc->media_channel_backfill_timer = NULL;
DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "media backfill: history completion subscriptions installed"); return 0;
}
void chat_media_startup_backfill_destroy(struct UTUN_INSTANCE* inst) {
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
if (!inst || !cc) return;
db_sync_remove_done_cbk(inst, media_startup_backfill_done_cb, inst);
db_sync_remove_done_cbk(inst, media_channel_backfill_done_cb, inst);
if (cc->media_backfill_timer) {
uasync_cancel_timeout(inst->ua, cc->media_backfill_timer);
cc->media_backfill_timer = NULL;
}
if (cc->media_channel_backfill_timer) {
uasync_cancel_timeout(inst->ua, cc->media_channel_backfill_timer);
cc->media_channel_backfill_timer = NULL;
}
cc->media_channel_backfill_pending = 0;
cc->media_backfill_inst = NULL;
cc->media_backfill_ran = 0;
}