Files
utun2/src/dm/dm_media.c
T

1628 lines
94 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* dm_media — E2E файлы бесед и отдельное ограниченное хранилище M. */
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <errno.h>
#include <sys/stat.h>
#include <openssl/sha.h>
#include <openssl/rand.h>
#include "../../lib/mem.h"
#include "../../lib/platform_compat.h"
#include "../../lib/debug_config.h"
#include "dm_media_priv.h"
#include "dm_media_receipt.h"
#include "dm_history.h"
#include "dm_crypto.h"
#include "dm_mailbox.h"
#include "dm_mailbox_media.h"
#include "../utun_instance.h"
#include "../ntp_time.h"
#include "../chat/chat_event.h"
#include "../chat/chat_setting.h"
#include "../routing_layer/etcp_router.h"
#include "../routing_layer/topo_node_sqlite.h"
#include "../routing_layer/topo_group.h"
#include "../transport_layer/secure_channel.h"
#include "../transport_layer/etcp.h"
#include "../media_delivery/file_transfer.h"
#include "../media_async/encrypted_file.h"
enum dm_file_work { DM_FILE_PREPARE, DM_FILE_RECEIVE, DM_FILE_CUSTODY, DM_FILE_DELETE };
struct dm_file_job;
struct dm_file_probe;
struct dm_media_state {
struct UTUN_INSTANCE* inst;
char root[768];
char pending[768];
struct dm_file_job* jobs;
struct dm_file_probe* probes;
void* timer;
int closing;
int cleanup_active;
};
struct dm_file_job {
struct dm_file_job* next;
struct dm_media_state* media;
enum dm_file_work work;
struct file_transfer_op* transfer;
uint8_t id[16], key[32], hash[32];
uint8_t wrapped[DM_FILE_KEYS_SIZE]; /* Подписывается с metadata после подготовки. */
uint64_t author, size;
char conv[64];
struct attachment_info info;
int remove_source;
char input[1024], cipher[1024], plain[1024], temp[1024], output[1024];
int error;
uint64_t generation;
uint64_t request_id;
unsigned held;
};
#define DM_SOURCE_MAX (DM_TARGET_MAX + 64)
struct dm_probe_source { uint64_t node, group, sent, rtt; int used; };
struct dm_file_probe {
struct dm_file_probe* next;
uint8_t desc[DM_MEDIA_DESC_SIZE];
uint64_t token, deadline, author;
char conv[64];
unsigned count;
struct dm_probe_source sources[DM_SOURCE_MAX];
};
sqlite3_stmt* dm_media_prepare(struct UTUN_INSTANCE* inst, const char* sql) {
sqlite3_stmt* st = NULL;
int rc = sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL);
if (rc != SQLITE_OK)
DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: SQL prepare rc=%d error=%s statement=%s", rc, sqlite3_errmsg(inst->topo_sqlite_db), sql);
return st;
}
/* Шаг записи всегда завершает statement; SQL-ошибки нельзя скрывать ACK. */
int dm_media_step(struct UTUN_INSTANCE* inst, sqlite3_stmt* st) {
if (!st) return -1;
int rc = sqlite3_step(st);
if (rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: SQL step rc=%d error=%s statement=%s",
rc, sqlite3_errmsg(inst->topo_sqlite_db), sqlite3_sql(st));
int final = sqlite3_finalize(st);
if (rc == SQLITE_DONE && final != SQLITE_OK)
DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: SQL finalize rc=%d step=%d error=%s", final, rc, sqlite3_errmsg(inst->topo_sqlite_db));
return rc == SQLITE_DONE && final == SQLITE_OK ? 0 : -1;
}
int dm_media_exec(struct UTUN_INSTANCE* inst, const char* sql) {
int rc = sqlite3_exec(inst->topo_sqlite_db, sql, NULL, NULL, NULL);
if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: SQL rc=%d error=%s statement=%s",
rc, sqlite3_errmsg(inst->topo_sqlite_db), sql);
return rc == SQLITE_OK ? 0 : -1;
}
const uint8_t* dm_message_media(const uint8_t* msg, size_t len) {
struct dm_message_view view;
return !dm_message_parse(msg, len, &view) && view.size == len ? view.media : NULL;
}
int dm_media_metadata_decode(const uint8_t* data, size_t size, struct attachment_info* info, const uint8_t** keys) {
if (!data || !info || !keys) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM metadata: invalid arguments"); return -1; }
const uint8_t* wrapped = NULL;
if (size >= 8 && !memcmp(data, "DMKEY001", 8)) {
if (size <= DM_MEDIA_KEYS_HEADER_SIZE) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM metadata: truncated file keys"); return -1; }
wrapped = data + 8; data += DM_MEDIA_KEYS_HEADER_SIZE; size -= DM_MEDIA_KEYS_HEADER_SIZE;
}
if (attachment_decode(data, size, info)) return -1;
*keys = wrapped; return 0;
}
/* Получение ключа разрешено только действующему участнику закреплённой беседы. */
static int dm_file_key(struct UTUN_INSTANCE* inst, const uint8_t* body, size_t len, uint8_t out[32]) {
struct dm_message_view view;
if (dm_message_parse(body, len, &view) || !view.media || view.size != len) return -1;
char conv[64]; snprintf(conv, sizeof(conv), "%llu", (unsigned long long)view.conv);
struct dm_party local[2], parties[2]; uint8_t key[32], secret[32], plain[DM_DATA_MAX], nonce[DM_NONCE_SIZE];
if (dm_conversation_access(inst, conv, local, key)) return -1;
size_t size = 0; const uint8_t* wrapped; struct attachment_info info; int result = -1;
dm_build_nonce(view.conv, view.author, view.seq, nonce);
if (media_ccm_decrypt(key, nonce, view.ciphertext, view.ciphertext_size, plain, &size) ||
dm_media_metadata_decode(plain, size, &info, &wrapped)) goto done;
if (!wrapped) {
if (view.mode != DM_MODE_DEVICE) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM file: GROUPS requires independent file keys"); goto done; }
memcpy(out, key, 32); result = 0; goto done;
}
unsigned side = view.author == inst->node_id ? 0 : 1;
if (view.mode == DM_MODE_GROUPS) {
int member = dm_message_target(&view, inst->node_id, NULL, NULL);
if (member < 0) goto done;
side = (unsigned)member; memcpy(parties, view.parties, sizeof(parties));
} else { parties[side] = local[0]; parties[1 - side] = local[1]; }
if (!dm_party_private(inst, parties + side, secret))
result = dm_file_keys_open(view.conv, view.author, view.media, parties, side, secret, wrapped, out);
done:
OPENSSL_cleanse(key, sizeof(key)); OPENSSL_cleanse(secret, sizeof(secret)); OPENSSL_cleanse(plain, sizeof(plain));
if (result) DEBUG_WARN(DEBUG_CATEGORY_DM, "DM file: key unavailable conv=%s author=%llu", conv, (unsigned long long)view.author);
return result;
}
uint64_t dm_media_cipher_size(uint64_t size) {
return size + (size ? (size + DM_MEDIA_PLAIN_SIZE - 1) / DM_MEDIA_PLAIN_SIZE : 1) * DM_TAG_SIZE;
}
/* Пути зависят только от UUID, имя пользователя не попадает в файловый путь. */
static int dm_file_path(struct dm_media_state* media, int custody, const uint8_t id[16], const char* suffix,
char* path, size_t cap) {
char hex[33];
for (int i = 0; i < 16; i++) snprintf(hex + 2 * i, 3, "%02x", id[i]);
int n = snprintf(path, cap, "%s/%s%s", custody ? media->pending : media->root, hex, suffix);
if (n < 0 || (size_t)n >= cap) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: file path too long"); return -1; }
return 0;
}
/* fflush не гарантирует сохранение: fsync/_commit нужен до публикации и ACK. */
static int dm_file_sync(FILE* file) {
if (fflush(file) != 0) goto fail;
#ifdef _WIN32
if (_commit(_fileno(file)) != 0) goto fail;
#else
if (fsync(fileno(file)) != 0) goto fail;
#endif
return 0;
fail:
DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: file sync failed: %s", strerror(errno));
return -1;
}
static int dm_file_remove(const char* path) {
if (remove(path) != 0 && errno != ENOENT) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: remove failed path=%s: %s", path, strerror(errno));
return -1;
}
return 0;
}
/* Манифест хранит только пути приложения. Поколение делает очередь удаления durable. */
int dm_media_file_hold(struct UTUN_INSTANCE* inst, const char* conv, uint64_t generation, const char* path) {
if (!path || !path[0] || !dm_history_current(inst, conv, generation)) {
DEBUG_WARN(DEBUG_CATEGORY_DM, "DM files: hold rejected conv=%s path=%s", conv ? conv : "", path ? path : ""); return -1;
}
sqlite3_stmt* st = dm_media_prepare(inst,
"INSERT INTO dm_local_files(path,conv_id,generation,busy) VALUES(?,?,?,1) ON CONFLICT(path) DO UPDATE SET busy=busy+1"
" WHERE conv_id=excluded.conv_id AND generation=excluded.generation AND busy>=0");
if (!st) return -1;
sqlite3_bind_text(st, 1, path, -1, SQLITE_STATIC); sqlite3_bind_text(st, 2, conv, -1, SQLITE_STATIC);
sqlite3_bind_int64(st, 3, (sqlite3_int64)generation);
if (dm_media_step(inst, st)) return -1;
if (sqlite3_changes(inst->topo_sqlite_db) != 1) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM files: path belongs to another task/generation path=%s", path); return -1;
}
DEBUG_TRACE(DEBUG_CATEGORY_DM, "DM files: hold conv=%s generation=%llu path=%s", conv, (unsigned long long)generation, path);
return 0;
}
void dm_media_file_release(struct UTUN_INSTANCE* inst, const char* path) {
sqlite3_stmt* st = dm_media_prepare(inst, "UPDATE dm_local_files SET busy=busy-1 WHERE path=? AND busy>0");
if (!st) return;
sqlite3_bind_text(st, 1, path, -1, SQLITE_STATIC);
if (!dm_media_step(inst, st) && sqlite3_changes(inst->topo_sqlite_db) != 1)
DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM files: release without owner path=%s", path);
}
struct dm_cleanup_job { struct dm_media_state* media; char path[1024]; int error; };
static void dm_cleanup_pump(struct dm_media_state* media);
static void dm_cleanup_work(void* arg) {
struct dm_cleanup_job* job = arg;
job->error = dm_file_remove(job->path);
if (!job->error) job->error = file_io_sync_directory(job->path);
}
static void dm_cleanup_done(void* arg, int error) {
struct dm_cleanup_job* job = arg;
struct dm_media_state* media = job->media;
media->cleanup_active = 0;
int success = !error && !job->error;
sqlite3_stmt* st = dm_media_prepare(media->inst, success ? "DELETE FROM dm_local_files WHERE path=? AND busy=-1" :
"UPDATE dm_local_files SET busy=0,retry_at=?2 WHERE path=?1 AND busy=-1");
if (st && !success) sqlite3_bind_int64(st, 2, (sqlite3_int64)(ntp_time_get_seconds(media->inst) + 30));
if (st) { sqlite3_bind_text(st, 1, job->path, -1, SQLITE_STATIC); if (dm_media_step(media->inst, st)) success = 0; }
else success = 0;
if (success) DEBUG_DEBUG(DEBUG_CATEGORY_DM, "DM files: removed deleted history path=%s", job->path);
else DEBUG_WARN(DEBUG_CATEGORY_DM, "DM files: cleanup retained for retry path=%s callback=%d worker=%d", job->path, error, job->error);
u_free(job);
if (success && !media->closing) dm_cleanup_pump(media);
}
static void dm_cleanup_pump(struct dm_media_state* media) {
if (media->closing || media->cleanup_active) return;
sqlite3_stmt* st = dm_media_prepare(media->inst,
"SELECT f.path FROM dm_local_files f JOIN dm_history h USING(conv_id)"
" WHERE f.generation<h.generation AND f.busy<=0 AND f.retry_at<=? ORDER BY f.retry_at,f.path LIMIT 1");
if (!st) return;
sqlite3_bind_int64(st, 1, (sqlite3_int64)ntp_time_get_seconds(media->inst));
int rc = sqlite3_step(st);
if (rc != SQLITE_ROW) {
if (rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM files: cleanup scan failed rc=%d", rc);
sqlite3_finalize(st); return;
}
struct dm_cleanup_job* job = u_calloc(1, sizeof(*job));
if (!job) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM files: cleanup allocation failed"); sqlite3_finalize(st); return; }
const char* path = (const char*)sqlite3_column_text(st, 0);
if (!path || strlen(path) >= sizeof(job->path)) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM files: invalid cleanup path"); sqlite3_finalize(st); u_free(job); return;
}
job->media = media; snprintf(job->path, sizeof(job->path), "%s", path); sqlite3_finalize(st);
st = dm_media_prepare(media->inst, "UPDATE dm_local_files SET busy=-1 WHERE path=? AND busy<=0");
if (!st) { u_free(job); return; }
sqlite3_bind_text(st, 1, job->path, -1, SQLITE_STATIC);
if (dm_media_step(media->inst, st)) { u_free(job); return; }
media->cleanup_active = 1;
media_async_submit(media->inst->media_async, media->inst->ua, dm_cleanup_work, job, dm_cleanup_done, job);
}
int dm_media_forget_prepare(struct UTUN_INSTANCE* inst, const char* conv, uint64_t generation) {
struct dm_media_state* media = inst->dm_media;
sqlite3_stmt* rows = dm_media_prepare(inst, "SELECT id FROM dm_files WHERE conv_id=?");
if (!rows) return -1;
sqlite3_bind_text(rows, 1, conv, -1, SQLITE_STATIC);
int rc, result = 0;
while ((rc = sqlite3_step(rows)) == SQLITE_ROW) {
if (sqlite3_column_bytes(rows, 0) != 16) { result = -1; break; }
const char* suffixes[] = {".file", ".cipher", ".encrypt", ".download", ".decrypt"};
for (unsigned i = 0; i < sizeof(suffixes) / sizeof(suffixes[0]); i++) {
char path[1024];
if (dm_file_path(media, 0, sqlite3_column_blob(rows, 0), suffixes[i], path, sizeof(path))) { result = -1; break; }
sqlite3_stmt* st = dm_media_prepare(inst,
"INSERT INTO dm_local_files(path,conv_id,generation) VALUES(?,?,?) ON CONFLICT(path) DO NOTHING");
if (!st) { result = -1; break; }
sqlite3_bind_text(st, 1, path, -1, SQLITE_STATIC); sqlite3_bind_text(st, 2, conv, -1, SQLITE_STATIC);
sqlite3_bind_int64(st, 3, (sqlite3_int64)generation);
if (dm_media_step(inst, st)) { result = -1; break; }
}
if (result) break;
}
sqlite3_finalize(rows);
if (result || rc != SQLITE_DONE) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM files: delete manifest failed conv=%s rc=%d", conv, rc); return -1; }
sqlite3_stmt* st = dm_media_prepare(inst, "DELETE FROM dm_files WHERE conv_id=?");
if (!st) return -1;
sqlite3_bind_text(st, 1, conv, -1, SQLITE_STATIC);
return dm_media_step(inst, st);
}
void dm_media_forget_committed(struct UTUN_INSTANCE* inst, const char* conv) {
sqlite3_stmt* st = dm_media_prepare(inst, "SELECT media_id FROM dm_deleted_messages WHERE conv_id=? AND media_id IS NOT NULL");
if (st) {
sqlite3_bind_text(st, 1, conv, -1, SQLITE_STATIC);
int rc;
while ((rc = sqlite3_step(st)) == SQLITE_ROW) {
if (sqlite3_column_bytes(st, 0) == 16) {
char path[1024];
if (!dm_file_path(inst->dm_media, 0, sqlite3_column_blob(st, 0), ".cipher", path, sizeof(path)))
file_transfer_cancel_path(inst->file_transfer, path);
}
else DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM files: damaged deleted media ID conv=%s", conv);
}
if (rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM files: cancellation scan failed conv=%s rc=%d", conv, rc);
sqlite3_finalize(st);
}
dm_cleanup_pump(inst->dm_media);
}
int dm_media_close_prepare(struct UTUN_INSTANCE* inst) {
if (!inst->dm_media) return 0;
return dm_media_exec(inst, "UPDATE dm_local_files SET generation=0 WHERE conv_id IN"
" (SELECT conv_id FROM dm_conversations WHERE closed=1) AND NOT EXISTS"
" (SELECT 1 FROM dm_files f WHERE f.conv_id=dm_local_files.conv_id AND f.ready=1"
" AND (dm_local_files.path=f.local_path OR dm_local_files.path=substr(f.local_path,1,length(f.local_path)-5)||'.cipher'))");
}
void dm_media_close_committed(struct UTUN_INSTANCE* inst) {
struct dm_media_state* media = inst->dm_media;
if (!media) return;
for (struct dm_file_job* job = media->jobs, *next; job; job = next) {
next = job->next;
if (job->transfer && job->work == DM_FILE_RECEIVE && dm_conversation_access(inst, job->conv, NULL, NULL)) {
DEBUG_INFO(DEBUG_CATEGORY_DM, "DM files: cancel after group exit conv=%s", job->conv);
file_transfer_cancel(job->transfer);
}
}
dm_cleanup_pump(media);
}
void dm_media_notify(struct UTUN_INSTANCE* inst, uint64_t conv) {
char id[64];
int n = snprintf(id, sizeof(id), "%llu", (unsigned long long)conv);
uint8_t evt[1 + 64 + 8];
evt[0] = (uint8_t)n;
memcpy(evt + 1, id, (size_t)n);
memcpy(evt + 1 + n, &inst->node_id, 8);
chat_event_post(inst, CHAT_EVT_DM_MSG_RECEIVED, evt, n + 9);
}
/* Транзакция dm_core связывает историю, очередь и состояние файла. */
int dm_media_track(struct UTUN_INSTANCE* inst, int dir, const uint8_t* msg, size_t len) {
const uint8_t* desc = dm_message_media(msg, len);
if (!desc) return 0;
struct dm_media_state* media = inst->dm_media;
if (!media || media->closing) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: file received while owner stopped"); return -1; }
uint64_t conv, seq, author, size, recipient;
memcpy(&conv, msg, 8); memcpy(&seq, msg + 8, 8); memcpy(&author, msg + 24, 8); memcpy(&size, desc + 16, 8);
char id[64], path[1024];
snprintf(id, sizeof(id), "%llu", (unsigned long long)conv);
sqlite3_stmt* st = dm_media_prepare(inst, "SELECT peer_node_id FROM dm_conversations WHERE conv_id=?");
if (!st) return -1;
sqlite3_bind_text(st, 1, id, -1, SQLITE_STATIC);
int rc = sqlite3_step(st);
if (rc != SQLITE_ROW) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: conversation absent conv=%s rc=%d", id, rc); sqlite3_finalize(st); return -1; }
dir = author == inst->node_id; /* Здесь направление означает владение оригиналом, а не отображение своей реплики. */
recipient = dir ? (uint64_t)sqlite3_column_int64(st, 0) : inst->node_id;
sqlite3_finalize(st);
if (dm_file_path(media, 0, desc, ".file", path, sizeof(path))) return -1;
if (dir) {
struct stat sb;
if (stat(path, &sb) || (uint64_t)sb.st_size != size) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: outgoing file not prepared path=%s size=%llu", path, (unsigned long long)size);
return -1;
}
}
st = dm_media_prepare(inst, "INSERT INTO dm_files(id,conv_id,dir,seq,author,recipient,plain_size,cipher_hash,body,"
"local_path,ready,holder) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO NOTHING");
if (!st) return -1;
sqlite3_bind_blob(st, 1, desc, 16, SQLITE_STATIC);
sqlite3_bind_text(st, 2, id, -1, SQLITE_STATIC); sqlite3_bind_int(st, 3, dir);
sqlite3_bind_int64(st, 4, (sqlite3_int64)seq); sqlite3_bind_int64(st, 5, (sqlite3_int64)author);
sqlite3_bind_int64(st, 6, (sqlite3_int64)recipient); sqlite3_bind_int64(st, 7, (sqlite3_int64)size);
sqlite3_bind_blob(st, 8, desc + 24, 32, SQLITE_STATIC); sqlite3_bind_blob(st, 9, msg, (int)len, SQLITE_STATIC);
sqlite3_bind_text(st, 10, path, -1, SQLITE_STATIC);
sqlite3_bind_int(st, 11, dir ? 1 : 0); sqlite3_bind_int64(st, 12, (sqlite3_int64)author);
if (dm_media_step(inst, st)) return -1;
st = dm_media_prepare(inst, "SELECT body FROM dm_files WHERE id=?");
if (!st) return -1;
sqlite3_bind_blob(st, 1, desc, 16, SQLITE_STATIC);
rc = sqlite3_step(st);
int exact = rc == SQLITE_ROW && sqlite3_column_bytes(st, 0) == (int)len && !memcmp(sqlite3_column_blob(st, 0), msg, len);
sqlite3_finalize(st);
if (!exact) DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: conflicting UUID/body conv=%s seq=%llu", id, (unsigned long long)seq);
return exact ? dm_media_targets_seed(inst, msg, len, recipient) : -1;
}
/* Квитанция endpoint удаляет только media pending, история/собственный файл остаются. */
int dm_media_accept_receipt(struct UTUN_INSTANCE* inst, const uint8_t* r) {
if (dm_media_verify_receipt(inst, r, NULL, 0)) return -1;
uint64_t author, node;
memcpy(&author, r + 24, 8); memcpy(&node, r + 32, 8);
if (author != inst->node_id) { DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: receipt for another author"); return -1; }
sqlite3_stmt* st = dm_media_prepare(inst, "SELECT f.body,t.received,t.taken FROM dm_files f LEFT JOIN dm_file_targets t"
" ON t.id=f.id AND t.node=?2 WHERE f.id=?1 AND f.author=?3");
if (!st) return -1;
sqlite3_bind_blob(st, 1, r + 40, 16, SQLITE_STATIC);
sqlite3_bind_int64(st, 2, (sqlite3_int64)node); sqlite3_bind_int64(st, 3, (sqlite3_int64)author);
int rc = sqlite3_step(st);
if (rc == SQLITE_DONE) {
sqlite3_finalize(st);
st = dm_media_prepare(inst,
"SELECT body_hash FROM dm_deleted_messages WHERE conv_id=? AND dir=1 AND seq=? AND media_id=?");
if (!st) return -1;
uint64_t conv, seq; char id[64]; memcpy(&conv, r + 8, 8); memcpy(&seq, r + 16, 8);
snprintf(id, sizeof(id), "%llu", (unsigned long long)conv);
sqlite3_bind_text(st, 1, id, -1, SQLITE_STATIC); sqlite3_bind_int64(st, 2, (sqlite3_int64)seq);
sqlite3_bind_blob(st, 3, r + 40, 16, SQLITE_STATIC);
rc = sqlite3_step(st);
int exact = rc == SQLITE_ROW && sqlite3_column_bytes(st, 0) == 32 && !memcmp(sqlite3_column_blob(st, 0), r + 56, 32);
sqlite3_finalize(st);
if (exact) { DEBUG_DEBUG(DEBUG_CATEGORY_DM, "DM files: late receipt for deleted file conv=%s", id); return 0; }
DEBUG_WARN(DEBUG_CATEGORY_DM, "DM files: receipt for unknown/conflicting deleted file conv=%s", id); return -1;
}
int kind = dm_media_receipt_kind(r);
int already = rc == SQLITE_ROW && sqlite3_column_bytes(st, kind + 1) == DM_MEDIA_RECEIPT_SIZE;
if (rc != SQLITE_ROW || dm_media_receipt_save(inst, r, sqlite3_column_blob(st, 0), (size_t)sqlite3_column_bytes(st, 0))) {
DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: receipt does not match outgoing artifact rc=%d", rc);
sqlite3_finalize(st);
return -1;
}
sqlite3_finalize(st);
if (!kind) {
st = dm_media_prepare(inst, "UPDATE dm_files SET receipt=? WHERE id=? AND dir=1");
if (!st) return -1;
sqlite3_bind_blob(st, 1, r, DM_MEDIA_RECEIPT_SIZE, SQLITE_STATIC); sqlite3_bind_blob(st, 2, r + 40, 16, SQLITE_STATIC);
if (dm_media_step(inst, st)) return -1;
}
if (!already) {
uint64_t conv;
memcpy(&conv, r + 8, 8);
DEBUG_INFO(DEBUG_CATEGORY_DM, "DM file fact: conv=%llu device=%llu fact=%s", (unsigned long long)conv,
(unsigned long long)node, kind ? "opened" : "downloaded");
dm_media_notify(inst, conv);
}
return 0;
}
static void dm_media_control(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t source,
uint8_t cmd, const uint8_t* body, size_t len);
/* Local S/M тоже используют тот же проверяемый обработчик, без искусственного CM к себе. */
int dm_media_send(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer,
uint8_t cmd, const uint8_t* body, size_t len) {
if (len > DM_MEDIA_BODY_MAX + 24 || !inst->dm_media || inst->dm_media->closing) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: invalid/stopped control send bytes=%zu", len);
return -1;
}
if (peer == inst->node_id) { dm_media_control(inst, group, peer, cmd, body, len); return 0; }
if (!dm_route_group(inst, peer, group, &group)) {
DEBUG_DEBUG(DEBUG_CATEGORY_DM, "DM media: no admitted route peer=%llu cmd=%u", (unsigned long long)peer, cmd); return -1;
}
struct ETCP_ROUTER_CONN* rc = etcp_router_conn_get(inst, group, peer, ETCP_RT_ID_DM_MEDIA);
if (!rc) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: router channel allocation failed"); return -1; }
if (!etcp_router_send_q_has_room(inst, group, peer, ETCP_RT_ID_DM_MEDIA)) return -1;
uint8_t packet[1 + DM_MEDIA_BODY_MAX + 24];
packet[0] = cmd;
memcpy(packet + 1, body, len);
int error = etcp_router_conn_send(rc, packet, len + 1, ROUTER_FLAG_SIGNED);
if (error) DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: control send failed peer=%llu cmd=%u rc=%d",
(unsigned long long)peer, cmd, error);
return error;
}
static struct dm_file_job* dm_job_find(struct dm_media_state* media, const uint8_t id[16], int custody) {
for (struct dm_file_job* job = media->jobs; job; job = job->next)
if ((job->work == DM_FILE_CUSTODY || job->work == DM_FILE_DELETE) == custody && !memcmp(job->id, id, 16)) return job;
return NULL;
}
static int dm_job_paths(struct dm_file_job* job, const char** paths) {
if (job->work != DM_FILE_PREPARE && job->work != DM_FILE_RECEIVE) return 0;
paths[0] = job->cipher; paths[1] = job->temp;
paths[2] = job->work == DM_FILE_PREPARE ? job->plain : job->output;
if (job->work == DM_FILE_PREPARE && job->remove_source) { paths[3] = job->input; return 4; }
return 3;
}
static int dm_job_hold(struct dm_file_job* job) {
const char* paths[4]; int count = dm_job_paths(job, paths);
for (int i = 0; i < count; i++) {
if (dm_media_file_hold(job->media->inst, job->conv, job->generation, paths[i])) return -1;
job->held++;
}
return 0;
}
static void dm_job_free(struct dm_file_job* job) {
struct dm_file_job** p = &job->media->jobs;
while (*p && *p != job) p = &(*p)->next;
if (*p) *p = job->next;
const char* paths[4]; dm_job_paths(job, paths);
for (unsigned i = 0; i < job->held; i++) dm_media_file_release(job->media->inst, paths[i]);
OPENSSL_cleanse(job->key, sizeof(job->key));
u_free(job);
}
/* Копирование своего source фиксирует один снимок до шифрования. */
static int dm_file_copy(const char* source, const char* dest, uint64_t limit) {
FILE* in = fopen(source, "rb");
FILE* out = in ? fopen(dest, "wb") : NULL;
int error = -1;
if (!in || !out) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: copy open failed %s -> %s: %s", source, dest, strerror(errno)); goto done; }
uint8_t buf[32768];
uint64_t size = 0;
size_t n;
while ((n = fread(buf, 1, sizeof(buf), in))) {
size += n;
if (size > limit || fwrite(buf, 1, n, out) != n) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: copy size/write failed bytes=%llu", (unsigned long long)size);
goto done;
}
}
if (ferror(in)) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: source read failed: %s", strerror(errno)); goto done; }
error = dm_file_sync(out);
done:
if (in && fclose(in)) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: source close failed"); error = -1; }
if (out && fclose(out)) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: copy close failed"); error = -1; }
return error;
}
/* Worker не обращается к БД/сети/контексту instance. */
static void dm_file_work(void* arg) {
struct dm_file_job* job = arg;
job->error = -1;
if (job->work == DM_FILE_DELETE) {
if (!dm_file_remove(job->cipher) && !dm_file_remove(job->temp)) job->error = file_io_sync_directory(job->cipher);
return;
}
if (job->work == DM_FILE_PREPARE) {
int copied = dm_file_copy(job->input, job->plain, DM_MEDIA_MAX_SIZE);
if (job->remove_source && dm_file_remove(job->input)) return;
if (copied) return;
} else if (job->input[0] && dm_file_copy(job->input, job->cipher, dm_media_cipher_size(job->size))) {
return;
}
FILE* in = fopen(job->work == DM_FILE_PREPARE ? job->plain : job->cipher, "rb");
FILE* out = job->work == DM_FILE_CUSTODY ? NULL : fopen(job->temp, "wb");
if (!in || (job->work != DM_FILE_CUSTODY && !out)) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: worker open failed: %s", strerror(errno));
goto done;
}
if (job->work == DM_FILE_PREPARE) {
job->error = media_encrypt_stream(job->key, job->author, job->id, in, out, &job->size, job->hash);
} else if (job->work == DM_FILE_RECEIVE) {
job->error = media_decrypt_stream(job->key, job->author, job->id, job->size, job->hash, in, out);
if (!job->error) {
FILE* durable = fopen(job->cipher, "rb+");
if (!durable) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: received ciphertext sync open failed"); job->error = -1; }
else { job->error = dm_file_sync(durable); if (fclose(durable)) job->error = -1; }
}
} else {
SHA256_CTX hash;
SHA256_Init(&hash);
uint8_t buf[32768], digest[32];
uint64_t size = 0;
size_t n;
while ((n = fread(buf, 1, sizeof(buf), in))) { size += n; SHA256_Update(&hash, buf, n); }
SHA256_Final(digest, &hash);
if (ferror(in) || size != dm_media_cipher_size(job->size) || memcmp(digest, job->hash, 32)) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: custody hash/size/read failed bytes=%llu expected=%llu",
(unsigned long long)size, (unsigned long long)dm_media_cipher_size(job->size));
} else job->error = 0;
if (!job->error) {
FILE* durable = fopen(job->cipher, "rb+");
if (!durable) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: custody sync open failed"); job->error = -1; }
else { job->error = dm_file_sync(durable); if (fclose(durable)) job->error = -1; }
}
}
if (!job->error && out) job->error = dm_file_sync(out);
done:
if (in && fclose(in)) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: worker input close failed"); job->error = -1; }
if (out && fclose(out)) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: worker output close failed"); job->error = -1; }
if (!job->error) {
if (job->work == DM_FILE_PREPARE) {
job->error = file_io_publish(job->temp, job->cipher);
if (!job->error) job->error = file_io_sync_directory(job->plain);
} else job->error = file_io_publish(job->work == DM_FILE_CUSTODY ? job->cipher : job->temp, job->output);
}
}
static void dm_custody_finalize(struct dm_media_state* media, const uint8_t id[16]);
/* Ответ относится к одному обращению S; свойства физической копии общие для всех обращений. */
static void dm_custody_status(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer,
const uint8_t assignment[16], const uint8_t id[16]) {
sqlite3_stmt* st = dm_media_prepare(inst, "SELECT r.rejected,c.ready,c.expires,c.delete_on_open,d.id IS NOT NULL,d.receipt"
" FROM dm_custody_requests r LEFT JOIN dm_custody c ON c.id=r.id LEFT JOIN dm_custody_done d ON d.id=r.id"
" WHERE r.supernode=? AND r.assignment_id=? AND r.id=?");
if (!st) return;
int rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)peer);
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 2, assignment, 16, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 3, id, 16, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_step(st);
if (rc != SQLITE_ROW) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM custody: request status unavailable S=%llu rc=%d",
(unsigned long long)peer, rc); sqlite3_finalize(st); return; }
int rejected = sqlite3_column_int(st, 0), ready = sqlite3_column_type(st, 1) == SQLITE_NULL ? -1 : sqlite3_column_int(st, 1);
uint64_t expires = (uint64_t)sqlite3_column_int64(st, 2);
uint8_t policy = (uint8_t)sqlite3_column_int(st, 3);
int retired = sqlite3_column_int(st, 4), delivered = sqlite3_column_bytes(st, 5) == DM_MEDIA_RECEIPT_SIZE;
rc = sqlite3_finalize(st);
if (rc != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM custody: status finalize failed rc=%d", rc); return; }
uint8_t status[41]; memcpy(status, assignment, 16); memcpy(status + 16, id, 16);
if (rejected) {
status[32] = (uint8_t)rejected; dm_media_send(inst, group, peer, DM_MEDIA_REJECT, status, 33);
} else if (retired) {
/* Все факты нужны S при политике «получили все»; один последний receipt недостаточен. */
if (delivered) {
st = dm_media_prepare(inst, "SELECT received FROM dm_file_targets WHERE id=?1 AND received IS NOT NULL"
" UNION ALL SELECT taken FROM dm_file_targets WHERE id=?1 AND taken IS NOT NULL");
if (!st) return;
rc = sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC);
uint8_t facts[2 * DM_TARGET_MAX][DM_MEDIA_RECEIPT_SIZE]; unsigned count = 0;
if (rc == SQLITE_OK) while ((rc = sqlite3_step(st)) == SQLITE_ROW && count < 2 * DM_TARGET_MAX) {
if (sqlite3_column_bytes(st, 0) != DM_MEDIA_RECEIPT_SIZE) { rc = SQLITE_CORRUPT; break; }
memcpy(facts[count++], sqlite3_column_blob(st, 0), DM_MEDIA_RECEIPT_SIZE);
}
int final = sqlite3_finalize(st);
if (rc != SQLITE_DONE || final != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM custody: retired facts scan failed rc=%d final=%d", rc, final); return;
}
for (unsigned i = 0; i < count; i++) dm_media_send(inst, group, peer, DM_MEDIA_RECEIPT, facts[i], sizeof(facts[i]));
}
dm_media_send(inst, group, peer, delivered ? DM_MEDIA_DELETE_ACK : DM_MEDIA_EXPIRED, status, 32);
} else if (ready >= 0 && expires > ntp_time_get_seconds(inst)) {
memcpy(status + 32, &expires, 8); status[40] = policy;
dm_media_send(inst, group, peer, ready ? DM_MEDIA_STORED : DM_MEDIA_RESERVED, status, sizeof(status));
}
}
/* Snapshot закрыт до send: локальный S=M может синхронно завершить копию из callback. */
static void dm_custody_notify(struct UTUN_INSTANCE* inst, const uint8_t id[16]) {
struct custody_target { uint64_t peer; uint8_t assignment[16]; } *targets = NULL;
size_t count = 0, capacity = 0;
sqlite3_stmt* st = dm_media_prepare(inst,
"SELECT supernode,assignment_id FROM dm_custody_requests WHERE id=? AND rejected=0");
if (!st) return;
int rc = sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC);
if (rc == SQLITE_OK) while ((rc = sqlite3_step(st)) == SQLITE_ROW) {
if (sqlite3_column_bytes(st, 1) != 16) { rc = SQLITE_CORRUPT; break; }
if (count == capacity) {
size_t next = capacity ? capacity * 2 : 8;
struct custody_target* grown = u_realloc(targets, next * sizeof(*targets));
if (!grown) { rc = SQLITE_NOMEM; break; }
targets = grown; capacity = next;
}
targets[count].peer = (uint64_t)sqlite3_column_int64(st, 0);
memcpy(targets[count++].assignment, sqlite3_column_blob(st, 1), 16);
}
int final = sqlite3_finalize(st);
if (rc != SQLITE_DONE || final != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_DM,
"DM custody: subscriber snapshot failed rc=%d final=%d count=%zu; S retries retained", rc, final, count);
else for (size_t i = 0; i < count; i++) dm_custody_status(inst, 0, targets[i].peer, targets[i].assignment, id);
u_free(targets);
}
/* Только uasync публикует проверенный файл и изменяет durable состояние. */
static void dm_file_done(void* arg, int error) {
struct dm_file_job* job = arg;
struct dm_media_state* media = job->media;
struct UTUN_INSTANCE* inst = media->inst;
if (job->work == DM_FILE_DELETE) {
if (!error && !job->error && !media->closing) dm_custody_finalize(media, job->id);
else DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: custody deletion worker failed/cancelled error=%d worker=%d", error, job->error);
dm_job_free(job);
return;
}
int custody = job->work == DM_FILE_CUSTODY;
if (error || job->error || media->closing) goto fail;
if (!custody && (!dm_history_current(inst, job->conv, job->generation) ||
dm_conversation_access(inst, job->conv, NULL, NULL))) {
DEBUG_INFO(DEBUG_CATEGORY_DM, "DM files: discard deleted preparation conv=%s generation=%llu work=%d", job->conv,
(unsigned long long)job->generation, job->work); goto fail;
}
if (job->work == DM_FILE_PREPARE) {
uint8_t data[DM_MEDIA_DESC_SIZE + DM_MEDIA_KEYS_HEADER_SIZE + ATTACHMENT_WIRE_MAX];
memcpy(data, job->id, 16); memcpy(data + 16, &job->size, 8); memcpy(data + 24, job->hash, 32);
memcpy(data + DM_MEDIA_DESC_SIZE, "DMKEY001", 8);
memcpy(data + DM_MEDIA_DESC_SIZE + 8, job->wrapped, sizeof(job->wrapped));
int n = attachment_encode(&job->info, data + DM_MEDIA_DESC_SIZE + DM_MEDIA_KEYS_HEADER_SIZE, ATTACHMENT_WIRE_MAX);
if (n < 0) goto fail;
if (dm_send(inst, job->conv, "file", data, (uint32_t)(DM_MEDIA_DESC_SIZE + DM_MEDIA_KEYS_HEADER_SIZE + n))) goto fail;
DEBUG_INFO(DEBUG_CATEGORY_DM, "dm_media: prepared conv=%s bytes=%llu name=%s", job->conv,
(unsigned long long)job->size, job->info.name);
chat_event_attachment(inst, 1, job->conv, job->id, 1);
chat_event_submitted(inst, job->request_id, 1);
dm_job_free(job);
return;
}
const char* sql = custody ? "SELECT body,author,ready,expires FROM dm_custody WHERE id=?" :
"SELECT body,author,ready,0 FROM dm_files WHERE id=?";
sqlite3_stmt* st = dm_media_prepare(inst, sql);
if (!st) goto fail;
sqlite3_bind_blob(st, 1, job->id, 16, SQLITE_STATIC);
int rc = sqlite3_step(st);
uint8_t body[DM_MEDIA_BODY_MAX];
size_t len = rc == SQLITE_ROW ? (size_t)sqlite3_column_bytes(st, 0) : 0;
uint64_t peer = rc == SQLITE_ROW ? (uint64_t)sqlite3_column_int64(st, 1) : 0;
int ready = rc == SQLITE_ROW ? sqlite3_column_int(st, 2) : -1;
uint64_t expires = rc == SQLITE_ROW ? (uint64_t)sqlite3_column_int64(st, 3) : 0;
if (len <= sizeof(body) && len) memcpy(body, sqlite3_column_blob(st, 0), len);
sqlite3_finalize(st);
/* EXPIRED завершает копию M; endpoint может принять проверенный оригинал в актуальном поколении. */
if (!len || len > sizeof(body) || (custody && (ready < 0 || expires <= ntp_time_get_seconds(inst)))) {
DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: completed task no longer admissible custody=%d ready=%d expiry=%llu",
custody, ready, (unsigned long long)expires);
goto fail;
}
if (!custody && job->input[0]) {
st = dm_media_prepare(inst, "SELECT 1 FROM dm_custody WHERE id=? AND ready=1 AND expires>?");
if (!st) goto fail;
sqlite3_bind_blob(st, 1, job->id, 16, SQLITE_STATIC); sqlite3_bind_int64(st, 2, ntp_time_get_seconds(inst));
int active = sqlite3_step(st) == SQLITE_ROW; sqlite3_finalize(st);
if (!active) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM local download: custody expired during copy"); goto fail; }
}
uint8_t receipt[DM_MEDIA_RECEIPT_SIZE];
int acknowledge = !custody && job->author != inst->node_id;
if (acknowledge && dm_media_receipt_make(inst, body, len, 0, receipt)) goto fail;
if (dm_media_exec(inst, "BEGIN IMMEDIATE")) goto fail;
st = dm_media_prepare(inst, custody ? "UPDATE dm_custody SET ready=1 WHERE id=? AND ready=0" : acknowledge ?
"UPDATE dm_files SET ready=1,downloading=0,receipt=? WHERE id=? AND ready<=0" :
"UPDATE dm_files SET ready=1,downloading=0 WHERE id=? AND ready<=0");
if (!st) { dm_media_exec(inst, "ROLLBACK"); goto fail; }
int idx = 1;
if (acknowledge) sqlite3_bind_blob(st, idx++, receipt, sizeof(receipt), SQLITE_STATIC);
sqlite3_bind_blob(st, idx, job->id, 16, SQLITE_STATIC);
if (dm_media_step(inst, st) || sqlite3_changes(inst->topo_sqlite_db) != 1 ||
(acknowledge && dm_media_receipt_save(inst, receipt, body, len)) || dm_media_exec(inst, "COMMIT")) {
dm_media_exec(inst, "ROLLBACK"); goto fail;
}
if (!custody && ready < 0)
DEBUG_INFO(DEBUG_CATEGORY_DM, "dm_media: recovered expired file conv=%s generation=%llu bytes=%llu",
job->conv, (unsigned long long)job->generation, (unsigned long long)job->size);
if (custody) {
dm_custody_notify(inst, job->id);
} else {
if (acknowledge) {
dm_media_send(inst, 0, peer, DM_MEDIA_RECEIPT, receipt, sizeof(receipt));
dm_mailbox_media_receipt(inst, receipt);
}
uint64_t conv; memcpy(&conv, body, 8); dm_media_notify(inst, conv);
}
DEBUG_INFO(DEBUG_CATEGORY_DM, "dm_media: file committed custody=%d peer=%llu bytes=%llu expires=%llu", custody,
(unsigned long long)peer, (unsigned long long)job->size, (unsigned long long)expires);
dm_job_free(job);
return;
fail:
DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: file task failed/cancelled work=%d error=%d worker=%d closing=%d",
job->work, error, job->error, media->closing);
if (job->work == DM_FILE_PREPARE) {
chat_event_submitted(inst, job->request_id, 0);
chat_event_attachment(inst, 1, job->conv, job->id, 2);
dm_file_remove(job->plain); dm_file_remove(job->cipher);
if (job->remove_source) dm_file_remove(job->input);
}
else if (!custody) {
sqlite3_stmt* st = dm_media_prepare(inst, "UPDATE dm_files SET downloading=0 WHERE id=?");
if (st) { sqlite3_bind_blob(st, 1, job->id, 16, SQLITE_STATIC); dm_media_step(inst, st); }
}
dm_file_remove(job->temp);
if (job->output[0]) dm_file_remove(job->output);
if (job->work != DM_FILE_PREPARE) dm_file_remove(job->cipher);
dm_job_free(job);
}
static void dm_file_received(void* arg, int error) {
struct dm_file_job* job = arg;
job->transfer = NULL;
if (error || job->media->closing) { dm_file_done(job, error ? error : -2); return; }
media_async_submit(job->media->inst->media_async, job->media->inst->ua, dm_file_work, job, dm_file_done, job);
}
/* Получать файл может endpoint или M; обе операции владеют одним CM handle в transfer. */
static void dm_file_pull(struct dm_media_state* media, int custody, uint64_t group, uint64_t peer, const uint8_t* desc,
uint64_t author) {
if (dm_job_find(media, desc, custody)) return;
struct dm_file_job* job = u_calloc(1, sizeof(*job));
if (!job) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: download allocation failed"); return; }
job->media = media; job->work = custody ? DM_FILE_CUSTODY : DM_FILE_RECEIVE; job->author = author;
memcpy(job->id, desc, 16); memcpy(&job->size, desc + 16, 8); memcpy(job->hash, desc + 24, 32);
if (dm_file_path(media, custody, job->id, custody ? ".download" : ".cipher", job->cipher, sizeof(job->cipher)) ||
dm_file_path(media, custody, job->id, ".decrypt", job->temp, sizeof(job->temp)) ||
dm_file_path(media, custody, job->id, custody ? ".cipher" : ".file", job->output, sizeof(job->output))) { u_free(job); return; }
if (!custody) {
sqlite3_stmt* st = dm_media_prepare(media->inst, "SELECT conv_id,body FROM dm_files WHERE id=?");
if (!st) { dm_job_free(job); return; }
sqlite3_bind_blob(st, 1, job->id, 16, SQLITE_STATIC);
int rc = sqlite3_step(st);
int found = rc == SQLITE_ROW;
if (found) {
snprintf(job->conv, sizeof(job->conv), "%s", sqlite3_column_text(st, 0));
found = !dm_file_key(media->inst, sqlite3_column_blob(st, 1), (size_t)sqlite3_column_bytes(st, 1), job->key);
}
sqlite3_finalize(st);
if (!found) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: target key unavailable author=%llu", (unsigned long long)author);
dm_job_free(job);
return;
}
job->generation = dm_history_generation(media->inst, job->conv);
if (dm_job_hold(job)) { dm_job_free(job); return; }
}
job->next = media->jobs; media->jobs = job;
if (peer == media->inst->node_id) {
if (dm_file_path(media, !custody, job->id, ".cipher", job->input, sizeof(job->input))) { dm_file_done(job, -1); return; }
DEBUG_INFO(DEBUG_CATEGORY_DM, "DM local copy: custody=%d bytes=%llu", custody, (unsigned long long)job->size);
media_async_submit(media->inst->media_async, media->inst->ua, dm_file_work, job, dm_file_done, job);
return;
}
job->transfer = file_transfer_pull(media->inst->file_transfer, group, peer, job->id, dm_media_cipher_size(job->size),
job->cipher, dm_file_received, job);
if (!job->transfer) { dm_file_done(job, -1); return; }
if (!custody) {
sqlite3_stmt* st = dm_media_prepare(media->inst, "UPDATE dm_files SET downloading=1 WHERE id=?");
if (st) { sqlite3_bind_blob(st, 1, job->id, 16, SQLITE_STATIC); dm_media_step(media->inst, st); }
}
}
/* Поставщиком может быть любая готовая реплика; UUID сам по себе права не даёт. */
static int dm_file_lookup(void* arg, uint64_t group, uint64_t peer, const uint8_t id[16], uint64_t size,
char* path, size_t cap) {
struct dm_media_state* media = arg;
struct UTUN_INSTANCE* inst = media->inst;
if (media->closing || (peer != inst->node_id && !topo_group_chat_allowed(inst, group, peer))) {
DEBUG_WARN(DEBUG_CATEGORY_DM, "DM file lookup: stopped/denied group=%llu peer=%llu",
(unsigned long long)group, (unsigned long long)peer); return -1;
}
for (int custody = 0; custody <= 1; custody++) {
sqlite3_stmt* st = dm_media_prepare(inst, custody ?
"SELECT author,recipient,plain_size,ready,expires,body,NULL FROM dm_custody WHERE id=?" :
"SELECT author,recipient,plain_size,ready,0,body,conv_id FROM dm_files WHERE id=?");
if (!st) return -1;
sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC);
int rc = sqlite3_step(st);
uint64_t author = rc == SQLITE_ROW ? (uint64_t)sqlite3_column_int64(st, 0) : 0;
uint64_t recipient = rc == SQLITE_ROW ? (uint64_t)sqlite3_column_int64(st, 1) : 0;
uint64_t plain = rc == SQLITE_ROW ? (uint64_t)sqlite3_column_int64(st, 2) : 0;
int ready = rc == SQLITE_ROW ? sqlite3_column_int(st, 3) : 0;
uint64_t expiry = rc == SQLITE_ROW ? (uint64_t)sqlite3_column_int64(st, 4) : 0;
int allowed = 0;
struct dm_message_view view;
if (rc == SQLITE_ROW && !dm_message_parse(sqlite3_column_blob(st, 5), (size_t)sqlite3_column_bytes(st, 5), &view)) {
if (view.mode == DM_MODE_DEVICE) allowed = peer == author || peer == recipient;
else allowed = dm_party_member(inst, view.parties, peer) == 1 || dm_party_member(inst, view.parties + 1, peer) == 1;
}
if (!custody && rc == SQLITE_ROW && dm_conversation_access(inst, (const char*)sqlite3_column_text(st, 6), NULL, NULL)) ready = 0;
sqlite3_finalize(st);
if (!author || ready != 1 || size != dm_media_cipher_size(plain) ||
(custody && expiry <= ntp_time_get_seconds(inst))) continue;
if (allowed || (!custody && dm_peer_role(inst, peer, "storage")))
return dm_file_path(media, custody, id, ".cipher", path, cap);
}
DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: file serve denied peer=%llu bytes=%llu", (unsigned long long)peer, (unsigned long long)size);
return -1;
}
/* Не менять активную передачу из-за позднего ответа; оставшиеся ответы — резерв. */
static struct dm_file_probe* dm_probe_find(struct dm_media_state* media, const uint8_t id[16]) {
for (struct dm_file_probe* p = media->probes; p; p = p->next) if (!memcmp(p->desc, id, 16)) return p;
return NULL;
}
static void dm_probe_add(struct dm_media_state* media, struct dm_file_probe* p, uint64_t node, uint64_t preferred) {
for (unsigned i = 0; i < p->count; i++) if (p->sources[i].node == node) return;
uint64_t group = preferred;
if (node != media->inst->node_id && !dm_route_group(media->inst, node, preferred, &group)) return;
if (p->count == DM_SOURCE_MAX) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM sources: candidate limit reached conv=%s", p->conv); return; }
struct dm_probe_source* source = p->sources + p->count++;
source->node = node; source->group = group; source->sent = get_time_tb();
uint8_t request[64]; uint64_t plain, size; memcpy(&plain, p->desc + 16, 8); size = dm_media_cipher_size(plain);
memcpy(request, p->desc, 16); memcpy(request + 16, p->desc + 24, 32);
memcpy(request + 48, &p->token, 8); memcpy(request + 56, &size, 8);
if (dm_media_send(media->inst, group, node, DM_MEDIA_HAVE, request, sizeof(request))) source->used = 1;
}
/* Одна ограниченная параллельная проба по устройствам обеих сторон и известным M. */
static void dm_probe_start(struct dm_media_state* media, const uint8_t* body, size_t len, uint64_t preferred) {
struct dm_message_view view;
if (dm_message_parse(body, len, &view) || !view.media || dm_probe_find(media, view.media)) return;
struct dm_file_probe* p = u_calloc(1, sizeof(*p));
if (!p) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM sources: allocation failed"); return; }
memcpy(p->desc, view.media, sizeof(p->desc)); p->author = view.author;
snprintf(p->conv, sizeof(p->conv), "%llu", (unsigned long long)view.conv);
struct dm_party parties[2]; struct dm_target targets[DM_TARGET_MAX]; size_t count;
if (dm_conversation_access(media->inst, p->conv, parties, NULL) ||
dm_identity_targets(media->inst, parties, targets, DM_TARGET_MAX, &count) || RAND_bytes((uint8_t*)&p->token, 8) != 1) {
DEBUG_WARN(DEBUG_CATEGORY_DM, "DM sources: party/source snapshot unavailable conv=%s", p->conv); u_free(p); return;
}
p->deadline = get_time_tb() + 10000; p->next = media->probes; media->probes = p;
for (size_t i = 0; i < count; i++) if (targets[i].node != media->inst->node_id) dm_probe_add(media, p, targets[i].node, preferred);
sqlite3_stmt* st = dm_media_prepare(media->inst, "SELECT node FROM dm_file_sources WHERE id=? AND (expires=0 OR expires>?)");
if (st) {
sqlite3_bind_blob(st, 1, p->desc, 16, SQLITE_STATIC); sqlite3_bind_int64(st, 2, ntp_time_get_seconds(media->inst));
uint64_t nodes[64]; unsigned n = 0; int rc;
while ((rc = sqlite3_step(st)) == SQLITE_ROW && n < 64) {
nodes[n++] = (uint64_t)sqlite3_column_int64(st, 0);
}
sqlite3_finalize(st);
if (rc != SQLITE_DONE) DEBUG_WARN(DEBUG_CATEGORY_DM, "DM sources: holder scan incomplete rc=%d count=%u", rc, n);
for (unsigned i = 0; i < n; i++) dm_probe_add(media, p, nodes[i], 0);
}
DEBUG_DEBUG(DEBUG_CATEGORY_DM, "DM sources: parallel HAVE conv=%s candidates=%u window_ms=1000", p->conv, p->count);
}
static void dm_probe_control(struct dm_media_state* media, uint64_t group, uint64_t peer, int reply, const uint8_t* request) {
uint64_t token, size; memcpy(&token, request + 48, 8); memcpy(&size, request + 56, 8);
if (reply) {
struct dm_file_probe* p = dm_probe_find(media, request);
if (!p || p->token != token || memcmp(request + 16, p->desc + 24, 32) || get_time_tb() > p->deadline) return;
for (unsigned i = 0; i < p->count; i++) {
struct dm_probe_source* s = p->sources + i;
if (s->node == peer && !s->used && !s->rtt) {
s->group = group;
s->rtt = get_time_tb() - s->sent + 1;
DEBUG_DEBUG(DEBUG_CATEGORY_DM, "DM sources: HAVE peer=%llu rtt_us=%llu", (unsigned long long)peer,
(unsigned long long)(s->rtt * 100)); return;
}
}
return;
}
sqlite3_stmt* st = dm_media_prepare(media->inst,
"SELECT body FROM dm_files WHERE id=? UNION ALL SELECT body FROM dm_custody WHERE id=?");
if (!st) return;
sqlite3_bind_blob(st, 1, request, 16, SQLITE_STATIC); sqlite3_bind_blob(st, 2, request, 16, SQLITE_STATIC);
int found = 0, rc;
while ((rc = sqlite3_step(st)) == SQLITE_ROW) {
const uint8_t* desc = dm_message_media(sqlite3_column_blob(st, 0), (size_t)sqlite3_column_bytes(st, 0));
if (desc && !memcmp(desc + 24, request + 16, 32)) { found = 1; break; }
}
sqlite3_finalize(st);
if (!found) return;
char path[1024]; struct stat sb;
if (!dm_file_lookup(media, group, peer, request, size, path, sizeof(path)) && !stat(path, &sb) && (uint64_t)sb.st_size == size)
dm_media_send(media->inst, group, peer, DM_MEDIA_HAVE_ACK, request, 64);
}
/* После окна проб выбираем минимальный RTT. Ошибка transfer/проверки оставляет резервные источники. */
static void dm_probe_tick(struct dm_media_state* media) {
struct dm_file_probe** link = &media->probes;
while (*link) {
struct dm_file_probe* p = *link;
if (get_time_tb() < p->deadline || dm_job_find(media, p->desc, 0)) { link = &p->next; continue; }
sqlite3_stmt* st = dm_media_prepare(media->inst, "SELECT f.ready,c.closed FROM dm_files f JOIN dm_conversations c USING(conv_id) WHERE f.id=?");
if (!st) return;
sqlite3_bind_blob(st, 1, p->desc, 16, SQLITE_STATIC); int rc = sqlite3_step(st);
int pending = rc == SQLITE_ROW && sqlite3_column_int(st, 0) != 1 && !sqlite3_column_int(st, 1);
sqlite3_finalize(st);
unsigned best = p->count;
if (pending) for (unsigned i = 0; i < p->count; i++) if (!p->sources[i].used && p->sources[i].rtt &&
(best == p->count || p->sources[i].rtt < p->sources[best].rtt)) best = i;
if (best == p->count) { *link = p->next; u_free(p); continue; }
struct dm_probe_source* s = p->sources + best; s->used = 1;
if (s->node != media->inst->node_id && !dm_route_group(media->inst, s->node, s->group, &s->group)) {
DEBUG_DEBUG(DEBUG_CATEGORY_DM, "DM sources: candidate lost route peer=%llu", (unsigned long long)s->node); continue;
}
DEBUG_INFO(DEBUG_CATEGORY_DM, "DM sources: download conv=%s peer=%llu rtt_us=%llu", p->conv,
(unsigned long long)s->node, (unsigned long long)(s->rtt * 100));
dm_file_pull(media, 0, s->group, s->node, p->desc, p->author);
link = &p->next;
}
}
/* Путь исходника копируется; caller может освободить строку после возврата. */
int dm_send_file(struct UTUN_INSTANCE* inst, const char* conv, const char* path, const struct attachment_info* info,
int remove_source, uint64_t request_id) {
struct dm_media_state* media = inst ? inst->dm_media : NULL;
if (!media || media->closing || !inst->media_async || !conv || !path || attachment_validate(info) || strlen(path) >= 1024) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: invalid file send arguments/state");
return -1;
}
unsigned preparing = 0;
for (struct dm_file_job* job = media->jobs; job; job = job->next) if (job->work == DM_FILE_PREPARE) preparing++;
if (preparing >= 4) { DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: preparation limit reached count=%u", preparing); return -1; }
struct dm_file_job* job = u_calloc(1, sizeof(*job));
if (!job) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: preparation allocation failed"); return -1; }
job->media = media; job->work = DM_FILE_PREPARE; job->author = inst->node_id;
job->generation = dm_history_generation(inst, conv);
job->request_id = request_id;
if (!dm_history_current(inst, conv, job->generation) || dm_conversation_access(inst, conv, NULL, NULL)) {
u_free(job); return -1;
}
snprintf(job->conv, sizeof(job->conv), "%s", conv); job->info = *info; job->remove_source = remove_source;
snprintf(job->input, sizeof(job->input), "%s", path);
struct dm_party parties[2];
if (dm_conversation_access(inst, conv, parties, NULL) || RAND_bytes(job->id, sizeof(job->id)) != 1 ||
dm_file_keys_create(strtoull(conv, NULL, 10), inst->node_id, job->id, parties, job->wrapped, job->key) ||
dm_file_path(media, 0, job->id, ".file", job->plain, sizeof(job->plain)) ||
dm_file_path(media, 0, job->id, ".cipher", job->cipher, sizeof(job->cipher)) ||
dm_file_path(media, 0, job->id, ".encrypt", job->temp, sizeof(job->temp))) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: preparation keys/paths failed conv=%s", conv);
OPENSSL_cleanse(job->key, sizeof(job->key)); u_free(job); return -1;
}
if (dm_job_hold(job)) { dm_job_free(job); return -1; }
job->next = media->jobs; media->jobs = job;
DEBUG_INFO(DEBUG_CATEGORY_DM, "dm_media: preparing conv=%s kind=%d name=%s", conv, info->kind, info->name);
chat_event_attachment(inst, 1, conv, job->id, 0);
media_async_submit(inst->media_async, inst->ua, dm_file_work, job, dm_file_done, job);
return 0;
}
/* Удаление непринятого временного файла тоже выполняется вне uasync. */
static void dm_source_cleanup(void* arg) {
struct dm_file_req* req = arg;
dm_file_remove(req->path);
}
static void dm_source_cleaned(void* arg, int error) {
if (error) {
DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: source cleanup cancelled error=%d", error);
dm_source_cleanup(arg);
}
u_free(arg);
}
void dm_send_file_trampoline(void* arg) {
struct dm_file_req* req = arg;
if (!req) return;
uint64_t generation = req->generation ? req->generation : dm_history_generation(req->inst, req->conv);
int current = dm_history_current(req->inst, req->conv, generation);
if (!current || dm_send_file(req->inst, req->conv, req->path, &req->info, req->remove_source, req->request_id)) {
DEBUG_WARN(DEBUG_CATEGORY_DM, "DM files: send rejected conv=%s generation=%llu", req->conv, (unsigned long long)generation);
chat_event_submitted(req->inst, req->request_id, 0);
const uint8_t rejected[16] = {0};
if (current) chat_event_attachment(req->inst, 1, req->conv, rejected, 2);
if (req->remove_source) {
media_async_submit(req->inst ? req->inst->media_async : NULL, req->inst ? req->inst->ua : NULL,
dm_source_cleanup, req, dm_source_cleaned, req);
return;
}
}
u_free(req);
}
int dm_media_user_action(struct UTUN_INSTANCE* inst, const char* conv, uint64_t author, uint64_t seq, int opened) {
if (!inst || !inst->dm_media || inst->dm_media->closing || !conv || (opened != 0 && opened != 1)) {
DEBUG_WARN(DEBUG_CATEGORY_DM, "DM file action: invalid/stopped request"); return -1;
}
sqlite3_stmt* st = dm_media_prepare(inst, "SELECT body,ready,local_path,plain_size FROM dm_files WHERE conv_id=? AND author=? AND seq=?");
if (!st) return -1;
sqlite3_bind_text(st, 1, conv, -1, SQLITE_STATIC); sqlite3_bind_int64(st, 2, (sqlite3_int64)author);
sqlite3_bind_int64(st, 3, (sqlite3_int64)seq); int rc = sqlite3_step(st);
uint8_t body[DM_BODY_MAX]; size_t len = rc == SQLITE_ROW ? (size_t)sqlite3_column_bytes(st, 0) : 0;
int ready = rc == SQLITE_ROW ? sqlite3_column_int(st, 1) : 0;
if (len && len <= sizeof(body)) memcpy(body, sqlite3_column_blob(st, 0), len);
int available = 0;
if (ready == 1) {
struct stat sb; const char* path = (const char*)sqlite3_column_text(st, 2);
available = path && !stat(path, &sb) && (uint64_t)sb.st_size == (uint64_t)sqlite3_column_int64(st, 3);
}
sqlite3_finalize(st);
const uint8_t* desc = len <= sizeof(body) ? dm_message_media(body, len) : NULL;
if (!desc || (opened && !available)) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM file action: file unavailable conv=%s rc=%d", conv, rc); return -1; }
int active = !dm_conversation_access(inst, conv, NULL, NULL);
if (opened) {
struct dm_message_view view; if (dm_message_parse(body, len, &view)) return -1;
if (!active || author == inst->node_id ||
(view.mode == DM_MODE_GROUPS && dm_message_target(&view, inst->node_id, NULL, NULL) == 0)) return 0;
uint8_t r[DM_MEDIA_RECEIPT_SIZE];
if (dm_media_receipt_make(inst, body, len, 1, r) || dm_media_receipt_save(inst, r, body, len)) return -1;
DEBUG_INFO(DEBUG_CATEGORY_DM, "DM file: user opened conv=%s author=%llu seq=%llu", conv,
(unsigned long long)author, (unsigned long long)seq);
} else if (!active) return -1;
if (!opened && available) return 0;
st = dm_media_prepare(inst, opened ? "UPDATE dm_files SET retry_at=0 WHERE id=?" :
"UPDATE dm_files SET ready=0,manual=1,retry_at=0 WHERE id=?");
if (!st) return -1;
sqlite3_bind_blob(st, 1, desc, 16, SQLITE_STATIC);
if (dm_media_step(inst, st)) return -1;
dm_media_notify(inst, strtoull(conv, NULL, 10)); return 0;
}
void dm_media_action_trampoline(void* arg) {
struct dm_media_action_req* req = arg;
if (!req) return;
if (!dm_history_current(req->inst, req->conv, req->generation) ||
dm_media_user_action(req->inst, req->conv, req->author, req->seq, req->opened))
DEBUG_WARN(DEBUG_CATEGORY_DM, "DM file action: rejected conv=%s author=%llu seq=%llu opened=%d", req->conv,
(unsigned long long)req->author, (unsigned long long)req->seq, req->opened);
u_free(req);
}
/* STORE сначала резервирует место и фиксирует expiry; повтор не продлевает TTL. */
static void dm_custody_store(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t source, const uint8_t* p, size_t len) {
if (len < 24) { DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: short STORE"); return; }
const uint8_t* assignment = p;
static const uint8_t zero[16];
if (!memcmp(assignment, zero, 16)) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM custody: zero assignment ID"); return; }
p += 16; len -= 16;
uint64_t recipient, author, size;
memcpy(&recipient, p, 8); p += 8; len -= 8;
const uint8_t* desc = dm_message_media(p, len);
if (!desc || dm_verify_message(inst, recipient, p, len)) return;
memcpy(&author, p + 24, 8); memcpy(&size, desc + 16, 8);
uint8_t hash[32]; SHA256(p, len, hash);
sqlite3_stmt* st = dm_media_prepare(inst,
"SELECT id,body_hash FROM dm_custody_requests WHERE supernode=? AND assignment_id=?");
if (!st) return;
int rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)source);
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 2, assignment, 16, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_step(st);
int exists = rc == SQLITE_ROW;
int exact = exists && sqlite3_column_bytes(st, 0) == 16 && !memcmp(sqlite3_column_blob(st, 0), desc, 16) &&
sqlite3_column_bytes(st, 1) == 32 && !memcmp(sqlite3_column_blob(st, 1), hash, 32);
int final = sqlite3_finalize(st);
if ((rc != SQLITE_ROW && rc != SQLITE_DONE) || final != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM custody: assignment lookup failed rc=%d final=%d", rc, final); return;
}
if (exists) {
if (!exact) DEBUG_WARN(DEBUG_CATEGORY_DM, "DM custody: assignment conflicts with file/body S=%llu", (unsigned long long)source);
else dm_custody_status(inst, group, source, assignment, desc);
return;
}
if (!dm_peer_role(inst, source, "supernode") || !dm_peer_role(inst, inst->node_id, "storage")) {
DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: unauthorized STORE source=%llu group=%llu",
(unsigned long long)source, (unsigned long long)group);
return;
}
if (dm_media_exec(inst, "BEGIN IMMEDIATE")) return;
st = dm_media_prepare(inst, "SELECT body_hash FROM dm_custody WHERE id=?1 UNION ALL SELECT body_hash FROM dm_custody_done WHERE id=?1");
if (!st) goto rollback;
rc = sqlite3_bind_blob(st, 1, desc, 16, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_step(st);
exists = rc == SQLITE_ROW;
exact = !exists || (sqlite3_column_bytes(st, 0) == 32 && !memcmp(sqlite3_column_blob(st, 0), hash, 32));
if (rc == SQLITE_ROW) rc = sqlite3_step(st);
final = sqlite3_finalize(st);
if (rc != SQLITE_DONE || final != SQLITE_OK || !exact) {
DEBUG_WARN(DEBUG_CATEGORY_DM, "DM custody: copy/tombstone conflict or lookup failure rc=%d final=%d exact=%d", rc, final, exact);
goto rollback;
}
int rejected = 0;
uint64_t cipher_size = dm_media_cipher_size(size);
if (!exists) {
st = dm_media_prepare(inst, "SELECT COALESCE(sum(cipher_size),0) FROM dm_custody");
if (!st) goto rollback;
rc = sqlite3_step(st);
uint64_t used = rc == SQLITE_ROW ? (uint64_t)sqlite3_column_int64(st, 0) : 0;
final = sqlite3_finalize(st);
if (rc != SQLITE_ROW || final != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM custody: quota read failed rc=%d final=%d; no REJECT", rc, final); goto rollback;
}
uint64_t quota = (uint64_t)chat_setting_get_int(inst, "dm_media_storage_mb", 1024) * 1024 * 1024;
rejected = used > quota || cipher_size > quota - used;
if (rejected) DEBUG_WARN(DEBUG_CATEGORY_DM, "DM custody: quota refused used=%llu requested=%llu quota=%llu",
(unsigned long long)used, (unsigned long long)cipher_size, (unsigned long long)quota);
}
if (!exists && !rejected) {
uint64_t expires = ntp_time_get_seconds(inst) + (uint64_t)chat_setting_get_int(inst, "dm_media_ttl_sec", 604800);
int policy = chat_setting_get_int(inst, "dm_media_delete_on_open", 1);
st = dm_media_prepare(inst, "INSERT INTO dm_custody(id,author,recipient,plain_size,cipher_size,body,body_hash,"
"expires,delete_on_open) VALUES(?,?,?,?,?,?,?,?,?)");
if (!st) goto rollback;
rc = sqlite3_bind_blob(st, 1, desc, 16, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 2, (sqlite3_int64)author);
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 3, (sqlite3_int64)recipient);
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 4, (sqlite3_int64)size);
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 5, (sqlite3_int64)cipher_size);
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 6, p, (int)len, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 7, hash, 32, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 8, (sqlite3_int64)expires);
if (rc == SQLITE_OK) rc = sqlite3_bind_int(st, 9, policy);
if (rc != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM custody: reservation bind failed rc=%d", rc); sqlite3_finalize(st); goto rollback;
}
if (dm_media_step(inst, st) || dm_media_targets_seed(inst, p, len, recipient)) goto rollback;
}
st = dm_media_prepare(inst, "INSERT INTO dm_custody_requests(supernode,assignment_id,id,body_hash,rejected) VALUES(?,?,?,?,?)");
if (!st) goto rollback;
rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)source);
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 2, assignment, 16, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 3, desc, 16, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 4, hash, 32, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_bind_int(st, 5, rejected);
if (rc != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM custody: assignment bind failed rc=%d", rc); sqlite3_finalize(st); goto rollback;
}
if (dm_media_step(inst, st) || dm_media_exec(inst, "COMMIT")) goto rollback;
uint64_t token[2], file[2]; memcpy(token, assignment, 16); memcpy(file, desc, 16);
DEBUG_INFO(DEBUG_CATEGORY_DM, "DM custody: assignment=%016llx%016llx file=%016llx%016llx S=%llu existing_copy=%d rejected=%d",
(unsigned long long)token[0], (unsigned long long)token[1], (unsigned long long)file[0], (unsigned long long)file[1],
(unsigned long long)source, exists, rejected);
dm_custody_status(inst, group, source, assignment, desc);
return;
rollback:
DEBUG_WARN(DEBUG_CATEGORY_DM, "DM custody: STORE rolled back S=%llu; request can retry, no response", (unsigned long long)source);
dm_media_exec(inst, "ROLLBACK");
}
/* Удаление подтверждается только после durable удаления файла и копии из БД. */
static void dm_custody_finalize(struct dm_media_state* media, const uint8_t id[16]) {
struct UTUN_INSTANCE* inst = media->inst;
sqlite3_stmt* st = dm_media_prepare(inst, "SELECT body_hash,receipt,delete_on_open FROM dm_custody WHERE id=? AND ready=-1");
if (!st) return;
int rc = sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC);
if (rc != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: delete state bind failed rc=%d; retry retained", rc);
sqlite3_finalize(st); return;
}
rc = sqlite3_step(st);
uint8_t hash[32], receipt[DM_MEDIA_RECEIPT_SIZE];
if (rc != SQLITE_ROW || sqlite3_column_bytes(st, 0) != sizeof(hash)) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: delete state missing/corrupt rc=%d", rc);
sqlite3_finalize(st); return;
}
memcpy(hash, sqlite3_column_blob(st, 0), sizeof(hash));
int delivered = sqlite3_column_bytes(st, 1) == sizeof(receipt);
if (delivered) memcpy(receipt, sqlite3_column_blob(st, 1), sizeof(receipt));
int policy = sqlite3_column_int(st, 2);
int final = sqlite3_finalize(st);
if (final != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: delete state finalize failed rc=%d; retry retained", final); return;
}
if (dm_media_exec(inst, "BEGIN IMMEDIATE")) return;
st = dm_media_prepare(inst, "INSERT INTO dm_custody_done(id,body_hash,receipt,delete_on_open) VALUES(?,?,?,?)"
" ON CONFLICT(id) DO UPDATE SET receipt=COALESCE(excluded.receipt,receipt)");
if (!st) goto rollback;
rc = sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 2, hash, 32, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = delivered ? sqlite3_bind_blob(st, 3, receipt, sizeof(receipt), SQLITE_STATIC) : sqlite3_bind_null(st, 3);
if (rc == SQLITE_OK) rc = sqlite3_bind_int(st, 4, policy);
if (rc != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: tombstone bind failed rc=%d; custody retained", rc);
sqlite3_finalize(st); goto rollback;
}
if (dm_media_step(inst, st)) goto rollback;
st = dm_media_prepare(inst, "DELETE FROM dm_custody WHERE id=? AND ready=-1");
if (!st) goto rollback;
rc = sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC);
if (rc != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: custody delete bind failed rc=%d; tombstone rolled back", rc);
sqlite3_finalize(st); goto rollback;
}
if (dm_media_step(inst, st) || dm_media_exec(inst, "COMMIT")) goto rollback;
dm_custody_notify(inst, id);
DEBUG_INFO(DEBUG_CATEGORY_DM, "dm_media: custody copy removed reason=%s super=%llu",
delivered ? "delivered" : "expired", (unsigned long long)super);
return;
rollback:
DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: custody retirement rolled back; retry retained, no ACK");
dm_media_exec(inst, "ROLLBACK");
}
/* Удаление больших файлов и fsync каталога выполняются worker, а не uasync. */
static void dm_custody_remove(struct dm_media_state* media, const uint8_t id[16]) {
char cipher[1024], download[1024];
if (dm_file_path(media, 1, id, ".cipher", cipher, sizeof(cipher)) ||
dm_file_path(media, 1, id, ".download", download, sizeof(download))) return;
file_transfer_cancel_path(media->inst->file_transfer, cipher);
file_transfer_cancel_path(media->inst->file_transfer, download);
if (dm_job_find(media, id, 1)) return;
struct dm_file_job* job = u_calloc(1, sizeof(*job));
if (!job) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: delete worker allocation failed"); return; }
job->media = media; job->work = DM_FILE_DELETE;
memcpy(job->id, id, 16);
if (dm_file_path(media, 1, id, ".cipher", job->cipher, sizeof(job->cipher)) ||
dm_file_path(media, 1, id, ".download", job->temp, sizeof(job->temp))) { u_free(job); return; }
job->next = media->jobs; media->jobs = job;
media_async_submit(media->inst->media_async, media->inst->ua, dm_file_work, job, dm_file_done, job);
}
/* Все факты уже проверены и сохранены. Терминальное состояние предшествует отмене I/O. */
static int dm_custody_complete(struct UTUN_INSTANCE* inst, const uint8_t id[16], int policy) {
int done = dm_media_retention_done(inst, id, policy);
if (done <= 0) return done;
sqlite3_stmt* st = dm_media_prepare(inst, policy ?
"UPDATE dm_custody SET ready=-1,retry_at=0,receipt=(SELECT taken FROM dm_file_targets"
" WHERE id=? AND side=1 AND taken IS NOT NULL LIMIT 1) WHERE id=? AND ready>=0" :
"UPDATE dm_custody SET ready=-1,retry_at=0,receipt=(SELECT received FROM dm_file_targets"
" WHERE id=? AND side=1 AND received IS NOT NULL LIMIT 1) WHERE id=? AND ready>=0");
if (!st) return -1;
sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC); sqlite3_bind_blob(st, 2, id, 16, SQLITE_STATIC);
if (dm_media_step(inst, st)) return -1;
if (sqlite3_changes(inst->topo_sqlite_db)) DEBUG_INFO(DEBUG_CATEGORY_DM, "DM custody: retention complete delete_on_open=%d", policy);
return 1;
}
/* DELETE переносит факт; удаление определяет сохранённая политика M. */
static void dm_custody_delete(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t source,
const uint8_t assignment[16], const uint8_t* r) {
if (dm_media_receipt_kind(r) < 0) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM custody: unknown receipt domain"); return; }
/* Для существующей копии точная подпись B разрешает удаление даже после отзыва роли M/S. */
sqlite3_stmt* st = dm_media_prepare(inst, "SELECT body,delete_on_open FROM dm_custody WHERE id=?");
if (!st) return;
sqlite3_bind_blob(st, 1, r + 40, 16, SQLITE_STATIC);
int rc = sqlite3_step(st);
if (rc == SQLITE_ROW) {
int policy = sqlite3_column_int(st, 1);
int valid = !dm_media_receipt_save(inst, r, sqlite3_column_blob(st, 0), (size_t)sqlite3_column_bytes(st, 0));
sqlite3_finalize(st);
if (!valid || dm_custody_complete(inst, r + 40, policy) != 1) return;
dm_custody_remove(inst->dm_media, r + 40);
return;
}
sqlite3_finalize(st);
if (rc != SQLITE_DONE) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: delete lookup failed rc=%d", rc); return; }
st = dm_media_prepare(inst, "SELECT body_hash FROM dm_custody_done WHERE id=?");
if (!st) return;
sqlite3_bind_blob(st, 1, r + 40, 16, SQLITE_STATIC);
rc = sqlite3_step(st);
int exact = rc == SQLITE_ROW && sqlite3_column_bytes(st, 0) == 32 && !memcmp(sqlite3_column_blob(st, 0), r + 56, 32);
sqlite3_finalize(st);
if (exact) {
uint8_t ack[32]; memcpy(ack, assignment, 16); memcpy(ack + 16, r + 40, 16);
dm_media_send(inst, group, source, DM_MEDIA_DELETE_ACK, ack, sizeof(ack));
}
else if (rc == SQLITE_DONE) DEBUG_DEBUG(DEBUG_CATEGORY_DM, "DM custody: fact before STORE; S retains it for retry");
else DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: delete conflicts with retired artifact rc=%d", rc);
}
/* OFFER не меняет идентичность беседы. Чужой holder допустим только от S. */
static void dm_media_offer(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t source, const uint8_t* p, size_t len) {
if (len <= 24) { DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: short OFFER"); return; }
uint64_t recipient, holder, expiry, author;
memcpy(&recipient, p, 8); memcpy(&holder, p + 8, 8); memcpy(&expiry, p + 16, 8);
p += 24; len -= 24;
const uint8_t* desc = dm_message_media(p, len);
if (recipient != inst->node_id || !desc || dm_verify_message(inst, recipient, p, len)) return;
memcpy(&author, p + 24, 8);
if (!holder || (source == author && (holder != author || expiry)) ||
(source != author && !dm_peer_role(inst, source, "supernode"))) {
DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: unauthorized OFFER source=%llu holder=%llu",
(unsigned long long)source, (unsigned long long)holder); return;
}
uint8_t receipt[DM_RECEIPT_SIZE];
if (dm_accept_message(inst, p, len, receipt)) return;
dm_mailbox_receipt(inst, receipt);
uint8_t file_receipt[DM_MEDIA_RECEIPT_SIZE];
int deleted = dm_history_deleted(inst, p, len, 0, file_receipt);
if (deleted) {
if (deleted > 0 && !memcmp(file_receipt, "DMFILE01", 8)) {
dm_media_send(inst, group, author, DM_MEDIA_RECEIPT, file_receipt, sizeof(file_receipt));
dm_mailbox_media_receipt(inst, file_receipt);
}
return;
}
sqlite3_stmt* st = dm_media_prepare(inst,
"INSERT INTO dm_file_sources(id,node,expires) VALUES(?,?,?) ON CONFLICT(id,node) DO NOTHING");
if (!st) return;
sqlite3_bind_blob(st, 1, desc, 16, SQLITE_STATIC); sqlite3_bind_int64(st, 2, (sqlite3_int64)holder);
sqlite3_bind_int64(st, 3, (sqlite3_int64)expiry);
if (dm_media_step(inst, st)) return;
st = dm_media_prepare(inst, "SELECT expires FROM dm_file_sources WHERE id=? AND node=?");
if (!st) return;
sqlite3_bind_blob(st, 1, desc, 16, SQLITE_STATIC); sqlite3_bind_int64(st, 2, (sqlite3_int64)holder);
int rc = sqlite3_step(st);
int current = rc == SQLITE_ROW && sqlite3_column_int64(st, 0) >= 0 && (uint64_t)sqlite3_column_int64(st, 0) == expiry;
int final = sqlite3_finalize(st);
if (!current || final != SQLITE_OK) {
DEBUG_WARN(DEBUG_CATEGORY_DM, "DM source: stale/conflicting OFFER holder=%llu expiry=%llu rc=%d final=%d",
(unsigned long long)holder, (unsigned long long)expiry, rc, final); return;
}
st = dm_media_prepare(inst, "UPDATE dm_files SET holder=?,expires=?,retry_at=0 WHERE id=? AND ready<=0");
if (!st) return;
sqlite3_bind_int64(st, 1, (sqlite3_int64)holder); sqlite3_bind_int64(st, 2, (sqlite3_int64)expiry);
sqlite3_bind_blob(st, 3, desc, 16, SQLITE_STATIC);
dm_media_step(inst, st);
}
/* Исчезновение одной копии не отменяет файл, другого поставщика или готовую локальную историю. */
static void dm_source_gone(struct UTUN_INSTANCE* inst, uint64_t source, const uint8_t* p) {
uint64_t holder; memcpy(&holder, p + 48, 8);
if (!holder || (source != holder && !dm_peer_role(inst, source, "supernode"))) {
DEBUG_WARN(DEBUG_CATEGORY_DM, "DM source: unauthorized removal source=%llu holder=%llu",
(unsigned long long)source, (unsigned long long)holder); return;
}
sqlite3_stmt* st = dm_media_prepare(inst, "SELECT body FROM dm_files WHERE id=?");
if (!st) return;
int rc = sqlite3_bind_blob(st, 1, p, 16, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_step(st);
uint8_t hash[32];
if (rc == SQLITE_ROW) SHA256(sqlite3_column_blob(st, 0), (size_t)sqlite3_column_bytes(st, 0), hash);
int exact = rc == SQLITE_ROW && !memcmp(hash, p + 16, 32), final = sqlite3_finalize(st);
if (!exact || final != SQLITE_OK) {
DEBUG_WARN(DEBUG_CATEGORY_DM, "DM source: removal for unknown/conflicting file rc=%d final=%d", rc, final); return;
}
if (dm_media_exec(inst, "BEGIN IMMEDIATE")) return;
st = dm_media_prepare(inst, "INSERT INTO dm_file_sources(id,node,expires) VALUES(?,?,-1)"
" ON CONFLICT(id,node) DO UPDATE SET expires=-1");
if (!st) goto rollback;
rc = sqlite3_bind_blob(st, 1, p, 16, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 2, (sqlite3_int64)holder);
if (rc != SQLITE_OK) { sqlite3_finalize(st); goto rollback; }
if (dm_media_step(inst, st)) goto rollback;
st = dm_media_prepare(inst, "UPDATE dm_files SET holder=author,expires=0,retry_at=0 WHERE id=? AND holder=? AND ready<=0");
if (!st) goto rollback;
rc = sqlite3_bind_blob(st, 1, p, 16, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 2, (sqlite3_int64)holder);
if (rc != SQLITE_OK) { sqlite3_finalize(st); goto rollback; }
if (dm_media_step(inst, st) || dm_media_exec(inst, "COMMIT")) goto rollback;
struct dm_file_probe* probe = dm_probe_find(inst->dm_media, p);
if (probe) for (unsigned i = 0; i < probe->count; i++) if (probe->sources[i].node == holder) probe->sources[i].used = 1;
DEBUG_INFO(DEBUG_CATEGORY_DM, "DM source: retired holder=%llu; other sources and local file retained", (unsigned long long)holder);
return;
rollback:
DEBUG_WARN(DEBUG_CATEGORY_DM, "DM source: removal rolled back holder=%llu rc=%d", (unsigned long long)holder, rc);
dm_media_exec(inst, "ROLLBACK");
}
/* Строгие границы control, независимые подписи DM и квитанций. */
static void dm_media_control(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t source,
uint8_t cmd, const uint8_t* p, size_t len) {
if (!inst->dm_media || inst->dm_media->closing) return;
if (cmd == DM_MEDIA_OFFER) dm_media_offer(inst, group, source, p, len);
else if ((cmd == DM_MEDIA_HAVE || cmd == DM_MEDIA_HAVE_ACK) && len == 64)
dm_probe_control(inst->dm_media, group, source, cmd == DM_MEDIA_HAVE_ACK, p);
else if (cmd == DM_MEDIA_STORE) dm_custody_store(inst, group, source, p, len);
else if (cmd == DM_MEDIA_DELETE && len == 16 + DM_MEDIA_RECEIPT_SIZE) dm_custody_delete(inst, group, source, p, p + 16);
else if (cmd == DM_MEDIA_SOURCE_GONE && len == 56) dm_source_gone(inst, source, p);
else if (cmd == DM_MEDIA_RECEIPT && len == DM_MEDIA_RECEIPT_SIZE) {
uint64_t author; memcpy(&author, p + 24, 8);
if (author == inst->node_id) {
dm_media_accept_receipt(inst, p);
} else dm_mailbox_media_recv(dm_mailbox_get_media(inst), group, source, cmd, p, len);
} else if (((cmd == DM_MEDIA_STORED || cmd == DM_MEDIA_RESERVED) && len == 41) || (cmd == DM_MEDIA_DELETE_ACK && len == 32) ||
(cmd == DM_MEDIA_EXPIRED && len == 32) || (cmd == DM_MEDIA_REJECT && len == 33)) {
dm_mailbox_media_recv(dm_mailbox_get_media(inst), group, source, cmd, p, len);
} else DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: invalid control cmd=%u bytes=%zu", cmd, len);
}
static void dm_media_recv(struct ETCP_CONN* conn, struct ll_entry* e) {
if (!conn || !e || !e->dgram || e->len <= ROUTER_SVC_PAYLOAD_OFF) goto done;
uint64_t group, source;
memcpy(&group, e->dgram + ROUTER_SVC_GROUP_OFF, 8); memcpy(&source, e->dgram + ROUTER_SVC_SRC_OFF, 8);
if (!(e->dgram[ROUTER_SVC_FLAGS_OFF] & ROUTER_FLAG_SIGNED)) {
DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: unsigned control source=%llu", (unsigned long long)source); goto done;
}
if (!topo_group_chat_allowed(conn->instance, group, source)) {
DEBUG_WARN(DEBUG_CATEGORY_DM, "DM media receive denied group=%llu peer=%llu",
(unsigned long long)group, (unsigned long long)source); goto done;
}
const uint8_t* p = e->dgram + ROUTER_SVC_PAYLOAD_OFF;
dm_media_control(conn->instance, group, source, p[0], p + 1, e->len - ROUTER_SVC_PAYLOAD_OFF - 1);
done:
if (e) { queue_dgram_free(e); queue_entry_free(e); }
}
/* Автор повторяет все полученные факты к S, остальные устройства — только свои. */
static void dm_file_forward_facts(struct dm_media_state* media, const uint8_t id[16], uint64_t author) {
struct UTUN_INSTANCE* inst = media->inst;
sqlite3_stmt* st = dm_media_prepare(inst,
"SELECT received FROM dm_file_targets WHERE id=?1 AND received IS NOT NULL AND (?2=0 OR node=?2)"
" UNION ALL SELECT taken FROM dm_file_targets WHERE id=?1 AND taken IS NOT NULL AND (?2=0 OR node=?2)");
if (!st) return;
sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC);
sqlite3_bind_int64(st, 2, author == inst->node_id ? 0 : (sqlite3_int64)inst->node_id);
uint8_t facts[2 * DM_TARGET_MAX][DM_MEDIA_RECEIPT_SIZE]; unsigned n = 0; int rc;
while ((rc = sqlite3_step(st)) == SQLITE_ROW && n < 2 * DM_TARGET_MAX) {
if (sqlite3_column_bytes(st, 0) != DM_MEDIA_RECEIPT_SIZE) { rc = SQLITE_CORRUPT; break; }
memcpy(facts[n++], sqlite3_column_blob(st, 0), DM_MEDIA_RECEIPT_SIZE);
}
sqlite3_finalize(st);
if (rc != SQLITE_DONE) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM facts: scan failed rc=%d", rc); return; }
uint64_t gid; int route = author != inst->node_id && dm_route_group(inst, author, 0, &gid);
for (unsigned i = 0; i < n; i++) {
if (route) dm_media_send(inst, gid, author, DM_MEDIA_RECEIPT, facts[i], DM_MEDIA_RECEIPT_SIZE);
dm_mailbox_media_receipt(inst, facts[i]);
}
}
/* Текст и файл имеют независимые durable retries; наличие text ACK не останавливает медиа. */
static void dm_files_tick(struct dm_media_state* media, uint64_t now) {
struct UTUN_INSTANCE* inst = media->inst;
for (int i = 0; i < 8; i++) {
sqlite3_stmt* st = dm_media_prepare(inst, "SELECT id,dir,author,body,ready,manual,first_attempt,plain_size"
" FROM dm_files WHERE retry_at<=? AND conv_id IN"
" (SELECT conv_id FROM dm_conversations WHERE closed=0) ORDER BY retry_at,seq LIMIT 1");
if (!st) return;
sqlite3_bind_int64(st, 1, (sqlite3_int64)now);
int rc = sqlite3_step(st);
if (rc == SQLITE_DONE) { sqlite3_finalize(st); break; }
if (rc != SQLITE_ROW || sqlite3_column_bytes(st, 0) != 16) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: local files scan failed rc=%d", rc); sqlite3_finalize(st); return;
}
uint8_t id[16], body[DM_MEDIA_BODY_MAX];
memcpy(id, sqlite3_column_blob(st, 0), 16);
int dir = sqlite3_column_int(st, 1), ready = sqlite3_column_int(st, 4);
uint64_t author = (uint64_t)sqlite3_column_int64(st, 2);
int manual = sqlite3_column_int(st, 5);
uint64_t first = (uint64_t)sqlite3_column_int64(st, 6), size = (uint64_t)sqlite3_column_int64(st, 7);
size_t len = (size_t)sqlite3_column_bytes(st, 3);
if (len <= sizeof(body)) memcpy(body, sqlite3_column_blob(st, 3), len);
sqlite3_finalize(st);
st = dm_media_prepare(inst, "UPDATE dm_files SET retry_at=?1,first_attempt=CASE WHEN first_attempt=0 THEN ?3 ELSE first_attempt END WHERE id=?2");
if (!st) return;
sqlite3_bind_int64(st, 1, (sqlite3_int64)(now + 5)); sqlite3_bind_int64(st, 3, (sqlite3_int64)now);
sqlite3_bind_blob(st, 2, id, 16, SQLITE_STATIC);
if (dm_media_step(inst, st)) return;
const uint8_t* desc = len <= sizeof(body) ? dm_message_media(body, len) : NULL;
if (!desc) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: corrupt saved file descriptor"); continue; }
if (dir && ready == 1) {
st = dm_media_prepare(inst, "SELECT node FROM dm_file_targets WHERE id=? AND received IS NULL ORDER BY side DESC,node");
if (!st) return;
sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC);
uint64_t nodes[DM_TARGET_MAX]; unsigned count = 0;
while ((rc = sqlite3_step(st)) == SQLITE_ROW && count < DM_TARGET_MAX) nodes[count++] = (uint64_t)sqlite3_column_int64(st, 0);
sqlite3_finalize(st);
if (rc != SQLITE_DONE) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM files: target scan failed rc=%d", rc); continue; }
for (unsigned j = 0; j < count; j++) {
uint64_t gid;
if (!dm_route_group(inst, nodes[j], 0, &gid)) continue;
uint8_t offer[24 + DM_MEDIA_BODY_MAX];
memcpy(offer, nodes + j, 8); memcpy(offer + 8, &author, 8); memset(offer + 16, 0, 8);
memcpy(offer + 24, body, len);
dm_media_send(inst, gid, nodes[j], DM_MEDIA_OFFER, offer, len + 24);
}
if (count && first && now >= first + 5) dm_mailbox_store_file(inst, nodes[0], body, len);
} else if (ready <= 0 && !dm_job_find(media, id, 0)) {
uint64_t limit = inst->config->global.chatserver_storage_unit_size;
if (manual || (chat_setting_get_int(inst, "storage_autoload", 1) && limit && size <= limit))
dm_probe_start(media, body, len, 0);
else DEBUG_DEBUG(DEBUG_CATEGORY_DM, "DM files: waiting for manual download bytes=%llu autoload=%d limit=%llu",
(unsigned long long)size, chat_setting_get_int(inst, "storage_autoload", 1), (unsigned long long)limit);
}
dm_file_forward_facts(media, id, author);
}
}
/* Периодическая уборка TTL не вытесняет подтверждённые файлы раньше их expiry. */
static void dm_custody_tick(struct dm_media_state* media, uint64_t now) {
struct UTUN_INSTANCE* inst = media->inst;
for (int i = 0; i < 8; i++) {
sqlite3_stmt* st = dm_media_prepare(inst, "SELECT id,body,author,expires,ready,delete_on_open,"
"EXISTS(SELECT 1 FROM dm_files f WHERE f.id=dm_custody.id AND f.ready=1) FROM dm_custody"
" WHERE retry_at<=? ORDER BY retry_at LIMIT 1");
if (!st) return;
sqlite3_bind_int64(st, 1, (sqlite3_int64)now);
int rc = sqlite3_step(st);
if (rc == SQLITE_DONE) { sqlite3_finalize(st); break; }
if (rc != SQLITE_ROW || sqlite3_column_bytes(st, 0) != 16) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: custody scan failed rc=%d", rc); sqlite3_finalize(st); return;
}
uint8_t id[16], body[DM_MEDIA_BODY_MAX];
memcpy(id, sqlite3_column_blob(st, 0), 16);
size_t len = (size_t)sqlite3_column_bytes(st, 1);
if (len <= sizeof(body)) memcpy(body, sqlite3_column_blob(st, 1), len);
uint64_t author = (uint64_t)sqlite3_column_int64(st, 2), expires = (uint64_t)sqlite3_column_int64(st, 3);
int ready = sqlite3_column_int(st, 4);
int policy = sqlite3_column_int(st, 5);
int local = sqlite3_column_int(st, 6);
sqlite3_finalize(st);
if (ready >= 0 && dm_custody_complete(inst, id, policy) == 1) ready = -1;
st = dm_media_prepare(inst, "UPDATE dm_custody SET retry_at=?,ready=CASE WHEN expires<=? THEN -1 ELSE ready END WHERE id=?");
if (!st) return;
sqlite3_bind_int64(st, 1, (sqlite3_int64)(now + 5)); sqlite3_bind_int64(st, 2, (sqlite3_int64)now);
sqlite3_bind_blob(st, 3, id, 16, SQLITE_STATIC);
if (dm_media_step(inst, st)) return;
if (expires <= now || ready < 0) {
dm_custody_remove(media, id);
} else if (!ready) {
const uint8_t* desc = len <= sizeof(body) ? dm_message_media(body, len) : NULL;
uint64_t gid = 0;
if (desc && local) dm_file_pull(media, 1, 0, inst->node_id, desc, author);
else if (desc && dm_route_group(inst, author, 0, &gid)) dm_file_pull(media, 1, gid, author, desc, author);
}
}
}
static void dm_media_tick(void* arg) {
struct dm_media_state* media = arg;
media->timer = NULL;
uint64_t now = ntp_time_get_seconds(media->inst);
dm_files_tick(media, now);
dm_probe_tick(media);
dm_custody_tick(media, now);
dm_cleanup_pump(media);
media->timer = uasync_set_timeout(media->inst->ua, 10000, media, dm_media_tick, "dm_media");
if (!media->timer) DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: retry timer allocation failed");
}
int dm_media_init(struct UTUN_INSTANCE* inst) {
if (!inst || !inst->dm || !inst->media_async || inst->dm_media) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: invalid initialization state"); return -1;
}
struct dm_media_state* media = u_calloc(1, sizeof(*media));
if (!media) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: owner allocation failed"); return -1; }
media->inst = inst;
const char* base = inst->config->global.db_path;
if (!base[0]) base = ".";
int n = snprintf(media->root, sizeof(media->root), "%s/dm_media", base);
int m = snprintf(media->pending, sizeof(media->pending), "%s/dm_pending", base);
if (n < 0 || (size_t)n >= sizeof(media->root) || m < 0 || (size_t)m >= sizeof(media->pending) ||
(utun_mkdir(media->root, 0700) && errno != EEXIST) || (utun_mkdir(media->pending, 0700) && errno != EEXIST)) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: cannot create file roots: %s", strerror(errno)); goto fail;
}
if (dm_media_exec(inst, "UPDATE dm_files SET downloading=0,retry_at=0") || dm_media_exec(inst, "UPDATE dm_custody SET retry_at=0") ||
dm_media_targets_restore(inst)) goto fail;
inst->dm_media = media;
if (!file_transfer_create(inst, dm_file_lookup, media) || etcp_router_bind(inst, ETCP_RT_ID_DM_MEDIA, dm_media_recv)) goto fail;
if (etcp_router_require_crypto(inst, ETCP_RT_ID_DM_MEDIA, ROUTER_FLAG_SIGNED) ||
etcp_router_set_authorize(inst, ETCP_RT_ID_DM_MEDIA, topo_group_chat_allowed)) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM media router policy setup failed"); goto fail;
}
media->timer = uasync_set_timeout(inst->ua, 10000, media, dm_media_tick, "dm_media");
if (!media->timer) goto fail;
DEBUG_INFO(DEBUG_CATEGORY_DM, "dm_media: initialized root=%s custody=%s ttl=%ds quota=%dMiB", media->root, media->pending,
chat_setting_get_int(inst, "dm_media_ttl_sec", 604800), chat_setting_get_int(inst, "dm_media_storage_mb", 1024));
return 0;
fail:
DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: initialization failed");
if (inst->dm_media) dm_media_destroy(inst); else u_free(media);
return -1;
}
/* Снять producers до ожидания workers; callbacks отмены ещё имеют живого owner. */
void dm_media_quiesce(struct UTUN_INSTANCE* inst) {
struct dm_media_state* media = inst ? inst->dm_media : NULL;
if (!media || media->closing) return;
media->closing = 1;
if (media->timer) uasync_cancel_timeout(inst->ua, media->timer);
media->timer = NULL;
etcp_router_unbind(inst, ETCP_RT_ID_DM_MEDIA);
file_transfer_destroy(inst->file_transfer);
while (media->probes) { struct dm_file_probe* p = media->probes; media->probes = p->next; u_free(p); }
DEBUG_INFO(DEBUG_CATEGORY_DM, "dm_media: quiesced, waiting for workers");
}
void dm_media_destroy(struct UTUN_INSTANCE* inst) {
if (!inst || !inst->dm_media) return;
dm_media_quiesce(inst);
struct dm_media_state* media = inst->dm_media;
if (media->jobs || media->cleanup_active) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: destroy before media_async workers drained");
return;
}
inst->dm_media = NULL;
DEBUG_INFO(DEBUG_CATEGORY_DM, "dm_media: destroyed, durable files/jobs retained");
u_free(media);
}