Файлы участников 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/. При старте сервис создаёт/открывает корень в дисковом worker, удаляет старые незавершённые temp и сканирует опубликованные файлы. До готовности отвечает BUSY. Ошибка сканирования оставляет локальное хранилище недоступным до restart; частичные счётчики клиенту не выдаются. Клиентские передачи к другим серверам продолжают работать. Очистка кэша вложений media/ это дерево не обходит. Настройки [chatserver] / [chat]: member_files_enabled=1 member_files_user_quota_mb=1024 member_files_upload_ttl_sec=3600 member_files_path=/путь/к/хранилищу (опционально, до 255 байт UTF-8) Путь, квота и TTL читаются при старте чат-сервиса; изменение требует его restart. enabled проверяется на каждом запросе. Лимит одинаков для каждого UID отдельно, размер UID суммируется по всем группам на этом сервере. Значение в МиБ (1024*1024 байт). Счётчики used/reserved/files/directories хранятся в памяти дискового worker и восстанавливаются с диска только при старте. Каталоги не занимают байтовую квоту. files/directories считают опубликованные файлы и каталоги внутри деревьев UID по всем группам; корни групп, корни UID и .uploads исключены. Пустой опубликованный файл считается файлом. Сохранённые файлы удалённых участников также занимают квоту. При начале загрузки резервируется размер нового файла или положительный прирост при замене. WRITE не изменяет used; COMMIT обновляет used на разницу размеров и освобождает резерв. ABORT/Cancel/TTL освобождают резерв после удаления temp; REMOVE вычитает размер файла. Переименование внутри UID не меняет размер и числа объектов. Новый COMMIT увеличивает files на 1; замена существующего файла, включая нулевой размер, сохраняет files. MKDIR увеличивает directories на 1, REMOVE уменьшает соответствующий счётчик. Для mkdir учитывается факт создания даже при последующей ошибке sync. BEGIN/WRITE/ABORT/Cancel/TTL не увеличивают files: temp не опубликован. Все изменения корректируют счётчики инкрементально, без обхода каталога пользователя; QUOTA только читает их из памяти. LIST выполняет свой обычный обход для отображения записей, не пересчитывая статистику. Повторы BEGIN/COMMIT/MKDIR/REMOVE не меняют учёт повторно. Уже превышенная квота не удаляет данные: чтение, удаление и уменьшение файла доступны, новый положительный прирост запрещён. Физический temp может занимать больше места, чем резерв прироста; нехватка места файловой системы остаётся отдельной I/O-ошибкой. Нехватка квоты/места возвращает NO_SPACE; опубликованные файлы не вытесняются. Общая member_files_quota_mb удалена. Корень обслуживается только файловым сервисом; ручные изменения требуют повторного сканирования через restart. Сервер — действующий полный мембер группы с подписанным adm_tags.storage="yes". Колонка peers.storage — производный сетевой приоритет, для полномочий не используется. Каждый запрос и ответ подписаны Ed25519 и зашифрованы end-to-end средствами route_crypto. group/src берутся только из проверенного заголовка router; ETCP peer промежуточного хопа не считается автором запроса. Читать могут действующие участники той же группы. Изменять дерево может только src == owner. Удалённые записи и source=1 (плейсхолдеры) доступа не дают. Открытый fd/UUID загрузки не отменяют повторную проверку членства. Файлы удалённого участника сохраняются на диске, но через сервис не доступны. Wire, версия 4 -------------- Все числа LE, без сырых структур и padding. Фиксированный заголовок 125 байт: 0 protocol_version:u8 = 4 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 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. Файл или пустой каталог, без рекурсивного удаления. 11 STREAM_WRITE: открытие потоковой загрузки; поля как BEGIN_WRITE, cursor — ненулевой ID потока. Ответ подтверждает готовность temp и конечный размер. 12 STREAM_READ: открытие потокового скачивания; owner/path/version/size/cursor, upload — случайный токен клиента. Сервер сверяет снимок и размер перед началом. 13 STREAM_DATA: request_id — ID потока, owner/path/upload, offset/size/data. kind=request для upload, response для download. Ответа на блок нет. offset обязан равняться следующему принятому смещению; size==data_len, не более 8192. 14 STREAM_END: ID потока, owner/path/upload, offset==size==размер файла, SHA256. Upload: клиент отправляет END после всех DATA, сервер отвечает END после COMMIT. Download: сервер отправляет END после DATA и повторной проверки версии; клиент проверяет SHA256 записанных данных, синхронизирует и публикует локальный файл. Ошибка сервера приходит END.status!=OK; ошибка открытия — ответом на STREAM_READ/WRITE. 15 STREAM_CANCEL: клиент отменяет конкретный поток. Поздние DATA не публикуются. 16 QUOTA: owner=UID, path/target пустые, cursor=0, data_len=0. Права чтения те же, что у STAT/LIST выбранного участника. Ответ data_len=40: used:u64, limit:u64, reserved:u64, files:u64, directories:u64, LE. Первые три значения в байтах, последние два — числа объектов. Снимок общий для UID во всех группах. Не резервирует место и не занимает квитанцию мутации. used может превышать limit после уменьшения лимита; сервер не выдаёт фиктивный нулевой размер при ошибке учёта. Открытие потока — обычный повторяемый запрос с отдельным request_id. cursor связывает его с DATA/END/CANCEL. Поток определяется group/peer/kind/ID; один ID не может одновременно обозначать разные открытия. Каждый пакет по-прежнему подписан и зашифрован. При закрытии/перезапуске router-канала его handles завершаются с IO_ERROR; байты разных эпох не смешиваются. Возобновление через новую эпоху пока не реализовано. Статусы (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 временных загрузок, 16 клиентских и 16 серверных потоков на экземпляр. Каждый поток имеет кольцо 32 x 8192 байт (256 КиБ); worker обрабатывает до 16 блоков за задачу. Чтение следующей порции перекрывается с отправкой уже подготовленной. Все серверные и локальные I/O используют один FIFO worker, без блокировки uasync. Отправитель добавляет пакеты, пока send_q router находится под нижним порогом (сейчас count<=0), затем ждёт etcp_router_on_send_ready. Файловый слой не устанавливает своё окно inflight и не повторяет DATA; транспортные ACK, retransmit и порядок — router. Callback отправляет не более 8 блоков за запуск, уступая очередь другим потокам. Параллельные файлы одного пира разделяют канал и FIFO/round-robin waiters. Готовность приёмника проверяется router до доставки следующего seq. Заполненное кольцо или очередь заданий оставляют пакет в recv_q: rx_seq/ACK не продвигаются. Освобождение места вызывает отложенный resume; pump отменяется при reset/close. ACK RECEIVE_BUSY (flags bit0) раз в 100 мс отличает ожидание диска от потери транспорта. Отправитель приостанавливает новые DATA, а живые busy ACK продлевают таймаут router. Буфер router ограничен его receive window. Локальная доставка использует такую же проверку готовности и ограниченный FIFO, а не сеть или отдельные подтверждения. При остановке сначала отменяются producers/requests и дожидаются все stream cleanup continuations; только затем worker освобождает storage. Отмена после уже выполненной публикации не удаляет опубликованный файл. 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 скачивает в выбранный пользователем локальный файл и открывает его приложением ОС после завершения. Смена контекста отбрасывает поздние ответы. Для выбранного UID строка хранилища показывает занято/всего, число файлов/каталогов и резерв текущих загрузок. Точные байты доступны в tooltip. QUOTA запрашивается отдельно от LIST при выборе сервера/UID, обновлении каталога, завершении передачи и каждые две секунды видимого окна. Поздние ответы старого UID/сервера игнорируются. BUSY при стартовом сканировании сохраняет Loading directory и повторяет LIST через секунду; новый переход отменяет повтор. API параллельных передач ----------------------- member_files_transfer_start(request, callback, arg) копирует request и возвращает отдельный mf_transfer* на каждый файл. upload задаёт направление, group/server/owner выбираются для каждой операции. id=0 выбирает случайный ID; id в callback — фактический. NULL означает отказ запуска без callback; причина пишется в лог. max_bytes=0 оставляет прежний лимит; ненулевое значение ограничивает размер файла. Upload проверяет размер источника до хеширования, download — ответ STAT до открытия локального temp. STREAM привязан к проверенным размеру/version; превышение — INVALID_RANGE. callback(arg, mf_transfer_progress*) получает id/done/total/status/finished. Прогресс заимствован до возврата callback. done для отправителя означает байты, принятые router в очередь; успешный finished подтверждает завершение операции, для upload — публикацию на сервере, для download — публикацию локального файла. finished приходит ровно один раз, handle к этому моменту уже освобождён; вызывающий обнуляет свой указатель в callback. member_files_transfer_cancel(handle) отменяет только этот файл, ждёт его I/O и ставит CANCEL в очередь с backpressure. Stop сервиса/группы завершает все принадлежащие ему handles. Все API/callbacks выполняются в uasync-потоке экземпляра. GUI trampolines используют тот же API и превращают callback в существующие события прогресса; Qt не делает I/O. Один локальный файл назначения не может одновременно принадлежать двум download. Аватарки пользователей --------------------- Относительный путь avatars/.png находится внутри обычного дерева UID: <корень>///avatars/.png. Дополнительного UID в API/пути нет. Аватарка по умолчанию копируется в каждую группу, где выбранный сервер имеет подписанную storage-роль. Ссылка каждого канала публикуется только после COMMIT соответствующей копии; отсутствующий сервер в другом канале не создаёт битую ссылку. Новое членство обнаруживается через снимки участников и повторяет публикацию. Между серверами автоматического копирования нет. userinfo содержит строковые avatar_server (16 hex цифр) и avatar_file (64 hex SHA256 + .png). Оба поля отсутствуют при удалённой аватарке. Профиль подписан ключом участника; смена имени/адресов сохраняет остальные плоские JSON-атрибуты. userinfo ограничен 1024 байтами UTF-8. Merkle, JOIN_INFO_RESP, CHANNEL_INVITE и JOIN_REQUEST используют длину userinfo:u16 BE вместо прежнего u8. Bootstrap передаёт существующий подписанный профиль инвайтера, не создавая более свежую запись только с именем. Обе стороны нужно обновить: обратной совместимости нет. Снимок участника CHAT_MEMBER_DISPLAY_SIZE теперь 157 байт: прежние 79 байт, avatar_server:8LE, avatar_file:69 (с NUL), storage_role:1. storage_role обозначает подписанную роль сервера, независимо от существующей колонки storage — сетевого приоритета, который может быть 0 у сервера за NAT. Потребители используют общий chat_member.h; локальное изменение профиля также вызывает MEMBER_UPDATED. В Settings → Profile доступны Upload avatar, выбор storage-сервера, предпросмотр и Remove avatar. Изменения применяются сразу, независимо от Save других настроек. Worker проверяет размер входного изображения (до 16 мегапикселей), обрезает центральный квадрат и сохраняет статический PNG 256×256. Файл ограничен 1 МиБ. Сервер проверяет каноническое имя/SHA256, размер и запрещает замену под прежним именем и rename в/из avatars. Файлы и каталог входят в обычные инкрементальные квоты/счётчики. Прежние версии сохраняются на сервере; владелец может удалить их через файловый браузер. AvatarManager общий для списка участников и сообщений, максимум две операции одновременно, одинаковые хеши не скачиваются параллельно. Изменение своего профиля имеет приоритет перед фоновыми загрузками. Для DM используются ссылки из общих каналов; при отсутствии доступной ссылки остаются инициалы. Онлайн-статус списка отображается отдельной точкой поверх фотографии. Поздний результат старой ссылки пополняет кеш, но не заменяет картинку новой ссылки. Кеш аккаунта: /avatar_cache//.png; без db_path используется Qt AppLocalDataLocation. Проверяются SHA256 и квадратный PNG до 512×512; превышение/битая картинка не публикуются. Публикация кеша атомарна через QSaveFile, незавершённые download/part очищаются при старте. Готовые файлы не требуют TTL/STAT: смена содержимого меняет имя. Дисковый LRU — 100 МиБ, кеш QImage — 32 МиБ; собственная аватарка и источники незавершённых upload сохраняются при вытеснении. Дисковое I/O и декодирование выполняются вне GUI/uasync. Повторы неудачных загрузок: 5/10/20/40/80/160 секунд; новая ссылка сбрасывает задержку. RTT/online не меняют ссылку и не вызывают повторной загрузки. Желаемая собственная ссылка/удаление сохраняются атомарно одной строкой ui_state.avatar_default, незавершённая публикация повторяется после запуска при наличии локального источника. test_avatars проверяет нормализацию, хеш/размеры, очистку temp, настоящее storage в двух каналах, публикацию, сохранение при смене имени, DM-кеш, restart без сети, автоматический download после потери кеша и удаление ссылок. Также проверяются max_bytes до открытия local temp и смена аватарки при задержанном download старой версии: поздний результат не заменяет новый рисунок. test_member_adapter проверяет подписанные профили 191/255/256/512/1023/1024 байт вместе с adm_tags до 8192. Категория media=debug показывает discovery, cache hit/eviction, направление, group/owner/server/hash, ошибки проверки и retry; media=info — передачи/публикации. Проверки и диагностика --------------------- tests/test_member_files: wire/UTF-8, произвольные блоки, повторы, EOF, версии, короткая замена, SHA256, пустой файл, независимые квоты UID, общий учёт по группам, резервирование/повторы/TTL, инкрементальные files/directories, замены пустых файлов, создание/удаление/rename каталогов, отсутствие runtime-rescan, стартовый scan и его ошибки, ссылки, expiry/restart, 10-секундный fd idle и принадлежность work/done потокам. tests/test_member_files_network: настоящие A/B/M, подписанная storage-роль, параллельные upload/download одного пира, local loopback, пустые файлы, побайтная проверка, отмена одного handle, restart router и destroy с активным server I/O. Шестисекундная задержка worker проверяет receive backpressure и busy ACK. Также проверяются чужая запись, виртуальный корень и подписанное удаление читателя. QUOTA проверяется на настоящем подписанном/зашифрованном канале; удалённый читатель не получает счётчики. test_services — stop/start общего чат-сервиса. Текущая диагностика выделена в категорию debug. Настройка для полного трасса: [debug] debug=trace debug=debug показывает запрос/ответ, ACL, очереди, маршрут, ACK, crypto-решения, I/O, fd idle и состояние браузера. trace дополнительно показывает каждый read/write и передачу задач/завершений между дисковым потоком и uasync. stream progress раз в секунду показывает ID/направление, done/total, число блоков в кольце, активность I/O, причину ожидания и скорость B/s. Router логирует переходы receive paused/resumed и peer receive busy без повторного сообщения на каждый блок. quota scan started/ready показывают путь, число пользователей и время обхода; quota updated показывает UID/операцию/used/reserved/files/directories; quota exceeded — used/reserved/growth/limit. По request_id видны GUI post → core request → router seq → server rx → ACL → disk queued/start/done → response tx → GUI result/bridge → directory ready. PID создания worker, PID отправки задачи и startup PID позволяют однозначно обнаружить fork после создания потока; несовпадение PID логируется как ошибка. Ключи шифрования, plaintext файлов и сырые медиа в debug не выводятся. Старые VAD/рация/GUI/вложения сообщения перенесены в vad/radio/chat/media/config; ошибки и пользовательские info остаются в тематических категориях.