diff --git a/doc/member_files.txt b/doc/member_files.txt new file mode 100644 index 00000000..3f23f9c4 --- /dev/null +++ b/doc/member_files.txt @@ -0,0 +1,176 @@ +Файлы участников CHAT-группы +=========================== + +Модуль: src/member_files/. Desktop-браузер: tools/chatgui/src/member_files/. +Отдельный сервис router ETCP_RT_ID_MEMBER_FILES (0x39), отдельно от вложений +сообщений media_delivery и E2E-файлов DM. Серверы выбираются явно; между +серверными деревьями автоматической репликации нет. + +Хранение и конфигурация +---------------------- + +Корень по умолчанию: /member_files/ (без db_path — ./member_files/). +Публичное дерево: <корень>///<относительный путь>. +Временные файлы: <корень>/.uploads/. +Корень создаётся лениво по первому разрешённому файловому запросу. +Очистка кэша вложений media/ это дерево не обходит. + +Настройки [chatserver] / [chat]: + member_files_enabled=1 + member_files_quota_mb=1024 + member_files_upload_ttl_sec=3600 + member_files_path=/путь/к/хранилищу (опционально, до 255 байт UTF-8) + +Путь, квота и TTL читаются при старте чат-сервиса; изменение требует его restart. +enabled проверяется на каждом запросе. Квота общая для дерева всех групп, +включает опубликованные файлы и полный конечный размер временных загрузок. +Замена требует места одновременно для прежнего файла и нового. +Нехватка квоты/места возвращает NO_SPACE; опубликованные файлы не вытесняются. + +Сервер — действующий полный мембер группы с подписанным adm_tags.storage="yes". +Колонка peers.storage — производный сетевой приоритет, для полномочий не используется. +Каждый запрос и ответ подписаны Ed25519 и зашифрованы end-to-end средствами +route_crypto. group/src берутся только из проверенного заголовка router; +ETCP peer промежуточного хопа не считается автором запроса. +Читать могут действующие участники той же группы. Изменять дерево может только +src == owner. Удалённые записи и source=1 (плейсхолдеры) доступа не дают. +Открытый fd/UUID загрузки не отменяют повторную проверку членства. +Файлы удалённого участника сохраняются на диске, но через сервис не доступны. + +Wire, версия 1 +-------------- + +Все числа LE, без сырых структур и padding. Фиксированный заголовок 125 байт: + 0 protocol_version:u8 = 1 + 1 kind:u8 (1=request, 2=response) + 2 command:u8 + 3 status:u16 (request=0) + 5 request_id:u64, ненулевой случайный ID + 13 owner:u64 (UID каталога; 0 только для LIST виртуального корня группы) + 21 offset:u64 + 29 size:u64 + 37 cursor:u64 + 45 version:16 + 61 upload:16 + 77 hash:32 + 109 mtime:u64, Unix seconds + 117 type:u8 (1=file, 2=directory) + 118 more:u8 (0/1) + 119 path_len:u16 + 121 target_len:u16 + 123 data_len:u16 +После заголовка: path[path_len], target[target_len], data[data_len], без NUL. +Длина пакета обязана точно совпадать с указанными размерами. +Путь максимум 1024 байта, компонент максимум 255 байт, максимум 60 компонентов. +Данные максимум 8192 байт; вместе с полным router-путём и crypto укладываются +в лимит normalizer 16384 байта. Размер файла/смещение ограничены INT64_MAX. +Пути — строгий UTF-8, относительные, разделитель '/'. Запрещены пустые компоненты, +компоненты с ведущей '.', завершающей точкой/пробелом, управляющие символы, +Windows device names и символы \ : * ? " < > |. Корень внутри UID — пустой путь. +POSIX разрешает путь через *at/O_NOFOLLOW; regular-файлы с hardlink отвергаются. +Windows удерживает родительские каталоги без SHARE_DELETE и не принимает reparse points. + +Команды (1..10) +--------------- + +1 LIST: owner/path/cursor/version. Первая страница cursor=0, version=0. + Ответ data — записи: name_len:u16,type:u8,size:u64,mtime:u64,version:16,name. + cursor — сколько записей уже выдано. more=1 требует следующую страницу с + возвращёнными cursor/version. При изменении снимка — STALE, начать сначала. + Размер каталога не ограничивает память страницы. Виртуальный корень группы + выдаёт UID действующих полных участников, даже если каталог ещё не создан. + Изменение namespace сервера консервативно инвалидирует дисковые LIST-курсоры. + +2 STAT: owner/path. Ответ type/size/mtime/version. Не хеширует весь файл. + version — непрозрачный снимок identity/size/mtime/ctime и эпохи сервера. + Закрытие fd по таймауту токен не меняет; restart меняет дисковые токены. + +3 READ: owner/path/version/offset/size (1..8192). + Ответ size — полный размер файла, data_len — фактически прочитано, + more — есть ли байты после ответа. offset==EOF возвращает 0 байт/more=0; + offset>EOF — INVALID_RANGE. Изменение файла между блоками — STALE. + Клиент не смешивает версии и публикует локальный файл только по завершении. + +4 BEGIN_WRITE: owner/path/upload/size/hash/version. + upload — 16 случайных байт, size — конечный размер, hash — SHA256 содержимого. + version=0 означает создать новый файл, существующий возвращает EXISTS. + Для замены требуется версия из STAT/LIST. В один путь одна активная загрузка. + UUID привязан к group/src/owner/path; повтор BEGIN с теми же параметрами безопасен. + +5 WRITE: owner/path/upload/offset/size/data. size==data_len, диапазон внутри + конечного размера. Можно писать в произвольном порядке и повторять блок. + ACK подтверждает запись в temp, а не durable публикацию всего файла. + +6 COMMIT: owner/path/upload. Проверяет SHA256, fsync/_commit и ожидаемую + версию назначения, затем атомарно заменяет файл и синхронизирует namespace. + Прежний файл виден до публикации. Ответ содержит размер/версию/SHA256. + Незаписанные sparse-области читаются как нули; неправильное содержимое не + проходит SHA256. Пустые файлы поддерживаются. При I/O-ошибке во время + публикации результат может быть неопределённым; нужно повторно проверить STAT. + +7 ABORT: owner/path/upload. Удаляет temp, освобождает резервацию. + Неизвестный UUID — OK; опубликованные файлы никогда не удаляет. + +8 MKDIR: owner/path. Создаёт один каталог; родители должны существовать. +9 RENAME: owner/path/target/version. Только внутри UID/группы, без замены + существующего назначения. Активная загрузка в затрагиваемом пути — BUSY. +10 REMOVE: owner/path/version. Файл или пустой каталог, без рекурсивного удаления. + +Статусы (0..13): OK, ACCESS_DENIED, NOT_FOUND, INVALID_PATH, INVALID_RANGE, +EXISTS, BUSY, STALE, NO_SPACE, IO_ERROR, BAD_PACKET, HASH_MISMATCH, CANCELLED, TIMEOUT. +Мутации COMMIT/MKDIR/RENAME/REMOVE сохраняют успешный ответ на 120 секунд +(64 квитанции); тот же request_id с другим содержимым — BAD_PACKET. +COMMIT дополнительно узнаётся по upload UUID. При заполнении квитанций новая +мутация возвращает BUSY до истечения старых. Клиент повторяет запрос с прежним +request_id раз в 5 секунд, общий срок 120 секунд. Повторы после этого срока +не дают гарантии того же результата; перед новой мутацией требуется STAT. + +Потоки, таймауты и остановка +--------------------------- + +На экземпляр один дисковый pthread с очередью до 64 обычных задач. Сеть, +ACL, таймеры и state machines выполняются в uasync; open/stat/readdir/read/write, +resize/hash/sync/rename/remove/close — в дисковом потоке. GUI только передаёт +команды через bridge и получает события. Нет вызова дискового I/O из callback +таймера. Completion резервируется до постановки задачи; без polling workers. +Локальные файлы transfer и серверное дерево используют тот же дисковый поток. +Для обязательной очистки резервируется отдельная задача до открытия файла. +Обращение к своему серверу доставляется через отдельную ограниченную очередь +call_soon в тот же обработчик, без сетевого канала. ACL и дисковый поток общие; +сетевой recv не принимает unsigned даже при src == self. + +Сервер держит до 64 открытых файлов. Каждый READ/WRITE/BEGIN продлевает idle. +После 10 секунд простоя fd закрывается на очередном дисковом sweep (таймер 1 с); +занятый дисковый поток откладывает sweep до освобождения. Закрытие не публикует +и не удаляет файл. Temp можно открыть заново до истечения upload TTL. +После restart незавершённые temp удаляются; опубликованные файлы остаются. + +Лимиты: 16 клиентских запросов, 32 серверных задания/ответа, 16 временных +загрузок, 4 общих transfer. Router-очередь обслуживается через backpressure. +Transfer владеет CM handle до окончания/отмены; DOWN/TIMEOUT handle не освобождает. +quiesce запрещает новые команды, отменяет таймеры, waiter и запросы; destroy +вне callbacks дожидается только своих работ/completions и закрывает дисковый +поток до уничтожения CHAT-групп. Контексты не освобождаются под работающим I/O. +Отмена не прерывает текущий системный I/O или хеширование, а ждёт его completion. + +Desktop +------- + +Контекстное меню канала: Group files. Меню участника: Member files. +Окно: выбор сервера/UID, путь, Up/Refresh, таблица каталогов/файлов, +Upload/Download/New folder/Rename/Delete, прогресс и Cancel transfer. +Чужой UID доступен только для чтения. Автор может быть offline. +Open скачивает в выбранный пользователем локальный файл и открывает его +приложением ОС после завершения. Смена контекста отбрасывает поздние ответы. + +Проверки и диагностика +--------------------- + +tests/test_member_files: wire/UTF-8, произвольные блоки, повторы, EOF, версии, +короткая замена, SHA256, пустой файл, квота, ссылки, expiry/restart, 10-секундный +fd idle и принадлежность work/done потокам. +tests/test_member_files_network: настоящие A/B/M, подписанная storage-роль, +upload/download, побайтная проверка, чужая запись, виртуальный корень, +подписанное удаление читателя. test_services — stop/start общего чат-сервиса. +Логи: media=debug для операций/диапазонов/ACL/fd; info — публикации и transfer, +warn/error — отказы с группой, UID, путём, смещением и точной причиной. diff --git a/src/Makefile.am b/src/Makefile.am index bd3b6b71..99ad2351 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -66,6 +66,18 @@ utun_CORE_SOURCES = \ media_delivery/media_index.c \ media_delivery/file_transfer.c \ media_delivery/file_transfer.h \ + member_files/protocol.c \ + member_files/protocol.h \ + member_files/disk.c \ + member_files/disk.h \ + member_files/storage.c \ + member_files/storage.h \ + member_files/member_files.c \ + member_files/member_files.h \ + member_files/private.h \ + member_files/transfer.c \ + member_files/worker.c \ + member_files/worker.h \ media_async/media_async.c \ media_async/attachment.c \ media_async/attachment.h \ @@ -187,6 +199,18 @@ libutun_a_SOURCES = \ media_delivery/media_index.c \ media_delivery/file_transfer.c \ media_delivery/file_transfer.h \ + member_files/protocol.c \ + member_files/protocol.h \ + member_files/disk.c \ + member_files/disk.h \ + member_files/storage.c \ + member_files/storage.h \ + member_files/member_files.c \ + member_files/member_files.h \ + member_files/private.h \ + member_files/transfer.c \ + member_files/worker.c \ + member_files/worker.h \ media_async/media_async.c \ media_async/attachment.c \ media_async/attachment.h \ diff --git a/src/chat/chat_channel.c b/src/chat/chat_channel.c index 89785daf..b5296c9e 100644 --- a/src/chat/chat_channel.c +++ b/src/chat/chat_channel.c @@ -18,6 +18,7 @@ #include "../transport_layer/secure_channel.h" #include "../media_delivery/media_delivery.h" #include "../media_delivery/media_index.h" +#include "../member_files/member_files.h" #include "../../lib/mem.h" #include "../../lib/ll_queue.h" #include "../../lib/platform_compat.h" @@ -338,6 +339,7 @@ void chat_core_delete_channel(struct UTUN_INSTANCE* inst, const char* ch_id) { /* 1. отмена медиа-загрузок/релэев/суперузлов канала (держат conn_mgr handle группы) */ media_delivery_cancel_channel(inst, gid); + member_files_cancel_group(inst, gid); /* 2. снять merkle-сессии этого namespace */ member_sync_cancel_channel(inst, ch_id); diff --git a/src/chat/chat_event.h b/src/chat/chat_event.h index c258b76c..6c758de9 100644 --- a/src/chat/chat_event.h +++ b/src/chat/chat_event.h @@ -70,6 +70,8 @@ extern "C" { #define CHAT_EVT_DM_CALL_ROUTE 47 /* [conv_id_len:1][conv_id:var][peer_node_id:8][group_id:8], 0 = нет BGP-пути */ #define CHAT_EVT_DM_INVITE_RESULT 48 /* [request_id:8][result:4][peer_node_id:8][conv_id:8], dm_invite_result */ +#define CHAT_EVT_MEMBER_FILES_RESPONSE 49 /* [group:8LE][server:8LE][mf wire response] */ +#define CHAT_EVT_MEMBER_FILES_TRANSFER 50 /* id/group/server/owner/done/total:8LE,status:2LE,state:1 */ typedef void (*chat_event_handler_fn)(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len); diff --git a/src/chat/chat_setting.c b/src/chat/chat_setting.c index a61f5f7f..b70a5a32 100644 --- a/src/chat/chat_setting.c +++ b/src/chat/chat_setting.c @@ -62,9 +62,14 @@ static const struct chat_setting_def g_setting_defs[] = { {"call_jitter_max_reserve_ms", CHAT_SETTING_INT, 1000, 500, 2000}, {"call_compressor_enabled", CHAT_SETTING_BOOL, 1, 0, 1}, {"call_compressor_max_gain_db",CHAT_SETTING_INT, 15, 1, 60}, + {"member_files_enabled", CHAT_SETTING_BOOL, 1, 0, 1}, + {"member_files_path", CHAT_SETTING_STRING, 0, 0, 0}, + {"member_files_quota_mb", CHAT_SETTING_INT, 1024, 1, 1048576}, + {"member_files_upload_ttl_sec", CHAT_SETTING_INT, 3600, 60, 604800}, }; #define CHAT_SETTING_COUNT (sizeof(g_setting_defs) / sizeof(g_setting_defs[0])) +typedef char chat_setting_capacity_check[CHAT_SETTING_COUNT <= CHAT_SETTING_MAX_SETTINGS ? 1 : -1]; /* ─── state helpers ─── */ @@ -103,6 +108,10 @@ int chat_setting_set_state(struct chat_setting_state* s, const char* name, const int v; if (def->type == CHAT_SETTING_STRING) { + if (strlen(value) >= sizeof(s->string_val[0])) { + DEBUG_ERROR(CS_ID, "chat_setting: '%s' string too long bytes=%zu limit=%zu", name, strlen(value), sizeof(s->string_val[0]) - 1); + return -2; + } for (int i = 0; i < s->string_count; i++) { if (s->string_idx[i] == idx) { if (strcmp(s->string_val[i], value) == 0) return 0; diff --git a/src/chat/chat_setting.h b/src/chat/chat_setting.h index 2a29f0bd..00078e51 100644 --- a/src/chat/chat_setting.h +++ b/src/chat/chat_setting.h @@ -23,7 +23,7 @@ extern "C" { /* ─── per-instance хранилище ─── */ -#define CHAT_SETTING_MAX_SETTINGS 32 +#define CHAT_SETTING_MAX_SETTINGS 48 #define CHAT_SETTING_STRING_MAX 16 struct chat_setting_state { diff --git a/src/member_files/disk.c b/src/member_files/disk.c new file mode 100644 index 00000000..528d93c3 --- /dev/null +++ b/src/member_files/disk.c @@ -0,0 +1,401 @@ +#ifndef _WIN32 +#define _FILE_OFFSET_BITS 64 +#endif +#include "disk.h" +#include "../../lib/debug_config.h" +#include +#include +#include +#include +#include +#include +#ifndef _WIN32 +#include +#endif + +static int disk_error(const char* operation, const char* path) { + int error = errno; + int status = error == ENOENT ? MF_NOT_FOUND : error == EEXIST ? MF_EXISTS : + error == ENOSPC ? MF_NO_SPACE : error == ENOTDIR ? MF_INVALID_PATH : MF_IO_ERROR; +#ifdef EDQUOT + if (error == EDQUOT) status = MF_NO_SPACE; +#endif +#ifdef ELOOP + if (error == ELOOP) status = MF_INVALID_PATH; +#endif + if (status == MF_NOT_FOUND || status == MF_EXISTS) + DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "member_files: disk %s path=%s status=%s", operation, path ? path : "", mf_status_text(status)); + else DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: disk %s path=%s errno=%d (%s)", operation, path ? path : "", error, strerror(error)); + return status; +} + +#ifdef _WIN32 +struct disk_parent { HANDLE held[66]; unsigned count; wchar_t path[4096]; }; + +static int disk_wide(const char* path, wchar_t* wide, size_t cap) { + if (!MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, path, -1, wide, (int)cap)) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: invalid UTF-8 path winerr=%lu", GetLastError()); return MF_INVALID_PATH; + } + return MF_OK; +} + +static int disk_win_error(const char* operation) { + DWORD e = GetLastError(); + if (e == ERROR_FILE_NOT_FOUND || e == ERROR_PATH_NOT_FOUND || e == ERROR_ALREADY_EXISTS || e == ERROR_FILE_EXISTS) + DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "member_files: %s winerr=%lu", operation, e); + else DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: %s winerr=%lu", operation, e); + if (e == ERROR_FILE_NOT_FOUND || e == ERROR_PATH_NOT_FOUND) return MF_NOT_FOUND; + if (e == ERROR_ALREADY_EXISTS || e == ERROR_FILE_EXISTS) return MF_EXISTS; + if (e == ERROR_DISK_FULL || e == ERROR_HANDLE_DISK_FULL) return MF_NO_SPACE; + return MF_IO_ERROR; +} + +static int disk_handle_info(HANDLE h, struct mf_disk_info* out) { + BY_HANDLE_FILE_INFORMATION i; + if (!GetFileInformationByHandle(h, &i)) return disk_win_error("GetFileInformationByHandle"); + if ((i.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) || i.nNumberOfLinks > 1) return MF_INVALID_PATH; + memset(out, 0, sizeof(*out)); + out->type = i.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY ? MF_DIRECTORY : MF_FILE; + out->size = out->type == MF_FILE ? ((uint64_t)i.nFileSizeHigh << 32) | i.nFileSizeLow : 0; + uint64_t written = ((uint64_t)i.ftLastWriteTime.dwHighDateTime << 32) | i.ftLastWriteTime.dwLowDateTime; + out->mtime = written > 116444736000000000ULL ? (written - 116444736000000000ULL) / 10000000 : 0; + out->identity[0] = i.dwVolumeSerialNumber; + out->identity[1] = ((uint64_t)i.nFileIndexHigh << 32) | i.nFileIndexLow; + out->identity[2] = written; + out->identity[3] = ((uint64_t)i.ftCreationTime.dwHighDateTime << 32) | i.ftCreationTime.dwLowDateTime; + out->identity[4] = out->size; + return MF_OK; +} + +static void disk_parent_close(struct disk_parent* p) { + while (p->count) if (!CloseHandle(p->held[--p->count])) disk_win_error("close directory"); +} + +/* Каждый родитель закреплён без SHARE_DELETE до завершения операции над листом. */ +static int disk_parent_open(struct mf_disk_root* root, const char* path, struct disk_parent* p) { + memset(p, 0, sizeof(*p)); + char full[4096]; + int n = snprintf(full, sizeof(full), "%s/%s", root->path, path); + if (n < 0 || (size_t)n >= sizeof(full) || disk_wide(full, p->path, 4096)) return MF_INVALID_PATH; + size_t begin = strlen(root->path) + 1; + char relative[MF_PATH_MAX + 80]; + if (strlen(path) >= sizeof(relative)) return MF_INVALID_PATH; + snprintf(relative, sizeof(relative), "%s", path); + for (char* s = relative; *s; s++) { + if (*s != '/') continue; + size_t at = begin + (size_t)(s - relative); + char saved = full[at]; full[at] = 0; + wchar_t dir[4096]; + int result = disk_wide(full, dir, 4096); full[at] = saved; + if (result || p->count >= 66) { disk_parent_close(p); return MF_INVALID_PATH; } + HANDLE h = CreateFileW(dir, FILE_READ_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + if (h == INVALID_HANDLE_VALUE) { result = disk_win_error("open parent"); disk_parent_close(p); return result; } + p->held[p->count++] = h; + struct mf_disk_info info; + result = disk_handle_info(h, &info); + if (result || info.type != MF_DIRECTORY) { disk_parent_close(p); return result ? result : MF_INVALID_PATH; } + } + return MF_OK; +} + +int mf_disk_root_open(struct mf_disk_root* root, const char* path) { + wchar_t wide[4096]; + if (strlen(path) >= sizeof(root->path) || disk_wide(path, wide, 4096)) return MF_INVALID_PATH; + if (!CreateDirectoryW(wide, NULL) && GetLastError() != ERROR_ALREADY_EXISTS) return disk_win_error("create root"); + root->handle = CreateFileW(wide, FILE_READ_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + if (root->handle == INVALID_HANDLE_VALUE) return disk_win_error("open root"); + struct mf_disk_info info; + int rc = disk_handle_info(root->handle, &info); + if (rc || info.type != MF_DIRECTORY) { CloseHandle(root->handle); return rc ? rc : MF_INVALID_PATH; } + snprintf(root->path, sizeof(root->path), "%s", path); + return MF_OK; +} + +void mf_disk_root_close(struct mf_disk_root* root) { + if (!CloseHandle(root->handle)) disk_win_error("close root"); +} + +int mf_disk_open(struct mf_disk_root* root, const char* path, int create, int writable, int* fd) { + struct disk_parent p; int rc = disk_parent_open(root, path, &p); + if (rc) return rc; + HANDLE h = CreateFileW(p.path, GENERIC_READ | (writable ? GENERIC_WRITE : 0), FILE_SHARE_READ, NULL, + create ? CREATE_NEW : OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, NULL); + if (h == INVALID_HANDLE_VALUE) rc = disk_win_error("open file"); + else { + struct mf_disk_info i; rc = disk_handle_info(h, &i); + if (!rc && i.type != MF_FILE) rc = MF_INVALID_PATH; + if (!rc) { + *fd = _open_osfhandle((intptr_t)h, _O_BINARY | (writable ? _O_RDWR : _O_RDONLY)); + if (*fd < 0) rc = disk_error("open fd", path); + } + if (rc) CloseHandle(h); + } + disk_parent_close(&p); return rc; +} + +int mf_disk_stat(struct mf_disk_root* root, const char* path, struct mf_disk_info* info) { + struct disk_parent p; int rc = disk_parent_open(root, path, &p); + if (rc) return rc; + HANDLE h = CreateFileW(p.path, FILE_READ_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, NULL); + if (h == INVALID_HANDLE_VALUE) rc = disk_win_error("stat"); + else { rc = disk_handle_info(h, info); CloseHandle(h); } + disk_parent_close(&p); return rc; +} + +int mf_disk_info(int fd, struct mf_disk_info* info) { return disk_handle_info((HANDLE)_get_osfhandle(fd), info); } + +int mf_disk_mkdir(struct mf_disk_root* root, const char* path) { + struct disk_parent p; int rc = disk_parent_open(root, path, &p); + if (rc) return rc; + if (!CreateDirectoryW(p.path, NULL)) rc = disk_win_error("mkdir"); + disk_parent_close(&p); return rc; +} + +int mf_disk_remove(struct mf_disk_root* root, const char* path, int directory) { + struct disk_parent p; int rc = disk_parent_open(root, path, &p); + if (rc) return rc; + if (!(directory ? RemoveDirectoryW(p.path) : DeleteFileW(p.path))) rc = disk_win_error("remove"); + disk_parent_close(&p); return rc; +} + +int mf_disk_rename(struct mf_disk_root* root, const char* from, const char* to) { + struct disk_parent a, b; int rc = disk_parent_open(root, from, &a); + if (rc) return rc; + rc = disk_parent_open(root, to, &b); + if (!rc) { + if (!MoveFileExW(a.path, b.path, MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) rc = disk_win_error("publish/rename"); + disk_parent_close(&b); + } + disk_parent_close(&a); return rc; +} + +int mf_disk_list(struct mf_disk_root* root, const char* path, mf_disk_entry_fn cb, void* arg) { + char probe[MF_PATH_MAX + 100]; snprintf(probe, sizeof(probe), "%s%s_", path, *path ? "/" : ""); + struct disk_parent p; int rc = disk_parent_open(root, probe, &p); + if (rc) return rc; + size_t n = wcslen(p.path); p.path[n - 1] = L'*'; + WIN32_FIND_DATAW data; + HANDLE find = FindFirstFileW(p.path, &data); + if (find == INVALID_HANDLE_VALUE) { + rc = GetLastError() == ERROR_FILE_NOT_FOUND ? MF_OK : disk_win_error("list"); + disk_parent_close(&p); return rc; + } + for (;;) { + if (!wcscmp(data.cFileName, L".") || !wcscmp(data.cFileName, L"..")) goto next; + char name[MF_NAME_MAX * 4 + 1], child[MF_PATH_MAX + 100]; + if (!WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, data.cFileName, -1, name, sizeof(name), NULL, NULL)) { + rc = MF_INVALID_PATH; break; + } + if (snprintf(child, sizeof(child), "%s/%s", path, name) >= (int)sizeof(child)) { rc = MF_INVALID_PATH; break; } + struct mf_disk_info info; rc = mf_disk_stat(root, child, &info); + if (rc) break; + int result = cb(arg, name, &info); + if (result) { rc = result < 0 ? -result : MF_OK; break; } +next: + if (!FindNextFileW(find, &data)) { + if (GetLastError() != ERROR_NO_MORE_FILES) rc = disk_win_error("FindNextFileW"); + break; + } + } + FindClose(find); disk_parent_close(&p); return rc; +} + +int mf_disk_resize(int fd, uint64_t size) { + if (_chsize_s(fd, size)) return disk_error("resize", NULL); + return MF_OK; +} +int mf_disk_sync(int fd) { return _commit(fd) ? disk_error("sync", NULL) : MF_OK; } +int mf_disk_close(int fd) { return _close(fd) ? disk_error("close", NULL) : MF_OK; } + +int mf_disk_local_open(const char* path, int create, int* fd) { + wchar_t wide[4096]; if (disk_wide(path, wide, 4096)) return MF_INVALID_PATH; + *fd = _wopen(wide, _O_BINARY | (create ? _O_RDWR | _O_CREAT | _O_EXCL : _O_RDONLY), _S_IREAD | _S_IWRITE); + return *fd < 0 ? disk_error("local open", path) : MF_OK; +} +int mf_disk_local_publish(const char* from, const char* to) { + wchar_t a[4096], b[4096]; if (disk_wide(from, a, 4096) || disk_wide(to, b, 4096)) return MF_INVALID_PATH; + return MoveFileExW(a, b, MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) ? MF_OK : disk_win_error("local publish"); +} +int mf_disk_local_remove(const char* path) { + wchar_t wide[4096]; if (disk_wide(path, wide, 4096)) return MF_INVALID_PATH; + if (DeleteFileW(wide) || GetLastError() == ERROR_FILE_NOT_FOUND) return MF_OK; + return disk_win_error("local remove"); +} +#else +struct disk_parent { int fd; char leaf[MF_NAME_MAX + 1]; }; + +static int disk_parent_open(struct mf_disk_root* root, const char* path, struct disk_parent* p) { + char copy[MF_PATH_MAX + 80]; + if (strlen(path) >= sizeof(copy)) return MF_INVALID_PATH; + snprintf(copy, sizeof(copy), "%s", path); + p->fd = dup(root->fd); + if (p->fd < 0) return disk_error("dup root", path); + char* component = copy; + for (char* slash; (slash = strchr(component, '/')); component = slash + 1) { + *slash = 0; + int next = openat(p->fd, component, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + close(p->fd); + if (next < 0) return disk_error("open parent", path); + p->fd = next; + } + if (strlen(component) > MF_NAME_MAX) { close(p->fd); return MF_INVALID_PATH; } + snprintf(p->leaf, sizeof(p->leaf), "%s", component); + return MF_OK; +} + +static int disk_posix_info(const struct stat* st, struct mf_disk_info* i) { + if ((!S_ISREG(st->st_mode) && !S_ISDIR(st->st_mode)) || (S_ISREG(st->st_mode) && st->st_nlink != 1)) return MF_INVALID_PATH; + memset(i, 0, sizeof(*i)); i->type = S_ISDIR(st->st_mode) ? MF_DIRECTORY : MF_FILE; + i->size = i->type == MF_FILE ? (uint64_t)st->st_size : 0; i->mtime = (uint64_t)st->st_mtime; + i->identity[0] = st->st_dev; i->identity[1] = st->st_ino; + i->identity[2] = st->st_mtim.tv_sec; i->identity[3] = st->st_mtim.tv_nsec; + i->identity[4] = st->st_ctim.tv_sec; i->identity[5] = st->st_ctim.tv_nsec; + return MF_OK; +} + +int mf_disk_root_open(struct mf_disk_root* root, const char* path) { + if (strlen(path) >= sizeof(root->path)) return MF_INVALID_PATH; + if (mkdir(path, 0700) && errno != EEXIST) return disk_error("create root", path); + root->fd = open(path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (root->fd < 0) return disk_error("open root", path); + snprintf(root->path, sizeof(root->path), "%s", path); return MF_OK; +} +void mf_disk_root_close(struct mf_disk_root* root) { if (close(root->fd)) disk_error("close root", root->path); } + +int mf_disk_open(struct mf_disk_root* root, const char* path, int create, int writable, int* fd) { + struct disk_parent p; int rc = disk_parent_open(root, path, &p); + if (rc) return rc; + *fd = openat(p.fd, p.leaf, O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK | (writable ? O_RDWR : O_RDONLY) | + (create ? O_CREAT | O_EXCL : 0), 0600); + if (*fd < 0) rc = disk_error("open file", path); + else { + struct mf_disk_info info; rc = mf_disk_info(*fd, &info); + if (!rc && info.type != MF_FILE) rc = MF_INVALID_PATH; + if (rc) { close(*fd); *fd = -1; } + } + close(p.fd); return rc; +} +int mf_disk_stat(struct mf_disk_root* root, const char* path, struct mf_disk_info* info) { + struct disk_parent p; int rc = disk_parent_open(root, path, &p); + if (rc) return rc; + struct stat st; + if (fstatat(p.fd, p.leaf[0] ? p.leaf : ".", &st, AT_SYMLINK_NOFOLLOW)) rc = disk_error("stat", path); + else rc = disk_posix_info(&st, info); + close(p.fd); return rc; +} +int mf_disk_info(int fd, struct mf_disk_info* info) { + struct stat st; + return fstat(fd, &st) ? disk_error("fstat", NULL) : disk_posix_info(&st, info); +} +int mf_disk_mkdir(struct mf_disk_root* root, const char* path) { + struct disk_parent p; int rc = disk_parent_open(root, path, &p); + if (rc) return rc; + if (mkdirat(p.fd, p.leaf, 0700)) rc = disk_error("mkdir", path); + else if (fsync(p.fd)) rc = disk_error("sync mkdir", path); + close(p.fd); return rc; +} +int mf_disk_remove(struct mf_disk_root* root, const char* path, int directory) { + struct disk_parent p; int rc = disk_parent_open(root, path, &p); + if (rc) return rc; + if (unlinkat(p.fd, p.leaf, directory ? AT_REMOVEDIR : 0)) rc = disk_error("remove", path); + else if (fsync(p.fd)) rc = disk_error("sync remove", path); + close(p.fd); return rc; +} +int mf_disk_rename(struct mf_disk_root* root, const char* from, const char* to) { + struct disk_parent a, b; int rc = disk_parent_open(root, from, &a); + if (rc) return rc; + rc = disk_parent_open(root, to, &b); + if (!rc) { + if (renameat(a.fd, a.leaf, b.fd, b.leaf)) rc = disk_error("rename", to); + else if (fsync(b.fd) || fsync(a.fd)) rc = disk_error("sync rename", to); + close(b.fd); + } + close(a.fd); return rc; +} +int mf_disk_list(struct mf_disk_root* root, const char* path, mf_disk_entry_fn cb, void* arg) { + char probe[MF_PATH_MAX + 100]; + if (snprintf(probe, sizeof(probe), "%s%s_", path, *path ? "/" : "") >= (int)sizeof(probe)) return MF_INVALID_PATH; + struct disk_parent p; int rc = disk_parent_open(root, probe, &p); + if (rc) return rc; + DIR* dir = fdopendir(p.fd); + if (!dir) { rc = disk_error("list", path); close(p.fd); return rc; } + struct dirent* e; + for (;;) { + errno = 0; e = readdir(dir); + if (!e) { if (errno) rc = disk_error("readdir", path); break; } + if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, "..")) continue; + struct stat st; struct mf_disk_info info; + if (fstatat(dirfd(dir), e->d_name, &st, AT_SYMLINK_NOFOLLOW)) { rc = disk_error("list stat", e->d_name); break; } + rc = disk_posix_info(&st, &info); + if (rc) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: unsafe directory entry path=%s name=%s", path, e->d_name); break; } + int result = cb(arg, e->d_name, &info); + if (result) { rc = result < 0 ? -result : MF_OK; break; } + } + if (closedir(dir) && !rc) rc = disk_error("closedir", path); + return rc; +} +int mf_disk_resize(int fd, uint64_t size) { return ftruncate(fd, (off_t)size) ? disk_error("resize", NULL) : MF_OK; } +int mf_disk_sync(int fd) { return fsync(fd) ? disk_error("sync", NULL) : MF_OK; } +int mf_disk_close(int fd) { return close(fd) ? disk_error("close", NULL) : MF_OK; } +int mf_disk_local_open(const char* path, int create, int* fd) { + *fd = open(path, O_CLOEXEC | O_NOFOLLOW | (create ? O_RDWR | O_CREAT | O_EXCL : O_RDONLY), 0600); + return *fd < 0 ? disk_error("local open", path) : MF_OK; +} +int mf_disk_local_publish(const char* from, const char* to) { return rename(from, to) ? disk_error("local publish", to) : MF_OK; } +int mf_disk_local_remove(const char* path) { return !unlink(path) || errno == ENOENT ? MF_OK : disk_error("local remove", path); } +#endif + +int mf_disk_read(int fd, uint64_t offset, void* data, size_t size) { + size_t done = 0; + while (done < size) { +#ifdef _WIN32 + if (_lseeki64(fd, (int64_t)(offset + done), SEEK_SET) < 0) return disk_error("seek read", NULL); + int n = _read(fd, (uint8_t*)data + done, (unsigned)(size - done)); +#else + ssize_t n = pread(fd, (uint8_t*)data + done, size - done, (off_t)(offset + done)); +#endif + if (n < 0 && errno == EINTR) continue; + if (n <= 0) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: short/read error offset=%llu size=%zu done=%zu errno=%d", + (unsigned long long)offset, size, done, errno); return MF_IO_ERROR; } + done += (size_t)n; + } + return MF_OK; +} +int mf_disk_write(int fd, uint64_t offset, const void* data, size_t size) { + size_t done = 0; + while (done < size) { +#ifdef _WIN32 + if (_lseeki64(fd, (int64_t)(offset + done), SEEK_SET) < 0) return disk_error("seek write", NULL); + int n = _write(fd, (const uint8_t*)data + done, (unsigned)(size - done)); +#else + ssize_t n = pwrite(fd, (const uint8_t*)data + done, size - done, (off_t)(offset + done)); +#endif + if (n < 0 && errno == EINTR) continue; + if (n <= 0) return disk_error("write", NULL); + done += (size_t)n; + } + return MF_OK; +} + +/* Полное хеширование выполняется только worker, с ограниченным буфером. */ +int mf_disk_hash(int fd, uint64_t size, uint8_t hash[32]) { + EVP_MD_CTX* ctx = EVP_MD_CTX_new(); + int rc = MF_IO_ERROR; + if (!ctx || EVP_DigestInit_ex(ctx, EVP_sha256(), NULL) != 1) goto out; + uint8_t buffer[65536]; + for (uint64_t off = 0; off < size;) { + size_t n = size - off < sizeof(buffer) ? (size_t)(size - off) : sizeof(buffer); + rc = mf_disk_read(fd, off, buffer, n); + if (rc || EVP_DigestUpdate(ctx, buffer, n) != 1) { rc = MF_IO_ERROR; goto out; } + off += n; + } + unsigned n = 0; + rc = EVP_DigestFinal_ex(ctx, hash, &n) == 1 && n == 32 ? MF_OK : MF_IO_ERROR; +out: + EVP_MD_CTX_free(ctx); + if (rc) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: hash failed size=%llu status=%d", (unsigned long long)size, rc); + return rc; +} diff --git a/src/member_files/disk.h b/src/member_files/disk.h new file mode 100644 index 00000000..58291e83 --- /dev/null +++ b/src/member_files/disk.h @@ -0,0 +1,38 @@ +/* Примитивы диска для storage и клиентского worker. На POSIX пути разрешаются + * относительно открытого каталога через *at/O_NOFOLLOW. Windows удерживает каталоги + * без FILE_SHARE_DELETE и отвергает reparse points; имена преобразуются UTF-8→UTF-16. + * fd принадлежит вызывающему; read/write работают по 64-битному смещению. */ +#ifndef MEMBER_FILES_DISK_H +#define MEMBER_FILES_DISK_H +#include "protocol.h" +#include "../../lib/platform_compat.h" + +struct mf_disk_info { uint64_t size, mtime, identity[6]; uint8_t type; }; +struct mf_disk_root { + char path[2048]; +#ifdef _WIN32 + HANDLE handle; +#else + int fd; +#endif +}; +int mf_disk_root_open(struct mf_disk_root* root, const char* path); +void mf_disk_root_close(struct mf_disk_root* root); +int mf_disk_open(struct mf_disk_root* root, const char* path, int create, int writable, int* fd); +int mf_disk_stat(struct mf_disk_root* root, const char* path, struct mf_disk_info* info); +int mf_disk_info(int fd, struct mf_disk_info* info); +int mf_disk_mkdir(struct mf_disk_root* root, const char* path); +int mf_disk_remove(struct mf_disk_root* root, const char* path, int directory); +int mf_disk_rename(struct mf_disk_root* root, const char* from, const char* to); +typedef int (*mf_disk_entry_fn)(void* arg, const char* name, const struct mf_disk_info* info); +int mf_disk_list(struct mf_disk_root* root, const char* path, mf_disk_entry_fn cb, void* arg); +int mf_disk_read(int fd, uint64_t offset, void* data, size_t size); +int mf_disk_write(int fd, uint64_t offset, const void* data, size_t size); +int mf_disk_resize(int fd, uint64_t size); +int mf_disk_sync(int fd); +int mf_disk_close(int fd); +int mf_disk_local_open(const char* path, int create, int* fd); +int mf_disk_local_publish(const char* from, const char* to); +int mf_disk_local_remove(const char* path); +int mf_disk_hash(int fd, uint64_t size, uint8_t hash[32]); +#endif diff --git a/src/member_files/member_files.c b/src/member_files/member_files.c new file mode 100644 index 00000000..ec114eab --- /dev/null +++ b/src/member_files/member_files.c @@ -0,0 +1,480 @@ +#include "private.h" +#include "../utun_instance.h" +#include "../chat/chat_event.h" +#include "../chat/chat_setting.h" +#include "../routing_layer/etcp_router.h" +#include "../routing_layer/conn_mgr.h" +#include "../routing_layer/topo_group.h" +#include "../transport_layer/etcp.h" +#include "../../lib/mem.h" +#include "../../lib/debug_config.h" +#include "../../lib/json_flat.h" +#include +#include +#include +#include + +#define MF_MAX_PENDING 16 +#define MF_MAX_JOBS 32 +#define MF_REQUEST_TB 1200000ULL + +struct mf_pending { + struct mf_pending* next; + struct member_files* files; + uint64_t group, server, started, sent; + struct mf_message request; + mf_response_fn callback; + void* arg; + void* pump; + struct CONN_MGR_HANDLE* cm; + struct queue_waiter_handle waiter; +}; +struct mf_job { + struct mf_job* next; + struct member_files* files; + uint64_t group, peer, created; + struct mf_message request, response; + struct queue_waiter_handle waiter; + int state; /* 0 queued, 1 worker, 2 reply; command=0 — sweep */ +}; +struct mf_local { + struct mf_local* next; + struct member_files* files; + uint64_t group; + struct mf_message message; + void* task; +}; + +static void mf_server_pump(struct member_files* f); +static void mf_client_ready(struct ll_queue* q, void* arg); +static void mf_dispatch(struct member_files* f, uint64_t group, uint64_t peer, const struct mf_message* message); + +static void mf_local_free(struct mf_local* local) { + struct member_files* f = local->files; + if (local->task) uasync_call_soon_cancel(f->inst->ua, local->task); + struct mf_local** link = &f->locals; + while (*link && *link != local) link = &(*link)->next; + if (*link) *link = local->next; + f->local_count--; u_free(local); +} + +/* Локальная доставка всегда отложена: ответ не освобождает pending внутри send. */ +static void mf_local_deliver(void* arg) { + struct mf_local* local = arg; local->task = NULL; + struct member_files* f = local->files; + struct mf_local** link = &f->locals; + while (*link && *link != local) link = &(*link)->next; + if (*link) *link = local->next; + f->local_count--; + if (!f->closing) mf_dispatch(f, local->group, f->inst->node_id, &local->message); + u_free(local); +} + +int member_files_member(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t node, int storage) { + struct TOPO_GROUP* g = inst && inst->topo_groups ? topo_groups_find(inst->topo_groups, group) : NULL; + if (!g || g->group_type != TOPO_GROUP_TYPE_CHAT || !node || !inst->topo_sqlite_db) { + DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "member_files: ACL missing CHAT group=%llu node=%llu role=%d", + (unsigned long long)group, (unsigned long long)node, storage); return 0; + } + char sql[256]; + snprintf(sql, sizeof(sql), "SELECT adm_tags,source,adm_tags_sig,deleted FROM \"peers_%llu\" WHERE node_id=?", + (unsigned long long)group); + sqlite3_stmt* st = NULL; + if (sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL) != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: membership query failed group=%llu: %s", + (unsigned long long)group, sqlite3_errmsg(inst->topo_sqlite_db)); return 0; + } + sqlite3_bind_int64(st, 1, (sqlite3_int64)node); + int rc = sqlite3_step(st); + char role[16]; const char* tags = (const char*)sqlite3_column_text(st, 0); + int storage_role = tags && json_flat_get(tags, "storage", role, sizeof(role)) == 0 && !strcmp(role, "yes"); + int allowed = rc == SQLITE_ROW && !sqlite3_column_int(st, 1) && !sqlite3_column_int(st, 3) && + (!storage || (storage_role && sqlite3_column_bytes(st, 2) == 64)); + if (!allowed) DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, + "member_files: ACL denied group=%llu node=%llu require_storage=%d row=%d storage=%d source=%d signature=%d deleted=%d", + (unsigned long long)group, (unsigned long long)node, storage, rc == SQLITE_ROW, + storage_role, sqlite3_column_int(st, 1), sqlite3_column_bytes(st, 2), sqlite3_column_int(st, 3)); + if (rc != SQLITE_ROW && rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: membership step failed rc=%d", rc); + sqlite3_finalize(st); return allowed; +} + +static int mf_server_allowed(struct member_files* f, uint64_t group, uint64_t peer, const struct mf_message* q) { + return chat_setting_get_int(f->inst, "member_files_enabled", 1) && + member_files_member(f->inst, group, f->inst->node_id, 1) && member_files_member(f->inst, group, peer, 0) && + (!q->owner || member_files_member(f->inst, group, q->owner, 0)) && + (q->command < MF_BEGIN_WRITE || (q->owner && q->owner == peer)); +} + +/* Router подтверждает доставку отдельно; здесь отправка всегда соблюдает порог очереди. */ +static int mf_send(struct member_files* f, uint64_t group, uint64_t peer, const struct mf_message* m) { + if (peer == f->inst->node_id) { + if (f->local_count >= MF_MAX_JOBS) return 1; + struct mf_local* local = u_calloc(1, sizeof(*local)); + if (!local) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: local delivery allocation failed"); return -1; } + local->files = f; local->group = group; local->message = *m; + local->task = uasync_call_soon(f->inst->ua, local, mf_local_deliver); + if (!local->task) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: local delivery scheduling failed"); u_free(local); return -1; } + local->next = f->locals; f->locals = local; f->local_count++; return 0; + } + struct ETCP_ROUTER_CONN* rc = etcp_router_conn_get(f->inst, group, peer, ETCP_RT_ID_MEMBER_FILES); + if (!rc) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: router allocation failed"); return -1; } + queue_set_threshold(rc->send_q, 0, 0); etcp_router_set_max_inflight(rc, 16); + if (!etcp_router_send_q_has_room(f->inst, group, peer, ETCP_RT_ID_MEMBER_FILES)) return 1; + uint8_t wire[MF_WIRE_MAX]; + int n = mf_encode(m, wire, sizeof(wire)); + if (n < 0) return -1; + int result = etcp_router_conn_send(rc, wire, (size_t)n, ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED); + if (result) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: send failed group=%llu peer=%llu cmd=%u rc=%d", + (unsigned long long)group, (unsigned long long)peer, m->command, result); + return result ? -1 : 0; +} + +static void mf_pending_finish(struct mf_pending* p, const struct mf_message* response, int status) { + struct member_files* f = p->files; + if (p->pump) uasync_call_soon_cancel(f->inst->ua, p->pump); + etcp_router_cancel_send_ready(f->inst, p->group, p->server, ETCP_RT_ID_MEMBER_FILES, &p->waiter); + if (p->cm) conn_mgr_close(p->cm); + struct mf_pending** link = &f->pending; + while (*link && *link != p) link = &(*link)->next; + if (*link) *link = p->next; + f->pending_count--; + struct mf_message result; + if (!response) { result = p->request; result.kind = MF_RESPONSE; result.status = (uint16_t)status; result.data_len = 0; response = &result; } + mf_response_fn cb = p->callback; void* arg = p->arg; + DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "member_files: request done id=%llu cmd=%u status=%s pending=%u", + (unsigned long long)p->request.request_id, p->request.command, mf_status_text(response->status), f->pending_count); + u_free(p); cb(arg, response); +} + +static void mf_pending_pump(void* arg) { + struct mf_pending* p = arg; p->pump = NULL; + if (p->files->closing) return; + int rc = mf_send(p->files, p->group, p->server, &p->request); + if (!rc) p->sent = get_time_tb(); + else if (rc > 0) etcp_router_on_send_ready(p->files->inst, p->group, p->server, ETCP_RT_ID_MEMBER_FILES, + &p->waiter, mf_client_ready, p); +} + +static void mf_pending_wake(struct mf_pending* p) { + if (p->pump || p->files->closing) return; + p->pump = uasync_call_soon(p->files->inst->ua, p, mf_pending_pump); + if (!p->pump) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: client pump allocation failed"); +} + +static void mf_client_ready(struct ll_queue* q, void* arg) { (void)q; mf_pending_wake(arg); } + +static void mf_connected(struct CONN_MGR_HANDLE* h, uint64_t peer, uint64_t group, enum conn_mgr_event event, void* arg) { + (void)h; (void)peer; (void)group; + struct mf_pending* p = arg; + DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "member_files: CM event=%d request=%llu", event, (unsigned long long)p->request.request_id); + if (event == CONN_EVENT_UP) mf_pending_wake(p); +} + +struct mf_pending* member_files_request(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t server, + const struct mf_message* request, mf_response_fn cb, void* arg) { + struct member_files* f = inst ? inst->member_files : NULL; + if (request && (request->command < MF_LIST || request->command > MF_REMOVE || request->status || + request->data_len > MF_BLOCK_SIZE || !mf_path_valid(request->path, request->command == MF_LIST || request->command == MF_STAT) || + (request->command == MF_RENAME && !mf_path_valid(request->target, 0)))) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: invalid client command=%u status=%u data=%u", + request->command, request->status, request->data_len); return NULL; + } + if (!f || f->closing || !request || !cb || f->pending_count >= MF_MAX_PENDING || + !member_files_member(inst, group, inst->node_id, 0) || !member_files_member(inst, group, server, 1)) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: client request denied group=%llu server=%llu", + (unsigned long long)group, (unsigned long long)server); return NULL; + } + struct mf_pending* p = u_calloc(1, sizeof(*p)); + if (!p) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: client allocation failed"); return NULL; } + p->files = f; p->group = group; p->server = server; p->request = *request; p->request.kind = MF_REQUEST; + p->callback = cb; p->arg = arg; p->started = get_time_tb(); + if (!p->request.request_id && (RAND_bytes((uint8_t*)&p->request.request_id, 8) != 1 || !p->request.request_id)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: request random failed"); u_free(p); return NULL; + } + for (struct mf_pending* other = f->pending; other; other = other->next) if (other->request.request_id == p->request.request_id) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: duplicate local request id"); u_free(p); return NULL; + } + p->next = f->pending; f->pending = p; f->pending_count++; + if (server != inst->node_id && conn_mgr_open(inst, group, server, mf_connected, p, &p->cm) != 0) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: CM launch failed server=%llu", (unsigned long long)server); + if (p->pump) uasync_call_soon_cancel(inst->ua, p->pump); + f->pending = p->next; f->pending_count--; u_free(p); return NULL; + } + mf_pending_wake(p); + return p; +} + +void member_files_cancel(struct mf_pending* p) { if (p) mf_pending_finish(p, NULL, MF_CANCELLED); } + +void member_files_cancel_request_id(struct UTUN_INSTANCE* inst, uint64_t id) { + struct member_files* f = inst ? inst->member_files : NULL; + if (f) for (struct mf_pending* p = f->pending; p; p = p->next) if (p->request.request_id == id) { + member_files_cancel(p); break; + } +} + +static void mf_job_free(struct mf_job* j) { + struct member_files* f = j->files; + etcp_router_cancel_send_ready(f->inst, j->group, j->peer, ETCP_RT_ID_MEMBER_FILES, &j->waiter); + struct mf_job** link = &f->jobs; + while (*link && *link != j) link = &(*link)->next; + if (*link) *link = j->next; + f->job_count--; u_free(j); +} + +static void mf_server_deferred(void* arg) { + struct member_files* f = arg; f->pump = NULL; mf_server_pump(f); +} +static void mf_server_ready(struct ll_queue* q, void* arg) { + (void)q; + struct member_files* f = arg; + if (!f->pump && !f->closing) { + f->pump = uasync_call_soon(f->inst->ua, f, mf_server_deferred); + if (!f->pump) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: server pump allocation failed"); + } +} + +static void mf_server_work(void* arg) { + struct mf_job* j = arg; + if (j->request.command) mf_storage_execute(j->files->storage, j->group, j->peer, &j->request, &j->response); + else mf_storage_sweep(j->files->storage, get_time_tb()); +} + +static void mf_server_done(void* arg, int error) { + struct mf_job* j = arg; + struct member_files* f = j->files; f->worker = NULL; + if (f->closing || !j->request.command) { mf_job_free(j); if (!f->closing) mf_server_pump(f); return; } + if (error) { j->response = j->request; j->response.kind = MF_RESPONSE; j->response.status = MF_BUSY; j->response.data_len = 0; } + j->state = 2; mf_server_pump(f); +} + +/* Виртуальный корень — действующие участники, в том числе ещё без файлов на диске. */ +static void mf_roots(struct mf_job* j) { + struct mf_message* r = &j->response; + *r = j->request; r->kind = MF_RESPONSE; r->type = MF_DIRECTORY; r->data_len = 0; r->more = 0; + if (*r->path || r->command != MF_LIST) { r->status = MF_INVALID_PATH; return; } + char sql[256]; + snprintf(sql, sizeof(sql), "SELECT node_id,join_ts,update_ts,adm_tags FROM \"peers_%llu\" WHERE deleted=0 AND source=0 ORDER BY node_id", + (unsigned long long)j->group); + sqlite3_stmt* st = NULL; EVP_MD_CTX* hash = EVP_MD_CTX_new(); + if (!hash || EVP_DigestInit_ex(hash, EVP_sha256(), NULL) != 1 || + sqlite3_prepare_v2(j->files->inst->topo_sqlite_db, sql, -1, &st, NULL) != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: virtual root query/hash failed"); r->status = MF_IO_ERROR; goto done; + } + uint64_t seen = 0; int rc; + while ((rc = sqlite3_step(st)) == SQLITE_ROW) { + uint8_t metadata[24]; + uint64_t id = (uint64_t)sqlite3_column_int64(st, 0); + mf_put64(metadata, id); mf_put64(metadata + 8, (uint64_t)sqlite3_column_int64(st, 1)); + mf_put64(metadata + 16, (uint64_t)sqlite3_column_int64(st, 2)); + const char* tags = (const char*)sqlite3_column_text(st, 3); + if (EVP_DigestUpdate(hash, metadata, sizeof(metadata)) != 1 || + (tags && EVP_DigestUpdate(hash, tags, strlen(tags) + 1) != 1)) { r->status = MF_IO_ERROR; goto done; } + if (seen++ < j->request.cursor || r->more) continue; + char name[32]; int n = snprintf(name, sizeof(name), "%llu", (unsigned long long)id); + if (r->data_len + MF_ENTRY_HEADER + (unsigned)n > MF_BLOCK_SIZE) { r->more = 1; continue; } + uint8_t* p = r->data + r->data_len; + memset(p, 0, MF_ENTRY_HEADER); p[0] = (uint8_t)n; p[2] = MF_DIRECTORY; + memcpy(p + MF_ENTRY_HEADER, name, (size_t)n); r->data_len += MF_ENTRY_HEADER + (uint32_t)n; r->cursor++; + } + uint8_t digest[32]; unsigned n; + if (rc != SQLITE_DONE || EVP_DigestFinal_ex(hash, digest, &n) != 1 || n != 32) { r->status = MF_IO_ERROR; goto done; } + memcpy(r->version, digest, 16); + if ((j->request.cursor && mf_zero(j->request.version, 16)) || + (!mf_zero(j->request.version, 16) && memcmp(j->request.version, r->version, 16))) r->status = MF_STALE; +done: + sqlite3_finalize(st); EVP_MD_CTX_free(hash); + if (r->status) r->data_len = 0; +} + +/* Один disk worker: I/O, sweep и namespace-мутации никогда не пересекаются. */ +static void mf_server_pump(struct member_files* f) { + if (f->closing) return; + struct mf_job* j = f->jobs; + while (j) { + struct mf_job* next = j->next; + if (j->state == 2 && !j->waiter.internal) { + if (j->response.status == MF_OK && !mf_server_allowed(f, j->group, j->peer, &j->request)) { + j->response.status = MF_ACCESS_DENIED; j->response.data_len = 0; + } + int result = mf_send(f, j->group, j->peer, &j->response); + if (result <= 0) mf_job_free(j); + else etcp_router_on_send_ready(f->inst, j->group, j->peer, ETCP_RT_ID_MEMBER_FILES, &j->waiter, mf_server_ready, f); + } + j = next; + } + if (f->worker) return; + for (j = f->jobs; j; j = j->next) if (!j->state) break; + if (!j && f->sweep_due && f->job_count < MF_MAX_JOBS) { + j = u_calloc(1, sizeof(*j)); + if (!j) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: sweep allocation failed"); return; } + j->files = f; j->next = f->jobs; f->jobs = j; f->job_count++; f->sweep_due = 0; + } + if (!j) return; + if (j->request.command && !mf_server_allowed(f, j->group, j->peer, &j->request)) { + j->response = j->request; j->response.kind = MF_RESPONSE; j->response.status = MF_ACCESS_DENIED; + j->response.data_len = 0; j->state = 2; mf_server_pump(f); return; + } + j->state = 1; f->worker = j; + mf_worker_submit(f->disk, mf_server_work, j, mf_server_done, j); +} + +static void mf_tick(void* arg) { + struct member_files* f = arg; f->tick = NULL; + uint64_t now = get_time_tb(); + for (;;) { + struct mf_pending* expired = NULL; + for (struct mf_pending* p = f->pending; p; p = p->next) if (now - p->started >= MF_REQUEST_TB) { expired = p; break; } + if (!expired) break; + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: timeout id=%llu cmd=%u", + (unsigned long long)expired->request.request_id, expired->request.command); + mf_pending_finish(expired, NULL, MF_TIMEOUT); + } + for (struct mf_pending* p = f->pending; p; p = p->next) + if (!p->sent || now - p->sent >= 50000) mf_pending_wake(p); + struct mf_job* j = f->jobs; + while (j) { + struct mf_job* next = j->next; + if (j != f->worker && j->created && now - j->created >= MF_REQUEST_TB) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: server reply/queue timeout peer=%llu", (unsigned long long)j->peer); mf_job_free(j); + } + j = next; + } + f->sweep_due = f->used_storage; mf_server_pump(f); + f->tick = uasync_set_timeout(f->inst->ua, 10000, f, mf_tick, "member_files"); + if (!f->tick) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: timer allocation failed"); +} + +/* Общая обработка проверенного сетевого сообщения или команды внутри экземпляра. */ +static void mf_dispatch(struct member_files* f, uint64_t group, uint64_t peer, const struct mf_message* message) { + struct mf_message m = *message; + if (m.kind == MF_RESPONSE) { + for (struct mf_pending* p = f->pending; p; p = p->next) { + if (p->group != group || p->server != peer || p->request.request_id != m.request_id) continue; + if (p->request.command != m.command || p->request.owner != m.owner || strcmp(p->request.path, m.path)) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: mismatched response id=%llu", (unsigned long long)m.request_id); return; + } + if (!member_files_member(f->inst, group, peer, 1)) { m.status = MF_ACCESS_DENIED; m.data_len = 0; } + mf_pending_finish(p, &m, 0); break; + } + return; + } + for (struct mf_job* other = f->jobs; other; other = other->next) if (other->group == group && other->peer == peer && + other->request.request_id == m.request_id) return; + if (f->job_count >= MF_MAX_JOBS) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: server queue full peer=%llu", (unsigned long long)peer); return; + } + struct mf_job* j = u_calloc(1, sizeof(*j)); + if (!j) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: server job allocation failed"); return; } + j->files = f; j->group = group; j->peer = peer; j->created = get_time_tb(); j->request = m; + struct mf_job** tail = &f->jobs; while (*tail) tail = &(*tail)->next; + *tail = j; f->job_count++; + if (!mf_server_allowed(f, group, peer, &m)) { + j->response = m; j->response.kind = MF_RESPONSE; j->response.status = MF_ACCESS_DENIED; j->response.data_len = 0; j->state = 2; + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: ACL denied group=%llu peer=%llu owner=%llu cmd=%u", + (unsigned long long)group, (unsigned long long)peer, (unsigned long long)m.owner, m.command); + } else if (!m.owner) { mf_roots(j); j->state = 2; } + else f->used_storage = 1; + mf_server_pump(f); +} + +static void mf_recv(struct ETCP_CONN* conn, struct ll_entry* e) { + struct member_files* f = conn ? conn->instance->member_files : NULL; + if (!f || f->closing || !e || !e->dgram || e->len <= ROUTER_SVC_PAYLOAD_OFF) goto out; + uint64_t group, peer; + memcpy(&group, e->dgram + ROUTER_SVC_GROUP_OFF, 8); memcpy(&peer, e->dgram + ROUTER_SVC_SRC_OFF, 8); + if ((e->dgram[ROUTER_SVC_FLAGS_OFF] & (ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED)) != + (ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED)) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: unsigned/unencrypted packet peer=%llu", (unsigned long long)peer); goto out; + } + struct mf_message m; + if (!mf_decode(e->dgram + ROUTER_SVC_PAYLOAD_OFF, e->len - ROUTER_SVC_PAYLOAD_OFF, &m)) mf_dispatch(f, group, peer, &m); +out: + if (e) { queue_dgram_free(e); queue_entry_free(e); } +} + +int member_files_init(struct UTUN_INSTANCE* inst) { + if (!inst || inst->member_files) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: invalid init"); return -1; } + struct member_files* f = u_calloc(1, sizeof(*f)); + if (!f) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: owner allocation failed"); return -1; } + f->inst = inst; + f->disk = mf_worker_create(inst->ua); f->cleanup = mf_worker_reserve(); + if (!f->disk || !f->cleanup) { + mf_worker_destroy(f->disk); mf_worker_unreserve(f->cleanup); u_free(f); return -1; + } + char path[2048]; const char* custom = chat_setting_get(inst, "member_files_path"); + if (custom && !*custom) custom = NULL; + snprintf(path, sizeof(path), "%s%s%s", custom ? custom : inst->config->global.db_path, custom ? "" : "/", custom ? "" : "member_files"); + if (!custom && !*inst->config->global.db_path) snprintf(path, sizeof(path), "member_files"); + f->storage = mf_storage_create(path, (uint64_t)chat_setting_get_int(inst, "member_files_quota_mb", 1024) * 1024 * 1024, + (uint64_t)chat_setting_get_int(inst, "member_files_upload_ttl_sec", 3600) * 10000); + if (!f->storage || etcp_router_bind(inst, ETCP_RT_ID_MEMBER_FILES, mf_recv)) { + mf_storage_destroy(f->storage); mf_worker_destroy(f->disk); mf_worker_unreserve(f->cleanup); u_free(f); return -1; + } + inst->member_files = f; + f->tick = uasync_set_timeout(inst->ua, 10000, f, mf_tick, "member_files"); + if (!f->tick) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: init timer failed"); member_files_destroy(inst); return -1; } + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "member_files: initialized root=%s idle=10s max_requests=%d max_jobs=%d", path, MF_MAX_PENDING, MF_MAX_JOBS); + return 0; +} + +void member_files_quiesce(struct UTUN_INSTANCE* inst) { + struct member_files* f = inst ? inst->member_files : NULL; + if (!f || f->closing) return; + f->closing = 1; + if (f->pump) uasync_call_soon_cancel(inst->ua, f->pump); + f->pump = NULL; + if (f->tick) uasync_cancel_timeout(inst->ua, f->tick); + f->tick = NULL; + mf_transfers_quiesce(f, 0); + while (f->pending) member_files_cancel(f->pending); + while (f->locals) mf_local_free(f->locals); + struct mf_job* j = f->jobs; + while (j) { struct mf_job* next = j->next; if (j != f->worker) mf_job_free(j); j = next; } + etcp_router_unbind(inst, ETCP_RT_ID_MEMBER_FILES); + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "member_files: quiesce workers=%d transfers=%u", f->worker != NULL, f->transfer_count); +} + +static void mf_storage_cleanup(void* arg) { mf_storage_destroy(arg); } +static void mf_cleanup_done(void* arg, int error) { (void)arg; (void)error; } + +void member_files_destroy(struct UTUN_INSTANCE* inst) { + struct member_files* f = inst ? inst->member_files : NULL; + if (!f) return; + member_files_quiesce(inst); + mf_worker_submit_reserved(f->disk, f->cleanup, mf_storage_cleanup, f->storage, mf_cleanup_done, NULL); + f->cleanup = NULL; + mf_worker_destroy(f->disk); + u_free(f); inst->member_files = NULL; +} + +void member_files_cancel_group(struct UTUN_INSTANCE* inst, uint64_t group) { + struct member_files* f = inst ? inst->member_files : NULL; + if (!f) return; + mf_transfers_quiesce(f, group); + struct mf_local* local = f->locals; + while (local) { struct mf_local* next = local->next; if (local->group == group) mf_local_free(local); local = next; } + for (;;) { + struct mf_pending* found = NULL; + for (struct mf_pending* p = f->pending; p; p = p->next) if (p->group == group) { found = p; break; } + if (!found) break; + member_files_cancel(found); + } + struct mf_job* j = f->jobs; + while (j) { struct mf_job* next = j->next; if (j->group == group && j != f->worker) mf_job_free(j); j = next; } +} + +static void mf_gui_done(void* arg, const struct mf_message* response) { + struct mf_gui_request* q = arg; + uint8_t event[16 + MF_WIRE_MAX]; mf_put64(event, q->group); mf_put64(event + 8, q->server); + int n = mf_encode(response, event + 16, MF_WIRE_MAX); + if (n > 0) chat_event_post(q->inst, CHAT_EVT_MEMBER_FILES_RESPONSE, event, n + 16); + u_free(q); +} + +void member_files_gui_request(void* arg) { + struct mf_gui_request* q = arg; + if (member_files_request(q->inst, q->group, q->server, &q->message, mf_gui_done, q)) return; + q->message.kind = MF_RESPONSE; q->message.status = MF_ACCESS_DENIED; q->message.data_len = 0; + mf_gui_done(q, &q->message); +} diff --git a/src/member_files/member_files.h b/src/member_files/member_files.h new file mode 100644 index 00000000..0d26907e --- /dev/null +++ b/src/member_files/member_files.h @@ -0,0 +1,45 @@ +/* Хранилище участников и асинхронный клиент поверх подписанного/зашифрованного router. + * Все API — uasync. callback получает заимствованный response ровно один раз; операция + * уже освобождена. NULL при отказе запуска, callback не вызывается. request копируется. + * quiesce запрещает новые команды и отменяет producers; destroy вне callbacks ждёт + * собственный дисковый поток до удаления CHAT-групп. Cancel не удаляет опубликованные файлы. + * GUI-команды используют trampolines; результат — CHAT_EVT_MEMBER_FILES_RESPONSE/TRANSFER. */ +#ifndef MEMBER_FILES_H +#define MEMBER_FILES_H +#include "protocol.h" +#ifdef __cplusplus +extern "C" { +#endif +struct UTUN_INSTANCE; +struct mf_pending; +typedef void (*mf_response_fn)(void* arg, const struct mf_message* response); +int member_files_init(struct UTUN_INSTANCE* inst); +void member_files_quiesce(struct UTUN_INSTANCE* inst); +void member_files_destroy(struct UTUN_INSTANCE* inst); +void member_files_cancel_group(struct UTUN_INSTANCE* inst, uint64_t group); +struct mf_pending* member_files_request(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t server, + const struct mf_message* request, mf_response_fn callback, void* arg); +void member_files_cancel(struct mf_pending* pending); +void member_files_cancel_request_id(struct UTUN_INSTANCE* inst, uint64_t id); +/* 1 — действующий мембер; storage!=0 дополнительно требует подписанную роль хранителя. */ +int member_files_member(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t node, int storage); + +struct mf_gui_request { struct UTUN_INSTANCE* inst; uint64_t group, server; struct mf_message message; }; +void member_files_gui_request(void* arg); + +struct mf_transfer_request { + struct UTUN_INSTANCE* inst; + uint64_t id, group, server, owner; + int upload; + uint8_t version[16]; + char remote[MF_PATH_MAX + 1], local[MF_PATH_MAX + 1]; +}; +/* id выбирает вызывающий. События: id/group/server/owner/done/total:8LE, status:2LE,state:1; + * state=1 progress, 2 finished. Загрузка/скачивание выполняет общий клиент, без Qt I/O. */ +void member_files_gui_transfer(void* arg); +struct mf_cancel_request { struct UTUN_INSTANCE* inst; uint64_t id; }; +void member_files_gui_cancel(void* arg); +#ifdef __cplusplus +} +#endif +#endif diff --git a/src/member_files/private.h b/src/member_files/private.h new file mode 100644 index 00000000..96cae114 --- /dev/null +++ b/src/member_files/private.h @@ -0,0 +1,28 @@ +/* Внутреннее состояние owner, только для member_files/; storage принадлежит единственному worker. */ +#ifndef MEMBER_FILES_PRIVATE_H +#define MEMBER_FILES_PRIVATE_H +#include "member_files.h" +#include "storage.h" +#include "worker.h" +#include "../../lib/ll_queue.h" +struct mf_job; +struct mf_transfer; +struct mf_local; +struct member_files { + struct UTUN_INSTANCE* inst; + struct mf_storage* storage; + struct mf_worker* disk; + struct mf_work* cleanup; + struct mf_pending* pending; + struct mf_job* jobs; + struct mf_job* worker; + struct mf_transfer* transfers; + struct mf_local* locals; + void* tick; + void* pump; + unsigned pending_count, job_count, transfer_count; + unsigned local_count; + int closing, used_storage, sweep_due; +}; +void mf_transfers_quiesce(struct member_files* files, uint64_t group); +#endif diff --git a/src/member_files/protocol.c b/src/member_files/protocol.c new file mode 100644 index 00000000..9e3e262a --- /dev/null +++ b/src/member_files/protocol.c @@ -0,0 +1,109 @@ +#include "protocol.h" +#include "../../lib/debug_config.h" +#include +#include + +uint64_t mf_get64(const uint8_t* p) { + uint64_t v = 0; + for (unsigned i = 0; i < 8; i++) v |= (uint64_t)p[i] << (8 * i); + return v; +} + +void mf_put64(uint8_t* p, uint64_t value) { + for (unsigned i = 0; i < 8; i++) p[i] = (uint8_t)(value >> (8 * i)); +} + +int mf_zero(const uint8_t* p, size_t n) { + for (size_t i = 0; i < n; i++) if (p[i]) return 0; + return 1; +} + +/* Строгий UTF-8: без overlong, суррогатов и кодовых точек за Unicode. */ +static int mf_utf8(const unsigned char* p) { + while (*p) { + unsigned c = *p++; + if (c < 0x80) continue; + unsigned count, value, minimum; + if (c >= 0xc2 && c <= 0xdf) { count = 1; value = c & 31; minimum = 0x80; } + else if (c >= 0xe0 && c <= 0xef) { count = 2; value = c & 15; minimum = 0x800; } + else if (c >= 0xf0 && c <= 0xf4) { count = 3; value = c & 7; minimum = 0x10000; } + else return 0; + for (unsigned i = 0; i < count; i++) { + if ((*p & 0xc0) != 0x80) return 0; + value = (value << 6) | (*p++ & 63); + } + if (value < minimum || value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff)) return 0; + } + return 1; +} + +int mf_path_valid(const char* path, int root_allowed) { + if (!path || strnlen(path, MF_PATH_MAX + 1) > MF_PATH_MAX) return 0; + if (!*path) return root_allowed; + if (!mf_utf8((const unsigned char*)path)) return 0; + const char* start = path; + unsigned components = 0; + for (const char* p = path;; p++) { + unsigned char c = (unsigned char)*p; + if (c && c != '/' && (c < 32 || c == 127 || strchr("\\:*?\"<>|", c))) return 0; + if (c && c != '/') continue; + size_t n = (size_t)(p - start); + if (++components > 60) return 0; + if (!n || n > MF_NAME_MAX || start[0] == '.' || start[n - 1] == '.' || start[n - 1] == ' ') return 0; + char base[9]; size_t k = 0; + while (k < n && k < sizeof(base) - 1 && start[k] != '.') { base[k] = (char)toupper((unsigned char)start[k]); k++; } + base[k] = 0; + if (!strcmp(base, "CON") || !strcmp(base, "PRN") || !strcmp(base, "AUX") || !strcmp(base, "NUL") || + (k == 4 && (!memcmp(base, "COM", 3) || !memcmp(base, "LPT", 3)) && base[3] >= '1' && base[3] <= '9')) return 0; + if (!c) return 1; + start = p + 1; + } +} + +int mf_encode(const struct mf_message* m, uint8_t* p, size_t cap) { + size_t a = strnlen(m->path, sizeof(m->path)), b = strnlen(m->target, sizeof(m->target)); + size_t len = MF_WIRE_HEADER + a + b + m->data_len; + if (a > MF_PATH_MAX || b > MF_PATH_MAX || m->data_len > MF_BLOCK_SIZE || cap < len) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: encode overflow paths=%zu/%zu data=%u cap=%zu", a, b, m->data_len, cap); + return -1; + } + memset(p, 0, MF_WIRE_HEADER); + p[0] = 1; p[1] = m->kind; p[2] = m->command; p[3] = (uint8_t)m->status; p[4] = (uint8_t)(m->status >> 8); + mf_put64(p + 5, m->request_id); mf_put64(p + 13, m->owner); mf_put64(p + 21, m->offset); + mf_put64(p + 29, m->size); mf_put64(p + 37, m->cursor); + memcpy(p + 45, m->version, 16); memcpy(p + 61, m->upload, 16); memcpy(p + 77, m->hash, 32); + mf_put64(p + 109, m->mtime); p[117] = m->type; p[118] = m->more; + p[119] = (uint8_t)a; p[120] = (uint8_t)(a >> 8); p[121] = (uint8_t)b; p[122] = (uint8_t)(b >> 8); + p[123] = (uint8_t)m->data_len; p[124] = (uint8_t)(m->data_len >> 8); + memcpy(p + MF_WIRE_HEADER, m->path, a); memcpy(p + MF_WIRE_HEADER + a, m->target, b); + memcpy(p + MF_WIRE_HEADER + a + b, m->data, m->data_len); + return (int)len; +} + +int mf_decode(const uint8_t* p, size_t n, struct mf_message* m) { + if (!p || !m || n < MF_WIRE_HEADER) goto bad; + size_t a = p[119] | ((size_t)p[120] << 8), b = p[121] | ((size_t)p[122] << 8); + size_t d = p[123] | ((size_t)p[124] << 8); + if (p[0] != 1 || (p[1] != MF_REQUEST && p[1] != MF_RESPONSE) || p[2] < MF_LIST || p[2] > MF_REMOVE || + a > MF_PATH_MAX || b > MF_PATH_MAX || d > MF_BLOCK_SIZE || n != MF_WIRE_HEADER + a + b + d || + memchr(p + MF_WIRE_HEADER, 0, a + b) || p[118] > 1) goto bad; + memset(m, 0, sizeof(*m)); + m->kind = p[1]; m->command = p[2]; m->status = p[3] | ((uint16_t)p[4] << 8); + m->request_id = mf_get64(p + 5); m->owner = mf_get64(p + 13); m->offset = mf_get64(p + 21); + m->size = mf_get64(p + 29); m->cursor = mf_get64(p + 37); m->mtime = mf_get64(p + 109); + memcpy(m->version, p + 45, 16); memcpy(m->upload, p + 61, 16); memcpy(m->hash, p + 77, 32); + m->type = p[117]; m->more = p[118]; m->data_len = (uint32_t)d; + memcpy(m->path, p + MF_WIRE_HEADER, a); memcpy(m->target, p + MF_WIRE_HEADER + a, b); + memcpy(m->data, p + MF_WIRE_HEADER + a + b, d); + if (!m->request_id || m->status > MF_TIMEOUT || (m->kind == MF_REQUEST && m->status)) goto bad; + return 0; +bad: + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: malformed wire packet bytes=%zu", n); + return -1; +} + +const char* mf_status_text(int status) { + static const char* texts[] = { "OK", "Access denied", "Not found", "Invalid path", "Invalid range", "Already exists", + "Busy", "File changed", "Storage full", "I/O error", "Invalid packet", "Hash mismatch", "Cancelled", "Timeout" }; + return status >= 0 && status <= MF_TIMEOUT ? texts[status] : "Unknown error"; +} diff --git a/src/member_files/protocol.h b/src/member_files/protocol.h new file mode 100644 index 00000000..02caa2ea --- /dev/null +++ b/src/member_files/protocol.h @@ -0,0 +1,56 @@ +/* Файловый протокол участников CHAT-группы. Все числа на проводе little-endian. + * Группа и подписанный отправитель приходят от router; owner — UID каталога. + * version — непрозрачный снимок объекта, upload — случайный ID временного файла. + * Нулевая version в BEGIN означает создание; замена требует version из STAT. + * LIST: cursor — число уже выданных записей; version фиксирует снимок каталога. + * READ/WRITE: offset и size задают диапазон; WRITE.data_len обязан равняться size. + * Данные LIST — последовательность entry: name_len:2,type:1,size:8,mtime:8,version:16,name. + * Токены меняются при перезапуске сервера. Никаких ABI-зависимых структур в wire. */ +#ifndef MEMBER_FILES_PROTOCOL_H +#define MEMBER_FILES_PROTOCOL_H +#include +#include +#ifdef __cplusplus +extern "C" { +#endif + +#define MF_BLOCK_SIZE 8192 +#define MF_PATH_MAX 1024 +#define MF_NAME_MAX 255 +#define MF_WIRE_HEADER 125 +#define MF_WIRE_MAX (MF_WIRE_HEADER + 2 * MF_PATH_MAX + MF_BLOCK_SIZE) +#define MF_ENTRY_HEADER 35 +#define MF_IDLE_TB 100000ULL /* 10 секунд */ +#define MF_REQUEST 1 +#define MF_RESPONSE 2 +#define MF_FILE 1 +#define MF_DIRECTORY 2 + +enum mf_command { MF_LIST = 1, MF_STAT, MF_READ, MF_BEGIN_WRITE, MF_WRITE, MF_COMMIT, MF_ABORT, MF_MKDIR, MF_RENAME, MF_REMOVE }; +enum mf_status { + MF_OK, MF_ACCESS_DENIED, MF_NOT_FOUND, MF_INVALID_PATH, MF_INVALID_RANGE, MF_EXISTS, + MF_BUSY, MF_STALE, MF_NO_SPACE, MF_IO_ERROR, MF_BAD_PACKET, MF_HASH_MISMATCH, MF_CANCELLED, MF_TIMEOUT +}; + +struct mf_message { + uint8_t kind, command, type, more; + uint16_t status; + uint64_t request_id, owner, offset, size, cursor, mtime; + uint8_t version[16], upload[16], hash[32]; + char path[MF_PATH_MAX + 1], target[MF_PATH_MAX + 1]; + uint32_t data_len; + uint8_t data[MF_BLOCK_SIZE]; +}; + +uint64_t mf_get64(const uint8_t* p); +void mf_put64(uint8_t* p, uint64_t value); +int mf_zero(const uint8_t* p, size_t n); +/* Проверка UTF-8 и переносимых компонентов. Пустой путь допустим только для корня. */ +int mf_path_valid(const char* path, int root_allowed); +int mf_encode(const struct mf_message* message, uint8_t* wire, size_t capacity); +int mf_decode(const uint8_t* wire, size_t length, struct mf_message* message); +const char* mf_status_text(int status); +#ifdef __cplusplus +} +#endif +#endif diff --git a/src/member_files/storage.c b/src/member_files/storage.c new file mode 100644 index 00000000..a089ba6b --- /dev/null +++ b/src/member_files/storage.c @@ -0,0 +1,431 @@ +#include "storage.h" +#include "disk.h" +#include "../../lib/mem.h" +#include "../../lib/debug_config.h" +#include "../../lib/u_async.h" +#include +#include +#include +#include + +#define STORE_FILES 64 +#define STORE_UPLOADS 16 +#define STORE_RECEIPTS 64 +#define RECEIPT_TB 1200000ULL + +struct store_file { char path[MF_PATH_MAX + 80]; int fd; uint64_t touched; }; +struct store_upload { + uint64_t group, peer, owner, size, touched; + uint8_t id[16], hash[32], expected[16]; + char path[MF_PATH_MAX + 80], temp[64]; + int active; +}; +struct store_receipt { + uint64_t group, peer, until; + uint8_t fingerprint[32]; + struct mf_message response; +}; +struct mf_storage { + char path[2048]; + struct mf_disk_root root; + int ready; + uint8_t epoch[16]; + uint64_t generation, quota, used, reserved, upload_ttl; + struct store_file files[STORE_FILES]; + struct store_upload uploads[STORE_UPLOADS]; + struct store_receipt receipts[STORE_RECEIPTS]; +}; + +/* Версия переживает закрытие fd, но не restart. Метаданные снимка — непрозрачные байты. */ +static void store_version(struct mf_storage* s, const struct mf_disk_info* i, uint8_t out[16]) { + uint8_t bytes[81], hash[32]; + memcpy(bytes, s->epoch, 16); + for (unsigned n = 0; n < 6; n++) mf_put64(bytes + 16 + n * 8, i->identity[n]); + mf_put64(bytes + 64, i->size); bytes[72] = i->type; + mf_put64(bytes + 73, i->type == MF_DIRECTORY ? s->generation : 0); + SHA256(bytes, sizeof(bytes), hash); memcpy(out, hash, 16); +} + +static void store_invalidate(struct mf_storage* s) { + for (unsigned i = 0; i < STORE_FILES; i++) { + if (s->files[i].fd >= 0) mf_disk_close(s->files[i].fd); + s->files[i].fd = -1; s->files[i].path[0] = 0; + } + s->generation++; +} + +static int store_fd(struct mf_storage* s, const char* path, int write, int* fd) { + struct store_file* slot = NULL; + for (unsigned i = 0; i < STORE_FILES; i++) { + struct store_file* f = &s->files[i]; + if (f->fd >= 0 && !strcmp(f->path, path)) { f->touched = get_time_tb(); *fd = f->fd; return MF_OK; } + if (!slot && f->fd < 0) slot = f; + } + if (!slot) return MF_BUSY; + int rc = mf_disk_open(&s->root, path, 0, write, &slot->fd); + if (rc) return rc; + snprintf(slot->path, sizeof(slot->path), "%s", path); slot->touched = get_time_tb(); *fd = slot->fd; + DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "member_files: open path=%s writable=%d descriptors=%u", path, write, mf_storage_open_count(s)); + return MF_OK; +} + +struct scan_context { struct mf_storage* s; char path[MF_PATH_MAX + 80]; unsigned depth; int cleanup; }; + +/* Обход не следует ссылкам. Резервации равны конечному размеру sparse temp-файла. */ +static int store_scan_entry(void* arg, const char* name, const struct mf_disk_info* info) { + struct scan_context* c = arg; + char child[MF_PATH_MAX + 80]; + if (snprintf(child, sizeof(child), "%s%s%s", c->path, *c->path ? "/" : "", name) >= (int)sizeof(child)) return -MF_INVALID_PATH; + if (c->cleanup) { + if (strlen(name) != 32 || info->type != MF_FILE) return -MF_INVALID_PATH; + for (const char* p = name; *p; p++) if (!strchr("0123456789abcdef", *p)) return -MF_INVALID_PATH; + int rc = mf_disk_remove(&c->s->root, child, 0); + return rc ? -rc : 0; + } + if (!strcmp(name, ".uploads") && !*c->path) return 0; + if (info->type == MF_FILE) { + if (info->size > UINT64_MAX - c->s->used) return -MF_NO_SPACE; + c->s->used += info->size; return 0; + } + if (c->depth >= 64) return -MF_INVALID_PATH; + struct scan_context next = { .s = c->s, .depth = c->depth + 1 }; + snprintf(next.path, sizeof(next.path), "%s", child); + int rc = mf_disk_list(&c->s->root, child, store_scan_entry, &next); + return rc ? -rc : 0; +} + +static int store_initialize(struct mf_storage* s) { + if (s->ready) return MF_OK; + int rc = mf_disk_root_open(&s->root, s->path); + if (rc) return rc; + rc = mf_disk_mkdir(&s->root, ".uploads"); + if (rc == MF_EXISTS) rc = MF_OK; + struct scan_context c = { .s = s, .cleanup = 1 }; + snprintf(c.path, sizeof(c.path), ".uploads"); + if (!rc) rc = mf_disk_list(&s->root, c.path, store_scan_entry, &c); + c.cleanup = 0; c.path[0] = 0; s->used = 0; + if (!rc) rc = mf_disk_list(&s->root, "", store_scan_entry, &c); + if (rc) { mf_disk_root_close(&s->root); return rc; } + s->ready = 1; + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "member_files: storage ready root=%s used=%llu quota=%llu", s->path, + (unsigned long long)s->used, (unsigned long long)s->quota); + return MF_OK; +} + +struct mf_storage* mf_storage_create(const char* root, uint64_t quota, uint64_t ttl) { + if (!root || !*root || strlen(root) >= 2048 || !ttl) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: invalid storage settings root=%s quota=%llu ttl=%llu", + root ? root : "(null)", (unsigned long long)quota, (unsigned long long)ttl); return NULL; + } + struct mf_storage* s = u_calloc(1, sizeof(*s)); + if (!s || RAND_bytes(s->epoch, 16) != 1) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: storage allocation/random failed"); u_free(s); return NULL; + } + snprintf(s->path, sizeof(s->path), "%s", root); s->quota = quota; s->upload_ttl = ttl; + for (unsigned i = 0; i < STORE_FILES; i++) s->files[i].fd = -1; + return s; +} + +unsigned mf_storage_open_count(const struct mf_storage* s) { + unsigned count = 0; + for (unsigned i = 0; i < STORE_FILES; i++) if (s->files[i].fd >= 0) count++; + return count; +} + +static int store_upload_discard(struct mf_storage* s, struct store_upload* u) { + store_invalidate(s); + int rc = mf_disk_remove(&s->root, u->temp, 0); + if (rc && rc != MF_NOT_FOUND) return rc; + s->reserved -= u->size; u->active = 0; + return MF_OK; +} + +void mf_storage_sweep(struct mf_storage* s, uint64_t now) { + if (!s) return; + for (unsigned i = 0; i < STORE_FILES; i++) { + struct store_file* f = &s->files[i]; + if (f->fd < 0 || now < f->touched || now - f->touched < MF_IDLE_TB) continue; + int rc = mf_disk_close(f->fd); f->fd = -1; + DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "member_files: idle close path=%s status=%d descriptors=%u", f->path, rc, mf_storage_open_count(s)); + f->path[0] = 0; + } + for (unsigned i = 0; i < STORE_UPLOADS; i++) { + struct store_upload* u = &s->uploads[i]; + if (!u->active || now < u->touched || now - u->touched < s->upload_ttl) continue; + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: upload expired group=%llu owner=%llu path=%s", + (unsigned long long)u->group, (unsigned long long)u->owner, u->path); + store_upload_discard(s, u); + } +} + +void mf_storage_destroy(struct mf_storage* s) { + if (!s) return; + store_invalidate(s); + if (s->ready) { + for (unsigned i = 0; i < STORE_UPLOADS; i++) if (s->uploads[i].active) store_upload_discard(s, &s->uploads[i]); + mf_disk_root_close(&s->root); + } + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "member_files: storage stopped used=%llu reserved=%llu", + (unsigned long long)s->used, (unsigned long long)s->reserved); + u_free(s); +} + +static int store_base(struct mf_storage* s, uint64_t group, uint64_t owner) { + char path[64]; struct mf_disk_info info; + snprintf(path, sizeof(path), "%llu", (unsigned long long)group); + int rc = mf_disk_mkdir(&s->root, path); + if (rc == MF_EXISTS) rc = mf_disk_stat(&s->root, path, &info); + if (rc) return rc; + snprintf(path, sizeof(path), "%llu/%llu", (unsigned long long)group, (unsigned long long)owner); + rc = mf_disk_mkdir(&s->root, path); + if (rc == MF_EXISTS) rc = mf_disk_stat(&s->root, path, &info); + return rc; +} + +static int store_expected(struct mf_storage* s, const char* path, const uint8_t expected[16], struct mf_disk_info* info) { + int rc = mf_disk_stat(&s->root, path, info); + if (rc == MF_NOT_FOUND) return mf_zero(expected, 16) ? MF_OK : MF_STALE; + if (rc) return rc; + if (mf_zero(expected, 16)) return MF_EXISTS; + uint8_t version[16]; store_version(s, info, version); + return memcmp(expected, version, 16) ? MF_STALE : MF_OK; +} + +struct list_context { struct mf_storage* s; const struct mf_message* req; struct mf_message* resp; uint64_t seen; }; + +static int store_list_entry(void* arg, const char* name, const struct mf_disk_info* info) { + struct list_context* c = arg; + if (!mf_path_valid(name, 0)) return -MF_INVALID_PATH; + if (c->seen++ < c->req->cursor) return 0; + size_t n = strlen(name), at = c->resp->data_len; + if (at + MF_ENTRY_HEADER + n > MF_BLOCK_SIZE) { c->resp->more = 1; return 1; } + uint8_t* p = c->resp->data + at; + p[0] = (uint8_t)n; p[1] = (uint8_t)(n >> 8); p[2] = info->type; + mf_put64(p + 3, info->size); mf_put64(p + 11, info->mtime); store_version(c->s, info, p + 19); + memcpy(p + MF_ENTRY_HEADER, name, n); + c->resp->data_len += (uint32_t)(MF_ENTRY_HEADER + n); c->resp->cursor++; + return 0; +} + +static struct store_upload* store_upload_find(struct mf_storage* s, uint64_t group, uint64_t peer, const uint8_t id[16]) { + for (unsigned i = 0; i < STORE_UPLOADS; i++) { + struct store_upload* u = &s->uploads[i]; + if (u->active && u->group == group && u->peer == peer && !memcmp(u->id, id, 16)) return u; + } + return NULL; +} + +static int store_begin(struct mf_storage* s, uint64_t group, uint64_t peer, const char* path, const struct mf_message* q) { + if (q->size > INT64_MAX || mf_zero(q->upload, 16)) return MF_INVALID_RANGE; + struct store_upload* u = store_upload_find(s, group, peer, q->upload); + if (u) { + if (strcmp(u->path, path) || u->size != q->size || memcmp(u->hash, q->hash, 32) || memcmp(u->expected, q->version, 16)) + return MF_BAD_PACKET; + int fd; + int rc = store_fd(s, u->temp, 1, &fd); + if (!rc) u->touched = get_time_tb(); + return rc; + } + struct store_upload* slot = NULL; + for (unsigned i = 0; i < STORE_UPLOADS; i++) { + if (s->uploads[i].active && !strcmp(s->uploads[i].path, path)) return MF_BUSY; + if (!s->uploads[i].active && !slot) slot = &s->uploads[i]; + } + if (!slot) return MF_BUSY; + struct mf_disk_info existing = {0}; + int rc = store_expected(s, path, q->version, &existing); + if (rc) return rc; + if (existing.type && existing.type != MF_FILE) return MF_INVALID_PATH; + if (s->quota && (s->used > s->quota || s->reserved > s->quota - s->used || q->size > s->quota - s->used - s->reserved)) + return MF_NO_SPACE; + rc = store_base(s, group, q->owner); + if (rc) return rc; + memset(slot, 0, sizeof(*slot)); + char hex[33]; for (unsigned i = 0; i < 16; i++) snprintf(hex + 2 * i, 3, "%02x", q->upload[i]); + snprintf(slot->temp, sizeof(slot->temp), ".uploads/%s", hex); + int fd = -1; + rc = mf_disk_open(&s->root, slot->temp, 1, 1, &fd); + if (rc) return rc; + rc = mf_disk_resize(fd, q->size); + struct store_file* cached = NULL; + if (!rc) for (unsigned i = 0; i < STORE_FILES; i++) if (s->files[i].fd < 0) { cached = &s->files[i]; break; } + if (!rc && !cached) rc = MF_BUSY; + if (rc) { mf_disk_close(fd); mf_disk_remove(&s->root, slot->temp, 0); return rc; } + cached->fd = fd; cached->touched = get_time_tb(); snprintf(cached->path, sizeof(cached->path), "%s", slot->temp); + slot->active = 1; slot->group = group; slot->peer = peer; slot->owner = q->owner; slot->size = q->size; + memcpy(slot->id, q->upload, 16); memcpy(slot->hash, q->hash, 32); memcpy(slot->expected, q->version, 16); + snprintf(slot->path, sizeof(slot->path), "%s", path); slot->touched = get_time_tb(); s->reserved += q->size; + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "member_files: begin group=%llu owner=%llu path=%s size=%llu reserved=%llu", + (unsigned long long)group, (unsigned long long)q->owner, path, (unsigned long long)q->size, (unsigned long long)s->reserved); + return MF_OK; +} + +static int store_command(struct mf_storage* s, uint64_t group, uint64_t peer, const struct mf_message* q, struct mf_message* r) { + if (!mf_path_valid(q->path, q->command == MF_LIST || q->command == MF_STAT) || + (q->command == MF_RENAME && !mf_path_valid(q->target, 0))) return MF_INVALID_PATH; + char path[MF_PATH_MAX + 80]; + snprintf(path, sizeof(path), "%llu/%llu%s%s", (unsigned long long)group, (unsigned long long)q->owner, *q->path ? "/" : "", q->path); + struct mf_disk_info info = {0}; + int rc, fd; + if (q->command == MF_BEGIN_WRITE) return store_begin(s, group, peer, path, q); + if (q->command == MF_WRITE || q->command == MF_COMMIT || q->command == MF_ABORT) { + struct store_upload* u = store_upload_find(s, group, peer, q->upload); + if (!u) return q->command == MF_ABORT ? MF_OK : MF_NOT_FOUND; + if (u->owner != q->owner || strcmp(u->path, path)) return MF_ACCESS_DENIED; + u->touched = get_time_tb(); + if (q->command == MF_ABORT) return store_upload_discard(s, u); + if (q->command == MF_WRITE && (q->size != q->data_len || q->size > MF_BLOCK_SIZE || + q->offset > u->size || q->size > u->size - q->offset)) return MF_INVALID_RANGE; + rc = store_fd(s, u->temp, 1, &fd); + if (rc) return rc; + if (q->command == MF_WRITE) return mf_disk_write(fd, q->offset, q->data, q->data_len); + uint8_t hash[32]; + rc = mf_disk_hash(fd, u->size, hash); + if (rc) return rc; + if (memcmp(hash, u->hash, 32)) return MF_HASH_MISMATCH; + rc = mf_disk_sync(fd); + if (rc) return rc; + rc = store_expected(s, path, u->expected, &info); + if (rc) return rc; + uint64_t previous = info.size; + store_invalidate(s); + rc = mf_disk_rename(&s->root, u->temp, path); + /* Даже при ошибке fsync rename уже мог произойти; сверяем существование temp. */ + struct mf_disk_info temp; + int temp_status = mf_disk_stat(&s->root, u->temp, &temp); + if (temp_status != MF_NOT_FOUND) return rc ? rc : temp_status ? temp_status : MF_IO_ERROR; + s->used = s->used - previous + u->size; s->reserved -= u->size; u->active = 0; + if (rc) return rc; + rc = mf_disk_stat(&s->root, path, &info); + if (rc) return rc; + r->size = info.size; r->mtime = info.mtime; r->type = info.type; + store_version(s, &info, r->version); memcpy(r->hash, hash, 32); + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "member_files: published path=%s size=%llu used=%llu", path, + (unsigned long long)r->size, (unsigned long long)s->used); + return MF_OK; + } + if (q->command == MF_MKDIR) { + for (unsigned i = 0; i < STORE_UPLOADS; i++) + if (s->uploads[i].active && !strcmp(s->uploads[i].path, path)) return MF_BUSY; + rc = store_base(s, group, q->owner); + if (!rc) { store_invalidate(s); rc = mf_disk_mkdir(&s->root, path); } + return rc; + } + rc = mf_disk_stat(&s->root, path, &info); + if (rc == MF_NOT_FOUND && q->command == MF_LIST && !*q->path && !q->cursor) return MF_OK; + if (rc) return rc; + uint8_t version[16]; store_version(s, &info, version); + if ((!mf_zero(q->version, 16) && memcmp(q->version, version, 16)) || + ((q->command == MF_READ || q->command == MF_RENAME || q->command == MF_REMOVE || q->cursor) && mf_zero(q->version, 16))) + return MF_STALE; + r->size = info.size; r->mtime = info.mtime; r->type = info.type; memcpy(r->version, version, 16); + if (q->command == MF_STAT) return MF_OK; + if (q->command == MF_LIST) { + if (info.type != MF_DIRECTORY) return MF_INVALID_PATH; + struct list_context c = { .s = s, .req = q, .resp = r }; + r->cursor = q->cursor; + return mf_disk_list(&s->root, path, store_list_entry, &c); + } + if (q->command == MF_READ) { + if (info.type != MF_FILE || q->offset > info.size || !q->size || q->size > MF_BLOCK_SIZE) return MF_INVALID_RANGE; + rc = store_fd(s, path, 0, &fd); + if (rc) return rc; + struct mf_disk_info opened; rc = mf_disk_info(fd, &opened); + if (rc) return rc; + store_version(s, &opened, version); + if (memcmp(version, q->version, 16)) { store_invalidate(s); return MF_STALE; } + r->data_len = (uint32_t)(info.size - q->offset < q->size ? info.size - q->offset : q->size); + r->more = q->offset + r->data_len < info.size; + rc = mf_disk_read(fd, q->offset, r->data, r->data_len); + if (!rc) { + rc = mf_disk_info(fd, &opened); store_version(s, &opened, version); + if (!rc && memcmp(version, q->version, 16)) rc = MF_STALE; + } + return rc; + } + for (unsigned i = 0; i < STORE_UPLOADS; i++) { + struct store_upload* u = &s->uploads[i]; + size_t n = strlen(path); + if (u->active && !strncmp(u->path, path, n) && (!u->path[n] || u->path[n] == '/')) return MF_BUSY; + } + store_invalidate(s); + if (q->command == MF_REMOVE) { + rc = mf_disk_remove(&s->root, path, info.type == MF_DIRECTORY); + struct mf_disk_info after; + if (mf_disk_stat(&s->root, path, &after) == MF_NOT_FOUND) s->used -= info.size; + return rc; + } + if (q->command == MF_RENAME) { + char target[MF_PATH_MAX + 80]; + snprintf(target, sizeof(target), "%llu/%llu/%s", (unsigned long long)group, (unsigned long long)q->owner, q->target); + for (unsigned i = 0; i < STORE_UPLOADS; i++) + if (s->uploads[i].active && !strcmp(s->uploads[i].path, target)) return MF_BUSY; + struct mf_disk_info destination; + rc = mf_disk_stat(&s->root, target, &destination); + if (!rc) return MF_EXISTS; + if (rc != MF_NOT_FOUND) return rc; + return mf_disk_rename(&s->root, path, target); + } + return MF_BAD_PACKET; +} + +void mf_storage_execute(struct mf_storage* s, uint64_t group, uint64_t peer, const struct mf_message* q, struct mf_message* r) { + *r = *q; r->kind = MF_RESPONSE; r->status = MF_OK; r->data_len = 0; r->more = 0; + if (!group || !peer || !q->owner || (q->command >= MF_BEGIN_WRITE && q->owner != peer)) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: storage ACL denied group=%llu peer=%llu owner=%llu command=%u", + (unsigned long long)group, (unsigned long long)peer, (unsigned long long)q->owner, q->command); + r->status = MF_ACCESS_DENIED; return; + } + uint64_t now = get_time_tb(); + int mutates = q->command == MF_COMMIT || q->command >= MF_MKDIR; + struct store_receipt* slot = NULL; + uint8_t wire[MF_WIRE_MAX], fingerprint[32]; + int n = mf_encode(q, wire, sizeof(wire)); + if (n < 0) { r->status = MF_BAD_PACKET; return; } + SHA256(wire, (size_t)n, fingerprint); + for (unsigned i = 0; i < STORE_RECEIPTS; i++) { + struct store_receipt* p = &s->receipts[i]; + if (p->until <= now) { if (!slot) slot = p; continue; } + if (p->group != group || p->peer != peer) continue; + if (p->response.request_id == q->request_id) { + if (memcmp(p->fingerprint, fingerprint, 32)) r->status = MF_BAD_PACKET; + else *r = p->response; + return; + } + if (q->command == MF_COMMIT && p->response.command == MF_COMMIT && p->response.owner == q->owner && + !strcmp(q->path, p->response.path) && !memcmp(q->upload, p->response.upload, 16)) { + *r = p->response; r->request_id = q->request_id; return; + } + } + if (mutates && !slot) r->status = MF_BUSY; + else { + r->status = (uint16_t)store_initialize(s); + if (!r->status) { + mf_storage_sweep(s, now); r->status = (uint16_t)store_command(s, group, peer, q, r); + /* Idle отсчитывается от завершения I/O, а не от начала медленного read/write. */ + if (!r->status && (q->command == MF_READ || q->command == MF_WRITE || q->command == MF_BEGIN_WRITE)) { + char accessed[MF_PATH_MAX + 80]; + if (q->command == MF_READ) snprintf(accessed, sizeof(accessed), "%llu/%llu/%s", + (unsigned long long)group, (unsigned long long)q->owner, q->path); + else { + struct store_upload* u = store_upload_find(s, group, peer, q->upload); + if (u) { snprintf(accessed, sizeof(accessed), "%s", u->temp); u->touched = get_time_tb(); } + else accessed[0] = 0; + } + for (unsigned i = 0; i < STORE_FILES; i++) if (s->files[i].fd >= 0 && !strcmp(s->files[i].path, accessed)) + s->files[i].touched = get_time_tb(); + } + } + } + if (!r->status && mutates) { + slot->group = group; slot->peer = peer; slot->until = now + RECEIPT_TB; + memcpy(slot->fingerprint, fingerprint, 32); slot->response = *r; + } + if (r->status) { + r->data_len = 0; + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: refused group=%llu peer=%llu owner=%llu cmd=%u path=%s offset=%llu size=%llu status=%s", + (unsigned long long)group, (unsigned long long)peer, (unsigned long long)q->owner, q->command, q->path, + (unsigned long long)q->offset, (unsigned long long)q->size, mf_status_text(r->status)); + } else DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "member_files: served cmd=%u path=%s offset=%llu bytes=%u", q->command, q->path, + (unsigned long long)q->offset, r->data_len); +} diff --git a/src/member_files/storage.h b/src/member_files/storage.h new file mode 100644 index 00000000..3253d420 --- /dev/null +++ b/src/member_files/storage.h @@ -0,0 +1,17 @@ +/* Серверное хранилище. Вызывать execute/sweep/destroy последовательно, только без + * одновременно работающего worker. Создание не обращается к диску; корень открывается + * лениво. ACL проверяет member_files.c перед каждой работой. Файлы временных загрузок + * закрываются после 10 секунд простоя, удаляются после upload_ttl; restart отменяет их. + * Открытые regular-файлы кешируются, максимум 64. Родительские пути не следуют ссылкам. */ +#ifndef MEMBER_FILES_STORAGE_H +#define MEMBER_FILES_STORAGE_H +#include "protocol.h" +struct mf_storage; +struct mf_storage* mf_storage_create(const char* root, uint64_t quota, uint64_t upload_ttl_tb); +void mf_storage_destroy(struct mf_storage* storage); +void mf_storage_sweep(struct mf_storage* storage, uint64_t now); +void mf_storage_execute(struct mf_storage* storage, uint64_t group, uint64_t peer, + const struct mf_message* request, struct mf_message* response); +/* Диагностика/тест: число файловых дескрипторов, исключая каталог корня. */ +unsigned mf_storage_open_count(const struct mf_storage* storage); +#endif diff --git a/src/member_files/transfer.c b/src/member_files/transfer.c new file mode 100644 index 00000000..08bc64f6 --- /dev/null +++ b/src/member_files/transfer.c @@ -0,0 +1,239 @@ +/* Общий клиент: подготовка/локальный I/O в worker, блоки и события в uasync. + * Один transfer закрепляет CM на весь срок. Cancel ждёт своего worker и закрывает + * локальный файл до DONE; download публикуется лишь после последнего блока и sync. */ +#include "private.h" +#include "disk.h" +#include "../utun_instance.h" +#include "../chat/chat_event.h" +#include "../routing_layer/conn_mgr.h" +#include "../../lib/mem.h" +#include "../../lib/debug_config.h" +#include +#include +#include + +enum transfer_work { TRANSFER_PREPARE, TRANSFER_READ, TRANSFER_WRITE, TRANSFER_FINISH }; +struct mf_transfer { + struct mf_transfer* next; + struct member_files* files; + struct mf_transfer_request request; + struct mf_pending* pending; + struct CONN_MGR_HANDLE* cm; + struct mf_work* cleanup; + struct mf_disk_info source; + int fd, busy, cancelled, status, work_status; + enum transfer_work work; + uint64_t size, offset, last_event; + uint8_t upload[16], version[16], hash[32], block[MF_BLOCK_SIZE]; + uint32_t bytes; + char temp[MF_PATH_MAX + 80]; +}; + +static void transfer_event(struct mf_transfer* t, int finished) { + uint64_t now = get_time_tb(); + if (!finished && t->last_event && now - t->last_event < 1000 && t->offset < t->size) return; + t->last_event = now; + uint8_t data[51]; + mf_put64(data, t->request.id); mf_put64(data + 8, t->request.group); mf_put64(data + 16, t->request.server); + mf_put64(data + 24, t->request.owner); mf_put64(data + 32, t->offset); mf_put64(data + 40, t->size); + data[48] = (uint8_t)t->status; data[49] = (uint8_t)(t->status >> 8); data[50] = finished ? 2 : 1; + chat_event_post(t->request.inst, CHAT_EVT_MEMBER_FILES_TRANSFER, data, sizeof(data)); +} + +static void transfer_send(struct mf_transfer* t, uint8_t command); +static void transfer_submit(struct mf_transfer* t, enum transfer_work work); + +static void transfer_abort_done(void* arg, const struct mf_message* response) { + (void)arg; + if (response->status) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: abort not confirmed status=%s", mf_status_text(response->status)); +} + +/* Отмена сетевого request синхронно вызывает его callback. Worker завершается отдельно. */ +static void transfer_finish(struct mf_transfer* t, int status) { + if (status) t->status = status; + if (t->busy) return; + if (t->request.upload && t->status && !t->files->closing && !mf_zero(t->upload, 16)) { + struct mf_message q = { .command = MF_ABORT, .owner = t->request.owner }; + memcpy(q.upload, t->upload, 16); snprintf(q.path, sizeof(q.path), "%s", t->request.remote); + if (!member_files_request(t->request.inst, t->request.group, t->request.server, &q, transfer_abort_done, NULL)) + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: abort deferred to upload TTL"); + } + transfer_submit(t, TRANSFER_FINISH); +} + +static void transfer_work(void* arg) { + struct mf_transfer* t = arg; + t->work_status = MF_OK; + if (t->work == TRANSFER_PREPARE) { + if (t->request.upload) { + t->work_status = mf_disk_local_open(t->request.local, 0, &t->fd); + if (!t->work_status) t->work_status = mf_disk_info(t->fd, &t->source); + if (!t->work_status && (t->source.type != MF_FILE || t->source.size > INT64_MAX)) t->work_status = MF_INVALID_RANGE; + if (!t->work_status) { t->size = t->source.size; t->work_status = mf_disk_hash(t->fd, t->size, t->hash); } + struct mf_disk_info after; + if (!t->work_status) t->work_status = mf_disk_info(t->fd, &after); + if (!t->work_status && memcmp(&after, &t->source, sizeof(after))) t->work_status = MF_STALE; + } else t->work_status = mf_disk_local_open(t->temp, 1, &t->fd); + } else if (t->work == TRANSFER_READ) { + struct mf_disk_info info; + t->work_status = mf_disk_info(t->fd, &info); + if (!t->work_status && memcmp(&info, &t->source, sizeof(info))) t->work_status = MF_STALE; + t->bytes = (uint32_t)(t->size - t->offset < MF_BLOCK_SIZE ? t->size - t->offset : MF_BLOCK_SIZE); + if (!t->work_status) t->work_status = mf_disk_read(t->fd, t->offset, t->block, t->bytes); + } else if (t->work == TRANSFER_WRITE) t->work_status = mf_disk_write(t->fd, t->offset, t->block, t->bytes); + else { + if (t->fd >= 0) { + if (!t->status && !t->request.upload) t->work_status = mf_disk_sync(t->fd); + int closed = mf_disk_close(t->fd); t->fd = -1; + if (!t->work_status) t->work_status = closed; + } + if (!t->request.upload && *t->temp) { + if (!t->status && !t->work_status) t->work_status = mf_disk_local_publish(t->temp, t->request.local); + if (t->status || t->work_status) mf_disk_local_remove(t->temp); + } + } +} + +static void transfer_release(struct mf_transfer* t) { + struct member_files* f = t->files; + if (t->cm) conn_mgr_close(t->cm); + mf_worker_unreserve(t->cleanup); + struct mf_transfer** link = &f->transfers; + while (*link && *link != t) link = &(*link)->next; + if (*link) *link = t->next; + f->transfer_count--; + transfer_event(t, 1); + if (t->status && t->status != MF_CANCELLED) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: transfer error id=%llu remote=%s local=%s reason=%s", + (unsigned long long)t->request.id, t->request.remote, t->request.local, mf_status_text(t->status)); + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "member_files: transfer finished id=%llu upload=%d bytes=%llu/%llu status=%s active=%u", + (unsigned long long)t->request.id, t->request.upload, (unsigned long long)t->offset, + (unsigned long long)t->size, mf_status_text(t->status), f->transfer_count); + u_free(t); +} + +static void transfer_done(void* arg, int error) { + struct mf_transfer* t = arg; t->busy = 0; + int status = error ? MF_CANCELLED : t->work_status; + if (t->work == TRANSFER_FINISH) { + /* При destroy work могла быть завершена, а completion отменён. fd уже закрыт. */ + if (!t->status) t->status = status; + transfer_release(t); return; + } + if (t->cancelled || t->files->closing || status) { transfer_finish(t, status ? status : MF_CANCELLED); return; } + if (t->work == TRANSFER_PREPARE) { + transfer_event(t, 0); + if (t->request.upload) transfer_send(t, MF_BEGIN_WRITE); + else if (t->size) transfer_send(t, MF_READ); + else transfer_finish(t, MF_OK); + } else if (t->work == TRANSFER_READ) transfer_send(t, MF_WRITE); + else { + t->offset += t->bytes; transfer_event(t, 0); + if (t->offset == t->size) transfer_finish(t, MF_OK); + else transfer_send(t, MF_READ); + } +} + +static void transfer_submit(struct mf_transfer* t, enum transfer_work work) { + t->work = work; t->busy = 1; + if (work == TRANSFER_FINISH) { + struct mf_work* reserved = t->cleanup; t->cleanup = NULL; + mf_worker_submit_reserved(t->files->disk, reserved, transfer_work, t, transfer_done, t); + } else mf_worker_submit(t->files->disk, transfer_work, t, transfer_done, t); +} + +static void transfer_response(void* arg, const struct mf_message* r) { + struct mf_transfer* t = arg; t->pending = NULL; + if (t->cancelled || t->files->closing || r->status) { transfer_finish(t, r->status ? r->status : MF_CANCELLED); return; } + if (r->command == MF_STAT) { + if (r->type != MF_FILE || r->size > INT64_MAX || mf_zero(r->version, 16)) { transfer_finish(t, MF_INVALID_RANGE); return; } + t->size = r->size; memcpy(t->version, r->version, 16); transfer_submit(t, TRANSFER_PREPARE); + } else if (r->command == MF_BEGIN_WRITE) { + if (t->size) transfer_submit(t, TRANSFER_READ); + else transfer_send(t, MF_COMMIT); + } else if (r->command == MF_WRITE) { + if (r->offset != t->offset || r->size != t->bytes) { transfer_finish(t, MF_BAD_PACKET); return; } + t->offset += t->bytes; transfer_event(t, 0); + if (t->offset == t->size) transfer_send(t, MF_COMMIT); + else transfer_submit(t, TRANSFER_READ); + } else if (r->command == MF_READ) { + uint64_t wanted = t->size - t->offset < MF_BLOCK_SIZE ? t->size - t->offset : MF_BLOCK_SIZE; + if (r->offset != t->offset || r->size != t->size || r->data_len != wanted || memcmp(r->version, t->version, 16)) { + transfer_finish(t, MF_STALE); return; + } + t->bytes = r->data_len; memcpy(t->block, r->data, r->data_len); transfer_submit(t, TRANSFER_WRITE); + } else if (r->command == MF_COMMIT) { + if (r->size != t->size || memcmp(r->hash, t->hash, 32)) { transfer_finish(t, MF_HASH_MISMATCH); return; } + transfer_finish(t, MF_OK); + } else transfer_finish(t, MF_BAD_PACKET); +} + +static void transfer_send(struct mf_transfer* t, uint8_t command) { + struct mf_message q = { .command = command, .owner = t->request.owner, .offset = t->offset }; + snprintf(q.path, sizeof(q.path), "%s", t->request.remote); memcpy(q.upload, t->upload, 16); + if (command == MF_BEGIN_WRITE) { q.size = t->size; memcpy(q.hash, t->hash, 32); memcpy(q.version, t->request.version, 16); } + else if (command == MF_READ) { q.size = MF_BLOCK_SIZE; memcpy(q.version, t->version, 16); } + else if (command == MF_WRITE) { q.size = t->bytes; q.data_len = t->bytes; memcpy(q.data, t->block, t->bytes); } + t->pending = member_files_request(t->request.inst, t->request.group, t->request.server, &q, transfer_response, t); + if (!t->pending) transfer_finish(t, MF_BUSY); +} + +static void transfer_connected(struct CONN_MGR_HANDLE* h, uint64_t peer, uint64_t group, enum conn_mgr_event event, void* arg) { + (void)h; (void)peer; (void)group; + struct mf_transfer* t = arg; + DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "member_files: transfer CM id=%llu event=%d", (unsigned long long)t->request.id, event); +} + +void member_files_gui_transfer(void* arg) { + struct mf_transfer_request* q = arg; + struct member_files* f = q->inst ? q->inst->member_files : NULL; + struct mf_transfer* t = u_calloc(1, sizeof(*t)); + if (!t) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: transfer allocation failed"); u_free(q); return; } + t->request = *q; t->fd = -1; u_free(q); + if (!f || f->closing || f->transfer_count >= 4 || !t->request.id || !t->request.owner || + !mf_path_valid(t->request.remote, 0) || !*t->request.local || + (t->request.upload && t->request.owner != t->request.inst->node_id)) { + t->status = MF_ACCESS_DENIED; transfer_event(t, 1); u_free(t); return; + } + for (struct mf_transfer* other = f->transfers; other; other = other->next) if (other->request.id == t->request.id) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: duplicate transfer id"); t->status = MF_BUSY; transfer_event(t, 1); u_free(t); return; + } + t->cleanup = mf_worker_reserve(); + if (!t->cleanup || RAND_bytes(t->upload, 16) != 1) { + t->status = MF_IO_ERROR; transfer_event(t, 1); mf_worker_unreserve(t->cleanup); u_free(t); return; + } + snprintf(t->temp, sizeof(t->temp), "%s.utun-%016llx.part", t->request.local, (unsigned long long)t->request.id); + t->files = f; t->next = f->transfers; f->transfers = t; f->transfer_count++; + if (t->request.server != t->request.inst->node_id && conn_mgr_open(t->request.inst, t->request.group, t->request.server, + transfer_connected, t, &t->cm)) { transfer_finish(t, MF_IO_ERROR); return; } + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "member_files: transfer started id=%llu upload=%d remote=%s local=%s", + (unsigned long long)t->request.id, t->request.upload, t->request.remote, t->request.local); + transfer_event(t, 0); + if (t->request.upload) transfer_submit(t, TRANSFER_PREPARE); + else transfer_send(t, MF_STAT); +} + +static void transfer_cancel(struct mf_transfer* t) { + if (t->cancelled || t->work == TRANSFER_FINISH) return; + t->cancelled = 1; t->status = MF_CANCELLED; + if (t->pending) member_files_cancel(t->pending); + else if (!t->busy) transfer_finish(t, MF_CANCELLED); +} + +void mf_transfers_quiesce(struct member_files* f, uint64_t group) { + struct mf_transfer* t = f->transfers; + while (t) { struct mf_transfer* next = t->next; if (!group || t->request.group == group) transfer_cancel(t); t = next; } +} + +void member_files_gui_cancel(void* arg) { + struct mf_cancel_request* q = arg; + struct member_files* f = q->inst ? q->inst->member_files : NULL; + int found = 0; + if (f) for (struct mf_transfer* t = f->transfers; t; t = t->next) if (t->request.id == q->id) { + transfer_cancel(t); found = 1; break; + } + if (f && !found) { + /* Отмена браузерных LIST/STAT использует тот же глобально случайный ID. */ + member_files_cancel_request_id(q->inst, q->id); + } + u_free(q); +} diff --git a/src/member_files/worker.c b/src/member_files/worker.c new file mode 100644 index 00000000..2a579990 --- /dev/null +++ b/src/member_files/worker.c @@ -0,0 +1,128 @@ +#include "worker.h" +#include "../../lib/u_async.h" +#include "../../lib/mem.h" +#include "../../lib/debug_config.h" +#include + +struct mf_work { + struct mf_work* next; + struct mf_work* queued_next; + struct mf_worker* owner; + struct posted_task* completion; + mf_work_fn work; + mf_done_fn done; + void* data; + void* arg; + int completed; +}; +struct mf_worker { + struct UASYNC* ua; + pthread_t thread; + pthread_mutex_t mutex; + pthread_cond_t condition; + struct mf_work* jobs; + struct mf_work* queue; + struct mf_work* tail; + unsigned count; + int stopping; +}; + +static void work_finish(struct mf_work* j) { + struct mf_worker* w = j->owner; + struct mf_work** link = &w->jobs; + while (*link && *link != j) link = &(*link)->next; + if (*link) *link = j->next; + w->count--; + mf_done_fn done = j->done; void* arg = j->arg; + u_free(j); done(arg, 0); +} + +static void work_completed(void* arg) { + struct mf_work* j = arg; + pthread_mutex_lock(&j->owner->mutex); + j->completion = NULL; + pthread_mutex_unlock(&j->owner->mutex); + work_finish(j); +} + +static void* disk_thread(void* arg) { + struct mf_worker* w = arg; + pthread_mutex_lock(&w->mutex); + for (;;) { + while (!w->queue && !w->stopping) pthread_cond_wait(&w->condition, &w->mutex); + if (!w->queue && w->stopping) break; + struct mf_work* j = w->queue; w->queue = j->queued_next; + if (!w->queue) w->tail = NULL; + pthread_mutex_unlock(&w->mutex); + j->work(j->data); + pthread_mutex_lock(&w->mutex); + j->completed = 1; + uasync_post_reserved(w->ua, j->completion); + pthread_cond_broadcast(&w->condition); + } + pthread_mutex_unlock(&w->mutex); + return NULL; +} + +struct mf_worker* mf_worker_create(struct UASYNC* ua) { + struct mf_worker* w = u_calloc(1, sizeof(*w)); + if (!w) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: disk worker allocation failed"); return NULL; } + w->ua = ua; + int rc = pthread_mutex_init(&w->mutex, NULL); + if (rc) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: worker mutex failed rc=%d", rc); u_free(w); return NULL; } + rc = pthread_cond_init(&w->condition, NULL); + if (rc) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: worker condition failed rc=%d", rc); + pthread_mutex_destroy(&w->mutex); u_free(w); return NULL; + } + rc = pthread_create(&w->thread, NULL, disk_thread, w); + if (rc) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: disk thread failed rc=%d", rc); + pthread_cond_destroy(&w->condition); pthread_mutex_destroy(&w->mutex); u_free(w); return NULL; + } + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "member_files: disk thread started queue_limit=64"); return w; +} + +struct mf_work* mf_worker_reserve(void) { + struct mf_work* j = u_calloc(1, sizeof(*j)); + if (j) j->completion = u_calloc(1, sizeof(*j->completion)); + if (!j || !j->completion) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: work/completion reservation failed"); u_free(j); return NULL; } + j->completion->callback = work_completed; j->completion->arg = j; + return j; +} + +void mf_worker_unreserve(struct mf_work* j) { if (j) { u_free(j->completion); u_free(j); } } + +void mf_worker_submit_reserved(struct mf_worker* w, struct mf_work* j, + mf_work_fn work, void* data, mf_done_fn done, void* arg) { + j->owner = w; j->work = work; j->data = data; j->done = done; j->arg = arg; + j->next = w->jobs; w->jobs = j; w->count++; + pthread_mutex_lock(&w->mutex); + if (w->tail) w->tail->queued_next = j; + else w->queue = j; + w->tail = j; pthread_cond_signal(&w->condition); + pthread_mutex_unlock(&w->mutex); +} + +void mf_worker_submit(struct mf_worker* w, mf_work_fn work, void* data, mf_done_fn done, void* arg) { + struct mf_work* j = w->count < 64 ? mf_worker_reserve() : NULL; + if (!j) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: disk queue rejected active=%u", w->count); done(arg, -1); return; } + mf_worker_submit_reserved(w, j, work, data, done, arg); +} + +void mf_worker_destroy(struct mf_worker* w) { + if (!w) return; + /* Не запускаем loop рекурсивно: завершаем только собственные callbacks. */ + while (w->jobs) { + struct mf_work* j = w->jobs; + pthread_mutex_lock(&w->mutex); + while (!j->completed) pthread_cond_wait(&w->condition, &w->mutex); + pthread_mutex_unlock(&w->mutex); + uasync_cancel_post(w->ua, j->completion); j->completion = NULL; + work_finish(j); + } + pthread_mutex_lock(&w->mutex); w->stopping = 1; pthread_cond_signal(&w->condition); pthread_mutex_unlock(&w->mutex); + pthread_join(w->thread, NULL); + pthread_cond_destroy(&w->condition); pthread_mutex_destroy(&w->mutex); + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "member_files: disk thread stopped"); u_free(w); +} diff --git a/src/member_files/worker.h b/src/member_files/worker.h new file mode 100644 index 00000000..fe1480de --- /dev/null +++ b/src/member_files/worker.h @@ -0,0 +1,19 @@ +/* Единственный дисковый поток модуля. submit/done выполняются в uasync, work — + * в pthread. Очередь ограничена; completion резервируется до передачи работы. + * Для обязательной очистки reserve выполняется до открытия файлов. destroy вне + * callbacks ждёт работ и доставляет done, включая поставленные ими cleanup jobs. */ +#ifndef MEMBER_FILES_WORKER_H +#define MEMBER_FILES_WORKER_H +struct UASYNC; +struct mf_worker; +struct mf_work; +typedef void (*mf_work_fn)(void* arg); +typedef void (*mf_done_fn)(void* arg, int error); +struct mf_worker* mf_worker_create(struct UASYNC* ua); +struct mf_work* mf_worker_reserve(void); +void mf_worker_unreserve(struct mf_work* work); +void mf_worker_submit(struct mf_worker* worker, mf_work_fn work, void* data, mf_done_fn done, void* arg); +void mf_worker_submit_reserved(struct mf_worker* worker, struct mf_work* reserved, + mf_work_fn work, void* data, mf_done_fn done, void* arg); +void mf_worker_destroy(struct mf_worker* worker); +#endif diff --git a/src/transport_layer/etcp_api.h b/src/transport_layer/etcp_api.h index a5b9c4c6..4258124c 100644 --- a/src/transport_layer/etcp_api.h +++ b/src/transport_layer/etcp_api.h @@ -80,6 +80,7 @@ extern "C" { #define ETCP_RT_ID_CALL 0x36 // call — P2P аудио-звонок #define ETCP_RT_ID_FILE_TRANSFER 0x37 // file_transfer — передача неизменяемых файлов #define ETCP_RT_ID_DM_MEDIA 0x38 // dm_media — custody, location и подтверждения медиа +#define ETCP_RT_ID_MEMBER_FILES 0x39 // member_files — каталоги и файлы участников группы // Connection status events (instance-level callback). // NEW начинает жизнь объекта, UP/DOWN отражают доступность, DELETE завершает её. diff --git a/src/utun_instance.c b/src/utun_instance.c index 2a10f0f0..da6ce98b 100644 --- a/src/utun_instance.c +++ b/src/utun_instance.c @@ -21,6 +21,7 @@ #include "dm/dm_core.h" #include "dm/dm_mailbox.h" #include "dm/dm_media.h" +#include "member_files/member_files.h" #include "call/call.h" #include "call/call_headless.h" #include "broadcast.h" @@ -772,7 +773,8 @@ int chat_service_start(struct UTUN_INSTANCE* instance) { if (chat_core_init(instance, db_file) < 0 || chat_sync_init(instance) < 0) goto fail; instance->media_async = media_async_create(); if (!instance->media_async || media_delivery_init(instance) < 0) goto fail; - if (dm_mailbox_init(instance) < 0 || dm_core_init(instance) < 0 || dm_media_init(instance) < 0 || call_init(instance) < 0) goto fail; + if (member_files_init(instance) < 0 || dm_mailbox_init(instance) < 0 || dm_core_init(instance) < 0 || + dm_media_init(instance) < 0 || call_init(instance) < 0) goto fail; chat_media_backfill(instance); chat_media_startup_backfill_init(instance); // Initialize headless chat control if configured in [chatserver] @@ -832,7 +834,9 @@ void chat_service_stop(struct UTUN_INSTANCE* instance) { call_headless_destroy(instance); radio_headless_destroy(instance); chat_media_startup_backfill_destroy(instance); dm_media_quiesce(instance); + member_files_quiesce(instance); media_async_destroy(instance->media_async); instance->media_async = NULL; + member_files_destroy(instance); media_delivery_destroy(instance); call_destroy(instance); dm_media_destroy(instance); dm_core_destroy(instance); dm_mailbox_destroy(instance); etcp_unbind(instance, ETCP_RT_ID_GROUP_INVITE); diff --git a/src/utun_instance.h b/src/utun_instance.h index 4e7b54fa..743209c6 100644 --- a/src/utun_instance.h +++ b/src/utun_instance.h @@ -228,6 +228,7 @@ struct UTUN_INSTANCE { struct dm_mb_state* dm_mailbox; // хранилище личных сообщений для получателей struct dm_media_state* dm_media; // собственные файлы и промежуточное ciphertext-хранилище DM struct file_transfer* file_transfer; // общий движок передачи файлов с CM-владением + struct member_files* member_files; // файловый сервис участников CHAT-групп struct call_ctx* call; // call/call.c — P2P audio call void* call_audio; // call/call_headless.c — audio stream socket struct radio_instance* radio; // radio/radio.c — PTT walkie-talkie (frame cb + counters) diff --git a/tests/Makefile.am b/tests/Makefile.am index dc10c6cf..5ed27baf 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -72,6 +72,8 @@ check_PROGRAMS = \ test_node_conn_direct \ test_group_ownership \ test_services \ + test_member_files \ + test_member_files_network \ test_group_exchange \ test_group_recovery \ test_group_connect \ @@ -717,6 +719,13 @@ test_services_SOURCES = test_services.c test_services_CFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/src/routing_layer -I$(top_srcdir)/src/transport_layer -I$(top_srcdir)/lib test_services_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) +test_member_files_SOURCES = test_member_files.c +test_member_files_CFLAGS = $(test_services_CFLAGS) +test_member_files_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) +test_member_files_network_SOURCES = test_member_files_network.c +test_member_files_network_CFLAGS = $(test_services_CFLAGS) +test_member_files_network_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) + # Граничные длины и checksum UDP/ICMP после восстановления пакета. test_proxy_packets_SOURCES = test_proxy_packets.c test_proxy_packets_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) diff --git a/tests/test_member_files.c b/tests/test_member_files.c new file mode 100644 index 00000000..dc1799a9 --- /dev/null +++ b/tests/test_member_files.c @@ -0,0 +1,175 @@ +/* Протокол и настоящее дисковое хранилище: атомарность, случайные диапазоны, + * версии, квота, повторы, ссылки и закрытие дескрипторов через 10 секунд. */ +#include "../src/member_files/storage.h" +#include "../src/member_files/disk.h" +#include "../src/member_files/worker.h" +#include "../lib/u_async.h" +#include "../lib/debug_config.h" +#include "test_utils.h" +#include +#include +#include +#include + +static int failures; +#define EXPECT(condition, name) do { if (!(condition)) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "FAIL: %s line=%d", name, __LINE__); failures++; } } while (0) +static struct mf_message q, r; +static uint64_t sequence; +static void run(struct mf_storage* s, uint64_t group, uint64_t peer, int command, int expected) { + q.command = (uint8_t)command; q.kind = MF_REQUEST; q.request_id = ++sequence; + mf_storage_execute(s, group, peer, &q, &r); + EXPECT(r.status == expected, mf_status_text(r.status)); +} + +static void protocol_test(void) { + static const char* invalid[] = { "../x", "a/../b", "/x", "a\\b", "a//b", "a/", "a/.x", "CON", "con.txt", "LPT1", "x:", "a ", "\xc0\xaf", "\xed\xa0\x80" }; + for (unsigned i = 0; i < sizeof(invalid) / sizeof(invalid[0]); i++) EXPECT(!mf_path_valid(invalid[i], 0), invalid[i]); + EXPECT(mf_path_valid("Документы/пример.webp", 0), "UTF-8 path"); + EXPECT(!mf_path_valid("", 0) && mf_path_valid("", 1), "root path"); + memset(&q, 0, sizeof(q)); q.kind = MF_REQUEST; q.command = MF_WRITE; q.request_id = 42; q.owner = UINT64_MAX; + q.offset = (1ULL << 40) + 7; q.size = q.data_len = MF_BLOCK_SIZE; + snprintf(q.path, sizeof(q.path), "Документы/пример.webp"); memset(q.data, 171, sizeof(q.data)); + uint8_t wire[MF_WIRE_MAX]; int n = mf_encode(&q, wire, sizeof(wire)); + EXPECT(n > 0 && !mf_decode(wire, (size_t)n, &r) && !memcmp(&q, &r, sizeof(q)), "wire roundtrip and 64-bit offset"); + EXPECT(mf_decode(wire, (size_t)n - 1, &r) != 0, "truncated packet"); + wire[MF_WIRE_HEADER] = 0; EXPECT(mf_decode(wire, (size_t)n, &r) != 0, "embedded NUL"); +} + +static void storage_test(const char* directory) { + struct mf_storage* s = mf_storage_create(directory, 40000, 200000); + EXPECT(s != NULL, "create storage"); if (!s) return; + uint8_t bytes[10001], version[16], upload[16]; + for (unsigned i = 0; i < sizeof(bytes); i++) bytes[i] = (uint8_t)(i * 73 + 19); + memset(&q, 0, sizeof(q)); q.owner = 77; + run(s, 55, 77, MF_LIST, MF_OK); EXPECT(!r.data_len, "empty lazy root"); + snprintf(q.path, sizeof(q.path), "пример.webp"); q.size = sizeof(bytes); memset(q.upload, 9, 16); memcpy(upload, q.upload, 16); + SHA256(bytes, sizeof(bytes), q.hash); + run(s, 55, 88, MF_BEGIN_WRITE, MF_ACCESS_DENIED); + run(s, 55, 77, MF_BEGIN_WRITE, MF_OK); + run(s, 55, 77, MF_BEGIN_WRITE, MF_OK); + run(s, 55, 88, MF_STAT, MF_NOT_FOUND); + q.offset = MF_BLOCK_SIZE; q.size = q.data_len = sizeof(bytes) - MF_BLOCK_SIZE; + memcpy(q.data, bytes + MF_BLOCK_SIZE, q.data_len); run(s, 55, 77, MF_WRITE, MF_OK); + q.offset = 0; q.size = q.data_len = MF_BLOCK_SIZE; memcpy(q.data, bytes, MF_BLOCK_SIZE); + run(s, 55, 77, MF_WRITE, MF_OK); run(s, 55, 77, MF_WRITE, MF_OK); + q.data_len = 0; run(s, 55, 77, MF_COMMIT, MF_OK); + memcpy(version, r.version, 16); EXPECT(r.size == sizeof(bytes), "published size"); + uint64_t commit_id = q.request_id; mf_storage_execute(s, 55, 77, &q, &r); EXPECT(r.status == MF_OK, "same commit replay"); + run(s, 55, 77, MF_COMMIT, MF_OK); EXPECT(q.request_id != commit_id, "upload commit replay with new request id"); + q.size = MF_BLOCK_SIZE; q.offset = 0; memset(q.version, 0, 16); + run(s, 55, 88, MF_READ, MF_STALE); + memcpy(q.version, version, 16); run(s, 55, 88, MF_READ, MF_OK); + EXPECT(r.data_len == MF_BLOCK_SIZE && !memcmp(r.data, bytes, MF_BLOCK_SIZE), "read by another member"); + EXPECT(mf_storage_open_count(s) == 1, "cached descriptor"); + uint64_t now = get_time_tb(); mf_storage_sweep(s, now + MF_IDLE_TB - 100); + EXPECT(mf_storage_open_count(s) == 1, "fd retained before 10 seconds"); + mf_storage_sweep(s, now + MF_IDLE_TB + 100); EXPECT(!mf_storage_open_count(s), "fd closed after 10 seconds"); + run(s, 55, 88, MF_READ, MF_OK); EXPECT(r.data_len == MF_BLOCK_SIZE, "reopen preserves file version"); + q.offset = sizeof(bytes) - 2; run(s, 55, 88, MF_READ, MF_OK); EXPECT(r.data_len == 2 && !r.more, "short final read"); + q.offset = sizeof(bytes); run(s, 55, 88, MF_READ, MF_OK); EXPECT(!r.data_len && !r.more, "EOF read"); + q.offset++; run(s, 55, 88, MF_READ, MF_INVALID_RANGE); + q.offset = 0; run(s, 56, 88, MF_STAT, MF_NOT_FOUND); + q.size = 30000; memset(q.upload, 10, 16); snprintf(q.path, sizeof(q.path), "big"); memset(q.version, 0, 16); + run(s, 55, 77, MF_BEGIN_WRITE, MF_NO_SPACE); + q.size = 1; memset(q.upload, 11, 16); snprintf(q.path, sizeof(q.path), "bad"); memset(q.hash, 0, 32); + run(s, 55, 77, MF_BEGIN_WRITE, MF_OK); run(s, 55, 77, MF_COMMIT, MF_HASH_MISMATCH); run(s, 55, 77, MF_ABORT, MF_OK); + run(s, 55, 77, MF_STAT, MF_NOT_FOUND); + q.size = 0; memset(q.upload, 12, 16); snprintf(q.path, sizeof(q.path), "empty"); SHA256(bytes, 0, q.hash); + run(s, 55, 77, MF_BEGIN_WRITE, MF_OK); run(s, 55, 77, MF_COMMIT, MF_OK); EXPECT(!r.size, "empty file"); + memset(&q, 0, sizeof(q)); q.owner = 77; snprintf(q.path, sizeof(q.path), "пример.webp"); memcpy(q.version, version, 16); + q.size = 2; memset(q.upload, 13, 16); SHA256(bytes, 2, q.hash); + run(s, 55, 77, MF_BEGIN_WRITE, MF_OK); + struct mf_message saved = q; + run(s, 55, 88, MF_STAT, MF_OK); EXPECT(r.size == sizeof(bytes), "old version visible during replacement"); + q = saved; q.size = q.data_len = 2; memcpy(q.data, bytes, 2); run(s, 55, 77, MF_WRITE, MF_OK); + q.data_len = 0; run(s, 55, 77, MF_COMMIT, MF_OK); EXPECT(r.size == 2 && memcmp(version, r.version, 16), "short replacement"); + q.offset = 0; q.size = MF_BLOCK_SIZE; memcpy(q.version, version, 16); run(s, 55, 88, MF_READ, MF_STALE); + memset(q.version, 0, 16); run(s, 55, 77, MF_STAT, MF_OK); memcpy(q.version, r.version, 16); + snprintf(q.target, sizeof(q.target), "renamed"); run(s, 55, 77, MF_RENAME, MF_OK); + run(s, 55, 77, MF_STAT, MF_NOT_FOUND); + snprintf(q.path, sizeof(q.path), "renamed"); memset(q.version, 0, 16); run(s, 55, 77, MF_STAT, MF_OK); + memcpy(q.version, r.version, 16); run(s, 55, 88, MF_REMOVE, MF_ACCESS_DENIED); run(s, 55, 77, MF_REMOVE, MF_OK); + snprintf(q.path, sizeof(q.path), "folder"); memset(q.version, 0, 16); run(s, 55, 77, MF_MKDIR, MF_OK); + q.path[0] = 0; run(s, 55, 88, MF_LIST, MF_OK); EXPECT(r.data_len > 0, "directory listing"); +#ifndef _WIN32 + char link[2048]; snprintf(link, sizeof(link), "%s/55/77/link", directory); + EXPECT(!symlink("/tmp", link), "create hostile symlink"); + snprintf(q.path, sizeof(q.path), "link/escape"); run(s, 55, 77, MF_MKDIR, MF_INVALID_PATH); + test_unlink(link); +#endif + snprintf(q.path, sizeof(q.path), "expired"); q.size = 10; memset(q.upload, 14, 16); memset(q.version, 0, 16); + run(s, 55, 77, MF_BEGIN_WRITE, MF_OK); mf_storage_sweep(s, get_time_tb() + 200001); + run(s, 55, 77, MF_WRITE, MF_NOT_FOUND); + mf_storage_destroy(s); + s = mf_storage_create(directory, 40000, 200000); + snprintf(q.path, sizeof(q.path), "empty"); q.data_len = 0; run(s, 55, 88, MF_STAT, MF_OK); EXPECT(r.size == 0, "restart retains published files"); + memset(&q, 0, sizeof(q)); q.owner = 77; + for (unsigned i = 0; i < 63; i++) { + int n = snprintf(q.path, sizeof(q.path), "dir_%02u_", i); + memset(q.path + n, 'a', 200); q.path[n + 200] = 0; run(s, 55, 77, MF_MKDIR, MF_OK); + } + q.path[0] = 0; run(s, 55, 88, MF_LIST, MF_OK); EXPECT(r.more && r.data_len <= MF_BLOCK_SIZE, "bounded directory page"); + struct mf_message page = q; page.cursor = r.cursor; memcpy(page.version, r.version, 16); + snprintf(q.path, sizeof(q.path), "fresh"); run(s, 55, 77, MF_MKDIR, MF_OK); + q = page; run(s, 55, 88, MF_LIST, MF_STALE); + memset(q.version, 0, 16); q.cursor = 0; + unsigned entries = 0, pages = 0; + do { + run(s, 55, 88, MF_LIST, MF_OK); pages++; + for (unsigned at = 0; at < r.data_len;) { + unsigned n = r.data[at] | ((unsigned)r.data[at + 1] << 8); + EXPECT(n && at + MF_ENTRY_HEADER + n <= r.data_len, "directory entry bounds"); + if (!n || at + MF_ENTRY_HEADER + n > r.data_len) break; + entries++; at += MF_ENTRY_HEADER + n; + } + q.cursor = r.cursor; memcpy(q.version, r.version, 16); + } while (r.more && pages < 8); + EXPECT(entries == 66 && pages >= 2 && !r.more, "all directory entries across pages"); + mf_storage_destroy(s); +} + +#ifndef _WIN32 +static void large_offset_test(const char* directory) { + char path[1200]; snprintf(path, sizeof(path), "%s/large-offset", directory); + int fd = -1; uint64_t end = (1ULL << 33) + 4; + EXPECT(mf_disk_local_open(path, 1, &fd) == MF_OK, "open sparse file"); if (fd < 0) return; + EXPECT(mf_disk_resize(fd, end) == MF_OK, "resize beyond 4 GiB"); + uint8_t data[4] = {1, 2, 3, 4}, read[4] = {0}; + EXPECT(mf_disk_write(fd, end - 4, data, 4) == MF_OK && mf_disk_read(fd, end - 4, read, 4) == MF_OK, + "disk I/O beyond 4 GiB"); + struct mf_disk_info info; + EXPECT(!mf_disk_info(fd, &info) && info.size == end && !memcmp(data, read, 4), "64-bit file size and bytes"); + mf_disk_close(fd); mf_disk_local_remove(path); +} +#endif + +struct worker_test { struct UASYNC* ua; struct mf_worker* worker; pthread_t caller; int ran, finished; }; +static void worker_work(void* arg) { + struct worker_test* t = arg; t->ran = !pthread_equal(t->caller, pthread_self()); +} +static void worker_done(void* arg, int error) { + struct worker_test* t = arg; t->finished = !error && pthread_equal(t->caller, pthread_self()); uasync_stop(t->ua); +} +static void worker_test(void) { + struct worker_test t = {0}; t.ua = uasync_create(); t.caller = pthread_self(); t.worker = mf_worker_create(t.ua); + EXPECT(t.worker != NULL, "worker start"); + if (t.worker) { + mf_worker_submit(t.worker, worker_work, &t, worker_done, &t); uasync_mainloop(t.ua); + EXPECT(t.ran && t.finished, "work on disk thread, done in uasync"); + t.ran = t.finished = 0; mf_worker_submit(t.worker, worker_work, &t, worker_done, &t); + mf_worker_destroy(t.worker); EXPECT(t.ran && t.finished, "shutdown drains queued completion"); + } + uasync_destroy(t.ua, 1); +} + +int main(void) { + debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); debug_set_category_level(DEBUG_CATEGORY_MEDIA, DEBUG_LEVEL_DEBUG); + char directory[1024] = "/tmp/utun_member_files_XXXXXX"; + if (test_mkdtemp(directory)) return 1; + protocol_test(); storage_test(directory); worker_test(); +#ifndef _WIN32 + large_offset_test(directory); +#endif + printf("member_files: %s failures=%d directory=%s\n", failures ? "FAIL" : "PASS", failures, directory); + return failures ? 1 : 0; +} diff --git a/tests/test_member_files_network.c b/tests/test_member_files_network.c new file mode 100644 index 00000000..3eb46502 --- /dev/null +++ b/tests/test_member_files_network.c @@ -0,0 +1,186 @@ +/* Три настоящих ядра A/B/M: подписанный storage, wire-запросы, загрузка A, + * скачивание B, запрет чужой записи, удалённого участника и restart сервиса. */ +#include "../src/member_files/member_files.h" +#include "../src/utun_instance.h" +#include "../src/chat/chat_core.h" +#include "../src/chat/chat_event.h" +#include "../src/chat/member_sync.h" +#include "../src/routing_layer/topo_node_sqlite.h" +#include "../src/routing_layer/etcp_router.h" +#include "../src/transport_layer/etcp.h" +#include "../src/transport_layer/secure_channel.h" +#include "../lib/debug_config.h" +#include "../lib/mem.h" +#include "test_utils.h" +#include +#include +#include + +#define GROUP 5858585858585858ULL +#define CHANNEL "5858585858585858" +#define BYTES (3 * MF_BLOCK_SIZE + 37) +enum { AUTHOR, READER, SERVER, NODE_COUNT }; +struct network_test { + struct UASYNC* ua; + struct UTUN_INSTANCE* nodes[NODE_COUNT]; + struct SC_MYKEYS keys[NODE_COUNT]; + uint64_t ids[NODE_COUNT]; + uint8_t ed[NODE_COUNT][32], channel_x[32], channel_ed[32], channel_private[32]; + char dir[512], source[768], destination[768]; + struct mf_message response; + unsigned phase, ticks, progress; + int answered, finished, transfer_status, failed; + void* timer; +}; +static struct network_test* active; + +static void fail(struct network_test* t, const char* why) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files network: FAIL phase=%u reason=%s", t->phase, why); + t->failed = 1; uasync_stop(t->ua); +} +static void hex(const uint8_t* input, char* out) { for (unsigned i = 0; i < 32; i++) snprintf(out + 2 * i, 3, "%02x", input[i]); } + +static void event(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len) { + (void)inst; + struct network_test* t = active; + if (type != CHAT_EVT_MEMBER_FILES_TRANSFER || len != 51) return; + t->progress++; + if (data[50] == 2) { t->finished = 1; t->transfer_status = data[48] | ((int)data[49] << 8); } +} + +static void response(void* arg, const struct mf_message* r) { + struct network_test* t = arg; t->response = *r; t->answered = 1; +} + +static int put_member(struct network_test* t, int destination, int member, int deleted) { + uint8_t msg[512], join[64], tags_sig[64], tree[64]; + uint64_t ts = 100000; + int n = member_sync_build_join_msg(t->channel_x, t->channel_ed, t->ids[member], t->keys[member].public_key, ts, msg, sizeof(msg)); + if (n < 0 || sc_ed25519_sign(t->nodes[member]->my_ed25519_privkey, msg, n, join) != SC_OK) return -1; + const char* tags = deleted ? "{\"deleted\":\"yes\",\"ver\":\"2\"}" : + member == SERVER ? "{\"storage\":\"yes\",\"ver\":\"1\"}" : "{\"ver\":\"1\"}"; + n = member_sync_build_owner_msg(t->ids[member], tags, msg, sizeof(msg)); + if (n < 0 || sc_ed25519_sign(t->channel_private, msg, n, tags_sig) != SC_OK || + member_sync_sign_pubkey(t->channel_private, t->keys[member].public_key, tree)) return -1; + char name[64]; snprintf(name, sizeof(name), "{\"name\":\"node%d\"}", member); + return member_sync_put(t->nodes[destination], CHANNEL, t->ids[member], t->keys[member].public_key, t->ed[member], + join, ts, NULL, 0, name, tags, tags_sig, member == SERVER, 0, tree) < 0 ? -1 : 0; +} + +static int start(struct network_test* t, int index, int port) { + char pub[65], priv[65], server[65], path[768], db[768]; + hex(t->keys[index].public_key, pub); hex(t->keys[index].private_key, priv); hex(t->keys[SERVER].public_key, server); + snprintf(path, sizeof(path), "%s/node%d.conf", t->dir, index); snprintf(db, sizeof(db), "%s/db%d", t->dir, index); + FILE* f = fopen(path, "w"); if (!f) return -1; + fprintf(f, "[global]\nmy_private_key=%s\nmy_public_key=%s\ndb_path=%s\n" + "[server:s1]\naddr=127.0.0.1:%d\ntype=public\n[allowed_keys]\nallow_all=1\n" + "[chatserver]\nstorage_autoload=0\nmember_files_quota_mb=8\n", priv, pub, db, port + index); + if (index != SERVER) fprintf(f, "[client:to_m]\npeer_public_key=%s\nlink=s1:127.0.0.1:%d\n", server, port + SERVER); + fclose(f); + t->nodes[index] = utun_instance_create(t->ua, path); + if (!t->nodes[index] || utun_instance_init(t->nodes[index])) return -1; + memcpy(t->ed[index], t->nodes[index]->my_ed25519_pubkey, 32); t->ids[index] = t->nodes[index]->node_id; + chat_event_set_handler(t->nodes[index], event); return 0; +} + +static int setup(struct network_test* t) { + struct SC_MYKEYS keys; sc_generate_keypair(&keys); memcpy(t->channel_x, keys.public_key, 32); + uint8_t seed[SHA512_DIGEST_LENGTH]; SHA512(keys.private_key, 32, seed); memcpy(t->channel_private, seed, 32); + if (sc_derive_ed25519_pubkey(keys.private_key, t->channel_ed)) return -1; + uint8_t signature[64], message[256]; size_t at = sizeof(CHANNEL); + memcpy(message, CHANNEL, at); const char name[] = "member-files"; + memcpy(message + at, name, sizeof(name)); at += sizeof(name); memcpy(message + at, &t->ids[AUTHOR], 8); at += 8; + memcpy(message + at, t->channel_x, 32); at += 32; memcpy(message + at, t->channel_ed, 32); at += 32; + if (sc_ed25519_sign(t->channel_private, message, at, signature)) return -1; + for (unsigned i = 0; i < NODE_COUNT; i++) { + if (topo_node_sqlite_channel_put(t->nodes[i]->topo_sqlite_db, CHANNEL, "member-files", t->ids[AUTHOR], + t->channel_x, NULL, t->channel_ed, i == AUTHOR ? t->channel_private : NULL, signature)) return -1; + chat_core_ensure_channel_ready(t->nodes[i], CHANNEL); + for (unsigned j = 0; j < NODE_COUNT; j++) if (put_member(t, i, j, 0)) return -1; + } + snprintf(t->source, sizeof(t->source), "%s/source.bin", t->dir); + snprintf(t->destination, sizeof(t->destination), "%s/download.bin", t->dir); + FILE* file = fopen(t->source, "wb"); if (!file) return -1; + for (unsigned i = 0; i < BYTES; i++) if (fputc((i * 73 + 19) & 255, file) == EOF) { fclose(file); return -1; } + return fclose(file); +} + +static void transfer(struct network_test* t, int upload, uint64_t id) { + struct mf_transfer_request* q = u_calloc(1, sizeof(*q)); + if (!q) { fail(t, "transfer allocation"); return; } + q->inst = t->nodes[upload ? AUTHOR : READER]; q->group = GROUP; q->server = t->ids[SERVER]; q->owner = t->ids[AUTHOR]; + q->id = id; q->upload = upload; + snprintf(q->remote, sizeof(q->remote), "file.bin"); snprintf(q->local, sizeof(q->local), "%s", upload ? t->source : t->destination); + t->finished = 0; member_files_gui_transfer(q); +} + +static void request(struct network_test* t, uint8_t command, uint64_t owner) { + struct mf_message q = { .command = command, .owner = owner }; + if (owner) snprintf(q.path, sizeof(q.path), "file.bin"); + t->answered = 0; + if (!member_files_request(t->nodes[READER], GROUP, t->ids[SERVER], &q, response, t)) fail(t, "request start"); +} + +static int downloaded(struct network_test* t) { + FILE* file = fopen(t->destination, "rb"); if (!file) return 0; + int ok = 1; + for (unsigned i = 0; i < BYTES; i++) if (fgetc(file) != (int)((i * 73 + 19) & 255)) { ok = 0; break; } + if (fgetc(file) != EOF) ok = 0; + fclose(file); return ok; +} + +static void tick(void* arg) { + struct network_test* t = arg; t->timer = NULL; + if (++t->ticks > 6000) { fail(t, "60s deadline"); return; } + if (t->phase == 0) { + int a = etcp_router_get_path(t->nodes[AUTHOR], GROUP, t->ids[SERVER], ETCP_RT_ID_MEMBER_FILES).next_hop_node_id != 0; + int b = etcp_router_get_path(t->nodes[READER], GROUP, t->ids[SERVER], ETCP_RT_ID_MEMBER_FILES).next_hop_node_id != 0; + if (a && b) { t->phase = 1; transfer(t, 1, 1); } + } else if (t->phase == 1 && t->finished) { + if (t->transfer_status) { fail(t, mf_status_text(t->transfer_status)); return; } + t->phase = 2; request(t, MF_STAT, t->ids[AUTHOR]); + } else if (t->phase == 2 && t->answered) { + if (t->response.status || t->response.size != BYTES) { fail(t, "STAT after upload"); return; } + t->phase = 3; request(t, MF_BEGIN_WRITE, t->ids[AUTHOR]); + } else if (t->phase == 3 && t->answered) { + if (t->response.status != MF_ACCESS_DENIED) { fail(t, "foreign write accepted"); return; } + t->phase = 4; transfer(t, 0, 2); + } else if (t->phase == 4 && t->finished) { + if (t->transfer_status || !downloaded(t)) { fail(t, "download bytes"); return; } + t->phase = 5; request(t, MF_LIST, 0); + } else if (t->phase == 5 && t->answered) { + if (t->response.status || !t->response.data_len) { fail(t, "virtual group root"); return; } + t->phase = 6; t->answered = 0; + struct mf_message q = { .command = MF_STAT, .owner = t->ids[AUTHOR] }; + snprintf(q.path, sizeof(q.path), "file.bin"); + if (!member_files_request(t->nodes[SERVER], GROUP, t->ids[SERVER], &q, response, t)) { fail(t, "local server request"); return; } + } else if (t->phase == 6 && t->answered) { + if (t->response.status || t->response.size != BYTES) { fail(t, "local server STAT"); return; } + if (put_member(t, SERVER, READER, 1)) { fail(t, "signed deletion"); return; } + t->phase = 7; request(t, MF_STAT, t->ids[AUTHOR]); + } else if (t->phase == 7 && t->answered) { + if (t->response.status != MF_ACCESS_DENIED) { fail(t, "deleted reader accepted"); return; } + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "member_files network: PASS progress=%u ticks=%u", t->progress, t->ticks); + uasync_stop(t->ua); return; + } + t->timer = uasync_set_timeout(t->ua, 100, t, tick, "member_files_test"); +} + +int main(void) { + debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); debug_set_category_level(DEBUG_CATEGORY_MEDIA, DEBUG_LEVEL_DEBUG); + utun_instance_set_tun_init_enabled(0); + struct network_test t = {0}; active = &t; + snprintf(t.dir, sizeof(t.dir), "/tmp/utun_member_files_net_XXXXXX"); + if (test_mkdtemp(t.dir)) return 1; + t.ua = uasync_create(); + for (unsigned i = 0; i < NODE_COUNT; i++) sc_generate_keypair(&t.keys[i]); + int port = 39000 + (int)(get_time_tb() % 8000); + for (unsigned i = 0; i < NODE_COUNT; i++) if (start(&t, i, port)) { t.failed = 1; goto stop; } + if (setup(&t)) { t.failed = 1; goto stop; } + t.timer = uasync_set_timeout(t.ua, 100, &t, tick, "member_files_test"); uasync_mainloop(t.ua); +stop: + if (t.timer) uasync_cancel_timeout(t.ua, t.timer); + for (unsigned i = 0; i < NODE_COUNT; i++) if (t.nodes[i]) utun_instance_destroy(t.nodes[i]); + uasync_destroy(t.ua, 0); + printf("member_files network: %s phase=%u\n", t.failed ? "FAIL" : "PASS", t.phase); return t.failed; +} diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 7ae97e18..1b5b7f23 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -177,6 +177,7 @@ add_executable(vibechat src/videoplayer_engine.cpp src/videoplayer_window.cpp src/imageviewer_window.cpp + src/member_files/browser.cpp src/audiodevicesettingspage.cpp src/storagesettingspage.cpp src/connmonitorwindow.cpp @@ -265,6 +266,11 @@ endif() enable_testing() +add_executable(test_member_files_browser tests/test_member_files_browser.cpp src/member_files/browser.cpp db/db_manager.cpp) +target_link_libraries(test_member_files_browser PRIVATE ${QT_LIBS} ${QT_TEST_LIB} utun pthread) +add_test(NAME test_member_files_browser COMMAND test_member_files_browser) +set_tests_properties(test_member_files_browser PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 30) + add_executable(test_gui_language tests/test_gui_language.cpp src/networksettingspage.cpp transport/node_config.cpp transport/config_updater.cpp "${CMAKE_CURRENT_BINARY_DIR}/translations.qrc") add_dependencies(test_gui_language chatgui_translations) diff --git a/tools/chatgui/src/accountlist.cpp b/tools/chatgui/src/accountlist.cpp index 0f1b8dd0..dc926878 100644 --- a/tools/chatgui/src/accountlist.cpp +++ b/tools/chatgui/src/accountlist.cpp @@ -492,7 +492,9 @@ void AccountList::onMemberContextMenu(const QPoint& pos) { QAction* renameAction = menu.addAction(QCoreApplication::translate("ChatGui", "Rename")); if (nodeId == m_db->myNodeId()) renameAction->setEnabled(false); QAction* editAction = menu.addAction(QCoreApplication::translate("ChatGui", "Edit")); + QAction* filesAction = menu.addAction(QCoreApplication::translate("ChatGui", "Member files")); QAction* chosen = menu.exec(m_listView->viewport()->mapToGlobal(pos)); + if (chosen == filesAction) { emit filesRequested(m_channelId, nodeId); return; } if (chosen == pmAction) { emit pmRequested(m_channelId, nodeId, name); diff --git a/tools/chatgui/src/accountlist.h b/tools/chatgui/src/accountlist.h index 7c1d7280..0173785e 100644 --- a/tools/chatgui/src/accountlist.h +++ b/tools/chatgui/src/accountlist.h @@ -72,6 +72,7 @@ signals: void callRequested(const QString& channelId, quint64 nodeId, const QString& name); void pmRequested(const QString& channelId, quint64 nodeId, const QString& name); void inviteRequested(const QString& channelId, quint64 nodeId, const QString& name); + void filesRequested(const QString& channelId, quint64 nodeId); private slots: void onMemberClicked(const QModelIndex& index); diff --git a/tools/chatgui/src/channellist.cpp b/tools/chatgui/src/channellist.cpp index 2d68cca4..a9b21ba4 100644 --- a/tools/chatgui/src/channellist.cpp +++ b/tools/chatgui/src/channellist.cpp @@ -9,6 +9,7 @@ #include #include #include +#include extern "C" { #include "../../lib/debug_config.h" @@ -222,6 +223,15 @@ ChannelList::ChannelList(DbManager* db, QWidget *parent) this, &ChannelList::inviteByLinkRequested); connect(static_cast(m_listView), &ChannelListView::settingsClicked, this, &ChannelList::channelSettingsRequested); + m_listView->setContextMenuPolicy(Qt::CustomContextMenu); + connect(m_listView, &QWidget::customContextMenuRequested, this, [this](const QPoint& point) { + auto index = m_listView->indexAt(point); + QString channel = index.data(ChannelChannelIdRole).toString(); + if (!index.isValid() || channel.isEmpty() || channel.startsWith(QStringLiteral("dm:"))) return; + QMenu menu(this); + auto* files = menu.addAction(QCoreApplication::translate("ChatGui", "Group files")); + if (menu.exec(m_listView->viewport()->mapToGlobal(point)) == files) emit filesRequested(channel); + }); } void ChannelList::refreshDmPresence() { diff --git a/tools/chatgui/src/channellist.h b/tools/chatgui/src/channellist.h index 764de1ba..c6568d91 100644 --- a/tools/chatgui/src/channellist.h +++ b/tools/chatgui/src/channellist.h @@ -31,6 +31,7 @@ signals: void inviteRequested(const QString& channelId); void inviteByLinkRequested(const QString& channelId); void channelSettingsRequested(const QString& channelId); + void filesRequested(const QString& channelId); void joinRequested(); void createGroupRequested(); void settingsRequested(); diff --git a/tools/chatgui/src/mainwindow.cpp b/tools/chatgui/src/mainwindow.cpp index f087b4ed..fd5d806c 100644 --- a/tools/chatgui/src/mainwindow.cpp +++ b/tools/chatgui/src/mainwindow.cpp @@ -14,6 +14,7 @@ #include "connmonitorwindow.h" #include "videoplayer_window.h" #include "imageviewer_window.h" +#include "member_files/browser.h" #include "creategroupdialog.h" #include "invite_link.h" #include "sound_manager.h" @@ -172,6 +173,9 @@ MainWindow::MainWindow(QWidget *parent, DbManager* db, const QString& cfgPath, MainWindow::~MainWindow() { s_mainWindow = nullptr; + if (m_fileBrowser) delete m_fileBrowser.data(); + gui_bridge_set_member_files_response_cb(nullptr); + gui_bridge_set_member_files_transfer_cb(nullptr); m_autoPlayTimer->stop(); gui_bridge_set_network_changed_cb(nullptr); gui_bridge_set_dm_call_route_cb(nullptr); @@ -383,6 +387,12 @@ void MainWindow::setupSoundFromConfig() { void MainWindow::setupBridgeCallbacks() { s_mainWindow = this; + gui_bridge_set_member_files_response_cb([](const uint8_t* data, int length) { + if (s_mainWindow && s_mainWindow->m_fileBrowser) s_mainWindow->m_fileBrowser->onResponse(data, length); + }); + gui_bridge_set_member_files_transfer_cb([](const uint8_t* data, int length) { + if (s_mainWindow && s_mainWindow->m_fileBrowser) s_mainWindow->m_fileBrowser->onTransfer(data, length); + }); gui_bridge_set_msg_received_cb(onMsgReceivedCallback); gui_bridge_set_channel_updated_cb(onChannelUpdatedCallback); gui_bridge_set_member_list_cb(onMemberListCallback); @@ -548,6 +558,8 @@ void MainWindow::setupConnects() { connect(m_channelList, &ChannelList::channelSettingsRequested, this, &MainWindow::showChannelSettings); connect(m_accountList, &AccountList::memberRenamed, m_messageList, &MessageList::reloadLocalNicks); + connect(m_accountList, &AccountList::filesRequested, this, &MainWindow::showMemberFiles); + connect(m_channelList, &ChannelList::filesRequested, this, [this](const QString& channel) { showMemberFiles(channel); }); connect(m_accountList, &AccountList::callRequested, this, &MainWindow::onCallRequested); connect(m_messageList, &MessageList::callRequested, this, &MainWindow::onCallRequested); connect(m_accountList, &AccountList::pmRequested, this, &MainWindow::onPmRequested); @@ -567,6 +579,12 @@ void MainWindow::setupConnects() { /* ── звонки ── */ +void MainWindow::showMemberFiles(const QString& channel, quint64 owner) { + if (channel.startsWith(QStringLiteral("dm:"))) return; + if (!m_fileBrowser) m_fileBrowser = new MemberFileBrowser(m_db, this); + m_fileBrowser->setChannel(channel, owner); m_fileBrowser->show(); m_fileBrowser->raise(); m_fileBrowser->activateWindow(); +} + void MainWindow::onCallRequested(const QString& chId, quint64 nodeId, const QString& name) { if (chId.startsWith(QStringLiteral("dm:")) && !m_activeCalls.isEmpty()) { auto* w = m_activeCalls.constBegin().value(); diff --git a/tools/chatgui/src/mainwindow.h b/tools/chatgui/src/mainwindow.h index 37fd5d14..baa62592 100644 --- a/tools/chatgui/src/mainwindow.h +++ b/tools/chatgui/src/mainwindow.h @@ -9,6 +9,7 @@ #include #include #include +#include class ChannelList; class MessageList; @@ -22,6 +23,7 @@ class VideoPlayerWindow; class ImageViewerWindow; class CallWindow; class CallAudioEngine; +class MemberFileBrowser; struct AutoPlayItem { QString channelId; @@ -98,6 +100,7 @@ private: void onShutdownCheck(); void autoPlayCheckNewMessage(const QString& channelId); QString autoPlayMediaDir() const; + void showMemberFiles(const QString& channel, quint64 owner = 0); ChannelList *m_channelList; MessageList *m_messageList; @@ -118,6 +121,7 @@ private: ConnMonitorWindow *m_connMonitor = nullptr; VideoPlayerWindow *m_videoPlayer = nullptr; ImageViewerWindow *m_imageViewer = nullptr; + QPointer m_fileBrowser; QQueue m_autoPlayQueue; QTimer* m_autoPlayTimer = nullptr; diff --git a/tools/chatgui/src/member_files/browser.cpp b/tools/chatgui/src/member_files/browser.cpp new file mode 100644 index 00000000..7926f6c8 --- /dev/null +++ b/tools/chatgui/src/member_files/browser.cpp @@ -0,0 +1,301 @@ +#include "browser.h" +#include "../../db/db_manager.h" +#include "../../transport/gui_bridge.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +extern "C" { +#include "member_files/member_files.h" +#include "mem.h" +#include "debug_config.h" +#include "json_flat.h" +} + +static QString text(const char* value) { return QCoreApplication::translate("ChatGui", value); } +static quint64 requestId() { quint64 id; do { id = QRandomGenerator::global()->generate64(); } while (!id); return id; } +static bool copyPath(char* out, const QString& value) { + QByteArray bytes = value.toUtf8(); + if (bytes.size() > MF_PATH_MAX || bytes.contains('\0')) return false; + memcpy(out, bytes.constData(), bytes.size()); out[bytes.size()] = 0; return true; +} + +MemberFileBrowser::MemberFileBrowser(DbManager* db, QWidget* parent) : QDialog(parent), m_db(db) { + setAttribute(Qt::WA_DeleteOnClose); setWindowTitle(text("Group files")); resize(940, 600); + auto* layout = new QVBoxLayout(this); + auto* selectors = new QHBoxLayout; + m_servers = new QComboBox(this); m_owners = new QComboBox(this); + selectors->addWidget(new QLabel(text("Media server:"), this)); selectors->addWidget(m_servers, 1); + selectors->addWidget(new QLabel(text("Member:"), this)); selectors->addWidget(m_owners, 1); layout->addLayout(selectors); + auto* navigation = new QHBoxLayout; + auto* up = new QPushButton(text("Up"), this); auto* reload = new QPushButton(text("Refresh"), this); + m_location = new QLineEdit(this); m_location->setPlaceholderText(text("Path inside the member directory")); + navigation->addWidget(up); navigation->addWidget(m_location, 1); navigation->addWidget(reload); layout->addLayout(navigation); + m_entries = new QTreeWidget(this); m_entries->setRootIsDecorated(false); + m_entries->setHeaderLabels({text("Name"), text("Type"), text("Size"), text("Modified")}); + m_entries->header()->setSectionResizeMode(0, QHeaderView::Stretch); + m_entries->setSelectionMode(QAbstractItemView::SingleSelection); layout->addWidget(m_entries, 1); + auto* actions = new QHBoxLayout; + m_upload = new QPushButton(text("Upload…"), this); m_download = new QPushButton(text("Download…"), this); + m_mkdir = new QPushButton(text("New folder…"), this); m_rename = new QPushButton(text("Rename…"), this); + m_remove = new QPushButton(text("Delete"), this); m_cancel = new QPushButton(text("Cancel transfer"), this); + for (auto* button : {m_upload, m_download, m_mkdir, m_rename, m_remove, m_cancel}) actions->addWidget(button); + layout->addLayout(actions); m_status = new QLabel(this); layout->addWidget(m_status); + m_progress = new QProgressBar(this); m_progress->setRange(0, 1000); m_progress->hide(); layout->addWidget(m_progress); + connect(reload, &QPushButton::clicked, this, [this]() { m_staleRetries = 0; refresh(); }); + connect(up, &QPushButton::clicked, this, [this]() { + if (m_path.isEmpty()) { m_owners->setCurrentIndex(0); return; } + int slash = m_path.lastIndexOf('/'); navigate(slash < 0 ? QString() : m_path.left(slash)); + }); + connect(m_location, &QLineEdit::returnPressed, this, [this]() { navigate(m_location->text()); }); + connect(m_servers, QOverload::of(&QComboBox::currentIndexChanged), this, [this]() { refresh(); }); + connect(m_owners, QOverload::of(&QComboBox::currentIndexChanged), this, [this]() { navigate(QString()); }); + connect(m_entries, &QTreeWidget::itemDoubleClicked, this, [this](QTreeWidgetItem* item) { enter(item); }); + connect(m_entries, &QTreeWidget::itemSelectionChanged, this, &MemberFileBrowser::updateActions); + connect(m_upload, &QPushButton::clicked, this, &MemberFileBrowser::upload); + connect(m_download, &QPushButton::clicked, this, [this]() { download(false); }); + connect(m_mkdir, &QPushButton::clicked, this, [this]() { mutate(MF_MKDIR); }); + connect(m_rename, &QPushButton::clicked, this, [this]() { mutate(MF_RENAME); }); + connect(m_remove, &QPushButton::clicked, this, [this]() { mutate(MF_REMOVE); }); + connect(m_cancel, &QPushButton::clicked, this, &MemberFileBrowser::cancel); + m_entries->setContextMenuPolicy(Qt::CustomContextMenu); + connect(m_entries, &QWidget::customContextMenuRequested, this, [this](const QPoint& point) { + auto* item = m_entries->itemAt(point); if (!item) return; + m_entries->setCurrentItem(item); QMenu menu(this); + auto* open = menu.addAction(text("Open")); auto* info = menu.addAction(text("Properties")); + auto* selected = menu.exec(m_entries->viewport()->mapToGlobal(point)); + if (selected == open) enter(item); + else if (selected == info && owner()) { + mf_message q{}; q.command = MF_STAT; q.owner = owner(); + if (copyPath(q.path, selectedPath()) && post(q)) m_actions.insert(q.request_id, MF_STAT); + } + }); + updateActions(); +} + +MemberFileBrowser::~MemberFileBrowser() { + cancel(); cancelRequest(m_listId); + for (auto id : m_actions.keys()) cancelRequest(id); +} + +void MemberFileBrowser::setChannel(const QString& channel, quint64 selectedOwner) { + bool valid = false; quint64 group = channel.toULongLong(&valid); + if (!valid || !group || !m_db) return; + cancelRequest(m_listId); m_listId = 0; m_group = group; m_path.clear(); + m_servers->blockSignals(true); m_owners->blockSignals(true); m_servers->clear(); m_owners->clear(); + m_owners->addItem(text("All members"), QVariant::fromValue(0)); + QString sql = QString("SELECT node_id,adm_tags,length(adm_tags_sig) FROM peers_%1 WHERE deleted=0 AND source=0 ORDER BY node_id").arg(group); + sqlite3_stmt* st = m_db->prepareOrNull(sql.toUtf8().constData()); + if (st) { + int rc; + while ((rc = sqlite3_step(st)) == SQLITE_ROW) { + quint64 id = (quint64)sqlite3_column_int64(st, 0); + QString label = QString("%1 [%2]").arg(m_db->getDisplayName(id), QString::number(id)); + m_owners->addItem(label, QVariant::fromValue(id)); + const char* tags = (const char*)sqlite3_column_text(st, 1); char role[16]; + if (tags && json_flat_get(tags, "storage", role, sizeof(role)) == 0 && !strcmp(role, "yes") && sqlite3_column_int(st, 2) == 64) + m_servers->addItem(label, QVariant::fromValue(id)); + } + if (rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "file browser: member query failed rc=%d", rc); + sqlite3_finalize(st); + } + int at = m_owners->findData(QVariant::fromValue(selectedOwner)); if (at >= 0) m_owners->setCurrentIndex(at); + m_servers->blockSignals(false); m_owners->blockSignals(false); m_staleRetries = 0; refresh(); +} + +quint64 MemberFileBrowser::owner() const { return m_owners->currentData().toULongLong(); } +quint64 MemberFileBrowser::server() const { return m_servers->currentData().toULongLong(); } + +QString MemberFileBrowser::selectedPath() const { + auto* item = m_entries->currentItem(); if (!item) return QString(); + QString name = item->data(0, Qt::UserRole).toString(); return m_path.isEmpty() ? name : m_path + '/' + name; +} +QByteArray MemberFileBrowser::selectedVersion() const { + auto* item = m_entries->currentItem(); return item ? item->data(0, Qt::UserRole + 2).toByteArray() : QByteArray(); +} + +void MemberFileBrowser::updateActions() { + auto* item = m_entries->currentItem(); bool own = owner() && owner() == m_db->myNodeId(); + bool available = server() && gui_bridge_is_uasync_ready(); + bool file = item && item->data(0, Qt::UserRole + 1).toInt() == MF_FILE; + m_upload->setEnabled(available && own && !m_transferId); m_mkdir->setEnabled(available && own); + m_rename->setEnabled(available && own && item); m_remove->setEnabled(available && own && item); + m_download->setEnabled(available && owner() && file && !m_transferId); m_cancel->setEnabled(m_transferId != 0); +} + +bool MemberFileBrowser::post(mf_message& message) { + if (!m_group || !server() || !gui_bridge_is_uasync_ready()) return false; + auto* q = (mf_gui_request*)u_calloc(1, sizeof(mf_gui_request)); + if (!q) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "file browser: request allocation failed"); return false; } + message.request_id = requestId(); message.kind = MF_REQUEST; + q->inst = gui_bridge_get_inst(); q->group = m_group; q->server = server(); q->message = message; + gui_bridge_post_uasync_fn(member_files_gui_request, q); return true; +} + +void MemberFileBrowser::cancelRequest(quint64 id) { + if (!id || !gui_bridge_is_uasync_ready()) return; + auto* q = (mf_cancel_request*)u_calloc(1, sizeof(mf_cancel_request)); + if (!q) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "file browser: cancel allocation failed"); return; } + q->inst = gui_bridge_get_inst(); q->id = id; gui_bridge_post_uasync_fn(member_files_gui_cancel, q); +} + +void MemberFileBrowser::refresh() { + cancelRequest(m_listId); m_listId = 0; m_entries->clear(); m_location->setText(m_path); updateActions(); + if (!server()) { m_status->setText(text("No media server in this group")); return; } + mf_message q{}; q.command = MF_LIST; q.owner = owner(); + if (!copyPath(q.path, m_path) || !post(q)) { m_status->setText(text("Unable to request directory")); return; } + m_listId = q.request_id; m_entries->setSortingEnabled(false); m_status->setText(text("Loading directory…")); +} + +void MemberFileBrowser::navigate(const QString& path) { + QByteArray bytes = path.toUtf8(); + if (!mf_path_valid(bytes.constData(), 1) || (!owner() && !path.isEmpty())) { + m_status->setText(text("Invalid path")); return; + } + m_path = path; m_staleRetries = 0; refresh(); +} + +void MemberFileBrowser::enter(QTreeWidgetItem* item) { + if (item->data(0, Qt::UserRole + 1).toInt() == MF_FILE) { download(true); return; } + if (!owner()) { + int at = m_owners->findData(QVariant::fromValue(item->data(0, Qt::UserRole).toString().toULongLong())); + if (at >= 0) m_owners->setCurrentIndex(at); + } else navigate(selectedPath()); +} + +void MemberFileBrowser::onResponse(const uint8_t* data, int length) { + if (length < 16 || mf_get64(data) != m_group || mf_get64(data + 8) != server()) return; + mf_message r{}; if (mf_decode(data + 16, (size_t)length - 16, &r)) return; + if (r.request_id == m_listId && r.owner == owner() && QString::fromUtf8(r.path) == m_path) { + if (r.status) { + m_listId = 0; + if (r.status == MF_STALE && m_staleRetries++ < 1) { refresh(); return; } + m_entries->clear(); m_status->setText(QString::fromUtf8(mf_status_text(r.status))); return; + } + for (unsigned at = 0; at < r.data_len;) { + if (r.data_len - at < MF_ENTRY_HEADER) { m_status->setText(text("Invalid directory response")); return; } + const uint8_t* p = r.data + at; unsigned n = p[0] | ((unsigned)p[1] << 8); + if (!n || n > MF_NAME_MAX || n > r.data_len - at - MF_ENTRY_HEADER || (p[2] != MF_FILE && p[2] != MF_DIRECTORY)) { + m_status->setText(text("Invalid directory response")); return; + } + QString name = QString::fromUtf8((const char*)p + MF_ENTRY_HEADER, (int)n); + QString label = owner() ? name : QString("%1 [%2]").arg(m_db->getDisplayName(name.toULongLong()), name); + auto* item = new QTreeWidgetItem(m_entries, {label, p[2] == MF_DIRECTORY ? text("Folder") : text("File"), + p[2] == MF_FILE ? QString::number(mf_get64(p + 3)) : QString(), + mf_get64(p + 11) ? QDateTime::fromSecsSinceEpoch((qint64)mf_get64(p + 11)).toString(Qt::ISODate) : QString()}); + item->setData(0, Qt::UserRole, name); item->setData(0, Qt::UserRole + 1, p[2]); + item->setData(0, Qt::UserRole + 2, QByteArray((const char*)p + 19, 16)); item->setToolTip(0, name); + at += MF_ENTRY_HEADER + n; + } + if (r.more) { + mf_message q{}; q.command = MF_LIST; q.owner = owner(); q.cursor = r.cursor; + memcpy(q.version, r.version, 16); copyPath(q.path, m_path); + if (post(q)) m_listId = q.request_id; + else { m_listId = 0; m_status->setText(text("Unable to request directory")); } + } else { + m_listId = 0; m_entries->setSortingEnabled(true); m_entries->sortItems(0, Qt::AscendingOrder); + m_status->setText(text("%1 entries").arg(m_entries->topLevelItemCount())); updateActions(); + } + return; + } + if (!m_actions.contains(r.request_id)) return; + uint8_t command = m_actions.take(r.request_id); + if (r.status && r.status != MF_CANCELLED) { QMessageBox::warning(this, text("Group files"), QString::fromUtf8(mf_status_text(r.status))); return; } + if (command == MF_STAT && !r.status) { + QMessageBox::information(this, text("Properties"), QString("%1\n%2: %3\n%4: %5\nUID: %6") + .arg(QString::fromUtf8(r.path), text("Size"), QString::number(r.size), text("Modified"), + QDateTime::fromSecsSinceEpoch((qint64)r.mtime).toString(Qt::ISODate), QString::number(r.owner))); + } else refresh(); +} + +void MemberFileBrowser::upload() { + if (m_transferId || !owner() || owner() != m_db->myNodeId()) return; + QString local = QFileDialog::getOpenFileName(this, text("Upload file")); if (local.isEmpty()) return; + QString name = QFileInfo(local).fileName(); QByteArray version(16, '\0'); + for (int i = 0; i < m_entries->topLevelItemCount(); i++) { + auto* item = m_entries->topLevelItem(i); + if (item->data(0, Qt::UserRole).toString() != name) continue; + if (item->data(0, Qt::UserRole + 1).toInt() != MF_FILE) { m_status->setText(text("A folder with this name already exists")); return; } + if (QMessageBox::question(this, text("Replace file"), text("Replace %1?").arg(name)) != QMessageBox::Yes) return; + version = item->data(0, Qt::UserRole + 2).toByteArray(); break; + } + startTransfer(m_path.isEmpty() ? name : m_path + '/' + name, local, true, version); +} + +void MemberFileBrowser::download(bool open) { + auto* item = m_entries->currentItem(); + if (m_transferId || !owner() || !item || item->data(0, Qt::UserRole + 1).toInt() != MF_FILE) return; + QString local = QFileDialog::getSaveFileName(this, text("Save file"), item->data(0, Qt::UserRole).toString()); + if (local.isEmpty()) return; + m_openAfter = open; startTransfer(selectedPath(), local, false, selectedVersion()); +} + +void MemberFileBrowser::startTransfer(const QString& remote, const QString& local, bool uploading, const QByteArray& version) { + auto* q = (mf_transfer_request*)u_calloc(1, sizeof(mf_transfer_request)); + if (!q) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "file browser: transfer allocation failed"); return; } + if (!copyPath(q->remote, remote) || !copyPath(q->local, local) || !mf_path_valid(q->remote, 0) || !gui_bridge_is_uasync_ready()) { + u_free(q); m_status->setText(text("Invalid path or chat is stopped")); return; + } + q->inst = gui_bridge_get_inst(); q->id = requestId(); q->group = m_group; q->server = server(); q->owner = owner(); q->upload = uploading; + if (version.size() == 16) memcpy(q->version, version.constData(), 16); + m_transferId = q->id; m_transferGroup = q->group; m_transferServer = q->server; m_local = local; + if (uploading) m_openAfter = false; + m_progress->setRange(0, 0); m_progress->show(); m_status->setText(text("Preparing transfer…")); updateActions(); + gui_bridge_post_uasync_fn(member_files_gui_transfer, q); +} + +void MemberFileBrowser::onTransfer(const uint8_t* data, int length) { + if (length != 51 || mf_get64(data) != m_transferId) return; + quint64 done = mf_get64(data + 32), total = mf_get64(data + 40); unsigned status = data[48] | ((unsigned)data[49] << 8); + if (total) { m_progress->setRange(0, 1000); m_progress->setValue((int)((long double)done * 1000 / total)); } + m_status->setText(text("Transferred %1 / %2 bytes").arg(done).arg(total)); + if (data[50] != 2) return; + m_transferId = 0; m_progress->hide(); updateActions(); + if (status) m_status->setText(QString::fromUtf8(mf_status_text((int)status))); + else { + m_status->setText(text("Transfer completed")); + if (m_openAfter && !QDesktopServices::openUrl(QUrl::fromLocalFile(m_local))) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: open failed path=%s", qPrintable(m_local)); m_status->setText(text("Unable to open file")); + } + if (m_group == m_transferGroup && server() == m_transferServer) refresh(); + } + m_openAfter = false; +} + +void MemberFileBrowser::mutate(uint8_t command) { + if (!owner() || owner() != m_db->myNodeId()) return; + mf_message q{}; q.command = command; q.owner = owner(); + QString source = selectedPath(); QByteArray version = selectedVersion(); + if (command == MF_MKDIR || command == MF_RENAME) { + bool ok = false; + QString name = QInputDialog::getText(this, command == MF_MKDIR ? text("New folder") : text("Rename"), text("Name:"), + QLineEdit::Normal, command == MF_RENAME ? QFileInfo(source).fileName() : QString(), &ok); + if (!ok || name.isEmpty()) return; + QByteArray bytes = name.toUtf8(); + if (name.contains('/') || !mf_path_valid(bytes.constData(), 0)) { m_status->setText(text("Invalid name")); return; } + QString target = m_path.isEmpty() ? name : m_path + '/' + name; + if (command == MF_MKDIR) source = target; + else if (!copyPath(q.target, target)) return; + } else if (!m_entries->currentItem() || QMessageBox::question(this, text("Delete"), text("Delete %1?").arg(source)) != QMessageBox::Yes) return; + if (!copyPath(q.path, source)) return; + if (version.size() == 16) memcpy(q.version, version.constData(), 16); + if (post(q)) m_actions.insert(q.request_id, command); +} + +void MemberFileBrowser::cancel() { cancelRequest(m_transferId); } diff --git a/tools/chatgui/src/member_files/browser.h b/tools/chatgui/src/member_files/browser.h new file mode 100644 index 00000000..f4e40309 --- /dev/null +++ b/tools/chatgui/src/member_files/browser.h @@ -0,0 +1,57 @@ +/* Браузер серверных файлов группы. Виджет не выполняет файловое/сетевое I/O: + * запросы уходят в uasync, передачи — в общий дисковый поток member_files. + * Контекст и request_id защищают от поздних ответов после перехода в другой каталог. */ +#pragma once +#include +#include +#include +#include +class DbManager; +class QComboBox; +class QLineEdit; +class QTreeWidget; +class QTreeWidgetItem; +class QPushButton; +class QLabel; +class QProgressBar; +struct mf_message; + +class MemberFileBrowser : public QDialog { + Q_OBJECT +public: + explicit MemberFileBrowser(DbManager* db, QWidget* parent = nullptr); + ~MemberFileBrowser() override; + void setChannel(const QString& channel, quint64 owner = 0); + void onResponse(const uint8_t* data, int length); + void onTransfer(const uint8_t* data, int length); +private: + void refresh(); + void navigate(const QString& path); + void enter(QTreeWidgetItem* item); + void updateActions(); + quint64 owner() const; + quint64 server() const; + QString selectedPath() const; + QByteArray selectedVersion() const; + bool post(struct mf_message& message); + void upload(); + void download(bool open); + void startTransfer(const QString& remote, const QString& local, bool upload, const QByteArray& version); + void mutate(uint8_t command); + void cancel(); + void cancelRequest(quint64 id); + DbManager* m_db; + quint64 m_group = 0, m_listId = 0, m_transferId = 0; + quint64 m_transferGroup = 0, m_transferServer = 0; + QString m_path, m_local; + bool m_openAfter = false; + int m_staleRetries = 0; + QHash m_actions; + QComboBox* m_servers; + QComboBox* m_owners; + QLineEdit* m_location; + QTreeWidget* m_entries; + QPushButton *m_upload, *m_download, *m_mkdir, *m_rename, *m_remove, *m_cancel; + QLabel* m_status; + QProgressBar* m_progress; +}; diff --git a/tools/chatgui/tests/test_member_files_browser.cpp b/tools/chatgui/tests/test_member_files_browser.cpp new file mode 100644 index 00000000..2f8764b6 --- /dev/null +++ b/tools/chatgui/tests/test_member_files_browser.cpp @@ -0,0 +1,61 @@ +/* Реальный браузер Qt с захватом bridge-команд: выбор подписанного storage через + * NAT, права чужого UID и отбрасывание старого LIST после смены каталога. */ +#include "../src/member_files/browser.h" +#include "../db/db_manager.h" +#include "../transport/gui_bridge.h" +#include +#include +#include +#include +extern "C" { +#include "member_files/member_files.h" +#include "mem.h" +} +static mf_gui_request captured; +extern "C" int gui_bridge_is_uasync_ready(void) { return 1; } +extern "C" UTUN_INSTANCE* gui_bridge_get_inst(void) { return nullptr; } +extern "C" void gui_bridge_post_uasync_fn(void (*function)(void*), void* arg) { + if (function == member_files_gui_request) captured = *(mf_gui_request*)arg; + u_free(arg); +} + +class BrowserTest : public QObject { + Q_OBJECT +private slots: + void rolePermissionsAndLateResponse() { + sqlite3* sql = nullptr; QCOMPARE(sqlite3_open(":memory:", &sql), SQLITE_OK); + QCOMPARE(sqlite3_exec(sql, "CREATE TABLE accounts(node_id INTEGER,display_name TEXT);" + "INSERT INTO accounts VALUES(11,'Author'),(22,'Reader'),(33,'Server');" + "CREATE TABLE peers_42(node_id INTEGER,storage INTEGER,adm_tags TEXT,adm_tags_sig BLOB,deleted INTEGER,source INTEGER);" + "INSERT INTO peers_42 VALUES(11,0,'{}',NULL,0,0),(22,0,'{}',NULL,0,0)," + "(33,0,'{\"storage\":\"yes\"}',zeroblob(64),0,0);", nullptr, nullptr, nullptr), SQLITE_OK); + DbManager db; db.setDb(sql); db.setMyNodeId(22); + { + MemberFileBrowser browser(&db); browser.setChannel("42", 11); + auto selectors = browser.findChildren(); QCOMPARE(selectors.size(), 2); + QCOMPARE(selectors[0]->count(), 1); QCOMPARE(selectors[0]->currentData().toULongLong(), 33ULL); + QCOMPARE(captured.message.owner, 11ULL); QCOMPARE(captured.message.command, (uint8_t)MF_LIST); + mf_gui_request previous = captured; + mf_message r = previous.message; r.kind = MF_RESPONSE; r.type = MF_DIRECTORY; + const char name[] = "readme.txt"; unsigned n = sizeof(name) - 1; + r.data[0] = (uint8_t)n; r.data[2] = MF_FILE; mf_put64(r.data + 3, 123); + memcpy(r.data + MF_ENTRY_HEADER, name, n); r.data_len = MF_ENTRY_HEADER + n; + uint8_t packet[16 + MF_WIRE_MAX]; mf_put64(packet, 42); mf_put64(packet + 8, 33); + int length = mf_encode(&r, packet + 16, MF_WIRE_MAX); browser.onResponse(packet, length + 16); + auto* table = browser.findChild(); QCOMPARE(table->topLevelItemCount(), 1); + table->setCurrentItem(table->topLevelItem(0)); + for (auto* button : browser.findChildren()) { + if (button->text().startsWith("Upload") || button->text().startsWith("Rename") || button->text() == "Delete") + QVERIFY(!button->isEnabled()); + if (button->text().startsWith("Download")) QVERIFY(button->isEnabled()); + } + int own = selectors[1]->findData(QVariant::fromValue(22)); QVERIFY(own >= 0); + selectors[1]->setCurrentIndex(own); QCOMPARE(captured.message.owner, 22ULL); QCOMPARE(table->topLevelItemCount(), 0); + browser.onResponse(packet, length + 16); QCOMPARE(table->topLevelItemCount(), 0); + for (auto* button : browser.findChildren()) if (button->text().startsWith("Upload")) QVERIFY(button->isEnabled()); + } + db.setDb(nullptr); QCOMPARE(sqlite3_close(sql), SQLITE_OK); + } +}; +QTEST_MAIN(BrowserTest) +#include "test_member_files_browser.moc" diff --git a/tools/chatgui/transport/gui_bridge.h b/tools/chatgui/transport/gui_bridge.h index ade48477..50560179 100644 --- a/tools/chatgui/transport/gui_bridge.h +++ b/tools/chatgui/transport/gui_bridge.h @@ -238,6 +238,9 @@ typedef void (*gui_dm_messages_fn)(const uint8_t* data, int len); void gui_bridge_set_dm_messages_cb(gui_dm_messages_fn cb); /* CHAT_EVT_DM_INVITE_RESULT: коррелированный результат отправки приглашения. */ void gui_bridge_set_dm_invite_result_cb(gui_dm_messages_fn cb); +/* Файловый браузер: wire-ответы и прогресс общих передач, в GUI-потоке. */ +void gui_bridge_set_member_files_response_cb(gui_dm_messages_fn cb); +void gui_bridge_set_member_files_transfer_cb(gui_dm_messages_fn cb); /* CHAT_EVT_ATTACHMENT_PREPARATION: target + job UUID + state. */ void gui_bridge_set_attachment_preparation_cb(gui_dm_messages_fn cb); diff --git a/tools/chatgui/transport/gui_bridge_impl.cpp b/tools/chatgui/transport/gui_bridge_impl.cpp index 35da42cc..05e40089 100644 --- a/tools/chatgui/transport/gui_bridge_impl.cpp +++ b/tools/chatgui/transport/gui_bridge_impl.cpp @@ -65,6 +65,8 @@ static gui_dm_messages_fn g_dm_messages_cb = nullptr; static gui_dm_messages_fn g_attachment_preparation_cb = nullptr; static gui_dm_messages_fn g_dm_call_route_cb = nullptr; static gui_dm_messages_fn g_dm_invite_result_cb = nullptr; +static gui_dm_messages_fn g_member_files_response_cb = nullptr; +static gui_dm_messages_fn g_member_files_transfer_cb = nullptr; static gui_network_changed_fn g_network_changed_cb = nullptr; static struct UASYNC* g_ua = nullptr; static struct UTUN_INSTANCE* g_inst = nullptr; @@ -79,6 +81,12 @@ void GuiBridgeReceiver::processPost(int eventType, QByteArray data) { eventType == 16 || eventType == 17 || eventType == 24 || eventType == 25 || eventType == 31 || eventType == 33)) g_network_changed_cb(eventType); switch (eventType) { + case CHAT_EVT_MEMBER_FILES_RESPONSE: + if (g_member_files_response_cb) g_member_files_response_cb(d, dlen); + break; + case CHAT_EVT_MEMBER_FILES_TRANSFER: + if (g_member_files_transfer_cb) g_member_files_transfer_cb(d, dlen); + break; case CHAT_EVT_DM_INVITE_RESULT: if (g_dm_invite_result_cb) g_dm_invite_result_cb(d, dlen); break; @@ -554,6 +562,8 @@ void gui_bridge_set_dm_msg_received_cb(gui_dm_msg_received_fn cb) { void gui_bridge_set_network_changed_cb(gui_network_changed_fn cb) { g_network_changed_cb = cb; } void gui_bridge_set_dm_call_route_cb(gui_dm_messages_fn cb) { g_dm_call_route_cb = cb; } void gui_bridge_set_dm_invite_result_cb(gui_dm_messages_fn cb) { g_dm_invite_result_cb = cb; } +void gui_bridge_set_member_files_response_cb(gui_dm_messages_fn cb) { g_member_files_response_cb = cb; } +void gui_bridge_set_member_files_transfer_cb(gui_dm_messages_fn cb) { g_member_files_transfer_cb = cb; } void gui_bridge_set_dm_conv_updated_cb(gui_dm_conv_updated_fn cb) { g_dm_conv_updated_cb = cb;