diff --git a/doc/encrypted_attachments.md b/doc/encrypted_attachments.md index 37fb6c4a..a422d8db 100644 --- a/doc/encrypted_attachments.md +++ b/doc/encrypted_attachments.md @@ -6,6 +6,12 @@ флажок. Выбор фиксируется вместе с адресатом при отправке, до подготовки. В DM флажок скрыт: используется существующее автоматическое E2E-шифрование лички. +Android показывает зашифрованное вложение отдельной карточкой со статусом запроса +ключа. После одобрения карточка обновляется на исходный файл, изображение, видео +или голосовое. В интерфейс передаётся только расшифрованный путь; контейнер `.uenc` +нельзя открыть или сохранить как готовое вложение. Цитаты и список чатов показывают +название «Зашифрованное вложение» вместо manifest. + Автор генерирует независимые случайные crypto_UUID и ключ AES-256 на каждую подготовку. Ключ записывается в локальную SQLite до шифрования. Готовый контейнер передаётся через обычные media_index/media_delivery. UUID транспортного медиа diff --git a/doc/tasks.md b/doc/tasks.md index 1633c4d3..6e5953a4 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -934,6 +934,13 @@ Двойной free по TIME_WAIT уже закрыт (detach по локальному закрытию + guard abort). ## Открытые флаки +[ ] **test_etcp_transport: таймаут восстановления REALITY-сессии** — 18.10.2026: + в общем `./check.sh` сценарий `reality=1 simultaneous=0 restart=0` завершился + `Session recovery timed out: phase=1 stream=15 control=6`. Перед таймаутом — + `record socket read failed: err=104`, клиент ждал server-handshake. + Отдельный повтор того же бинарника прошёл. Причина нестабильности пока не установлена; + изменения Android JNI/UI транспортные исходники не затрагивали. + [ ] **test_etcp_reconnect** — флаки под параллельной нагрузкой `make check -j4`: phase 4 (reconnect после server restart) таймаутит (`sent=480 recv=0`), при одиночном запуске стабильно `TEST PASSED`. Наш код (chat_join/chat_sync) его не трогает. diff --git a/tests/test_android_replies.c b/tests/test_android_replies.c index f1b75a5c..04d920a3 100644 --- a/tests/test_android_replies.c +++ b/tests/test_android_replies.c @@ -8,6 +8,8 @@ #include "../src/utun_instance.h" #include "../src/chat/chat_core.h" #include "../src/chat/chat_event.h" +#include "../src/media_async/attachment_send.h" +#include "../src/media_async/encrypted_file.h" #include "../src/routing_layer/topo_node_sqlite.h" #include "../src/transport_layer/etcp_connections.h" #include "../src/config_parser.h" @@ -52,6 +54,62 @@ static int check_reply(const char* channel, const char* original, const char* ty u_free(json); return ok; } +/* Настоящий контейнер проходит через bridge; ciphertext и manifest не становятся доступным файлом в UI. */ +static int check_encrypted_attachment(const char* channel, const char* directory) { + char source[1024], value[1024]; + snprintf(source, sizeof(source), "%s/private-source", directory); + FILE* file = fopen(source, "wb"); REQUIRE(file); + REQUIRE(fwrite("private image", 1, 13, file) == 13 && !fclose(file)); + struct attachment_send_req* req = u_calloc(1, sizeof(*req)); REQUIRE(req); + req->inst = instance; req->encrypted = 1; req->request_id = 10; + snprintf(req->target, sizeof(req->target), "%s", channel); + snprintf(req->source, sizeof(req->source), "%s", source); + snprintf(req->info.name, sizeof(req->info.name), "Секрет | 😀.png"); + snprintf(req->content_type, sizeof(req->content_type), "image/png"); + req->info.kind = ATTACHMENT_FILE; + attachment_send_trampoline(req); REQUIRE(wait_result(10) == 1); + sqlite3_stmt* st = NULL; uint8_t id[32]; + REQUIRE(sqlite3_prepare_v2(instance->topo_sqlite_db, "SELECT message_id FROM attachment_keys", -1, &st, NULL) == SQLITE_OK); + REQUIRE(sqlite3_step(st) == SQLITE_ROW && sqlite3_column_bytes(st, 0) == 32); + memcpy(id, sqlite3_column_blob(st, 0), sizeof(id)); sqlite3_finalize(st); + char* json = utun_bridge_get_messages_json(channel, 1, id, 1); REQUIRE(json); + REQUIRE(!extract(json, "$[0].encryptionState", value, sizeof(value)) && !strcmp(value, "ready")); + REQUIRE(!extract(json, "$[0].text", value, sizeof(value)) && !strcmp(value, "Секрет | 😀.png")); + REQUIRE(!extract(json, "$[0].contentType", value, sizeof(value)) && !strcmp(value, "image/png")); + REQUIRE(!extract(json, "$[0].filePath", value, sizeof(value)) && !access(value, F_OK) && !strstr(value, ".uenc")); + REQUIRE(!extract(json, "$[0].fileSize", value, sizeof(value)) && !strcmp(value, "13")); + REQUIRE(!extract(json, "$[0].width", value, sizeof(value)) && !strcmp(value, "0")); + REQUIRE(!extract(json, "$[0].height", value, sizeof(value)) && !strcmp(value, "0")); u_free(json); + json = utun_bridge_get_channels_json(); REQUIRE(json); + REQUIRE(!extract(json, "$[0].lastMsgEncrypted", value, sizeof(value)) && !strcmp(value, "1")); + REQUIRE(!strstr(json, ".uenc")); u_free(json); + + /* Состояния получателя читаются в одной транзакции, затем восстанавливается авторская запись. */ + REQUIRE(sqlite3_exec(instance->topo_sqlite_db, "BEGIN", NULL, NULL, NULL) == SQLITE_OK); + const struct { const char* state; const char* sql; } states[] = { + {"download", "UPDATE attachment_keys SET ready=0,downloaded=0,key=NULL"}, + {"requesting", "INSERT INTO attachment_key_requests SELECT channel,message_id,1,0,0,0 FROM attachment_keys"}, + {"pending", "UPDATE attachment_key_requests SET state=1"}, + {"denied", "UPDATE attachment_key_requests SET state=3"}, + {"decrypting", "UPDATE attachment_key_requests SET state=1; UPDATE attachment_keys SET decrypting=1"}, + {"error", "UPDATE attachment_keys SET error=1"}, + {"error", "UPDATE attachment_keys SET error=0,ready=1,info=X'00'"}, + {"download", "UPDATE attachment_keys SET ready=1,decrypting=0,plain_path='/nonexistent/android-private-file'"} + }; + for (size_t i = 0; i < sizeof(states) / sizeof(states[0]); i++) { + REQUIRE(sqlite3_exec(instance->topo_sqlite_db, states[i].sql, NULL, NULL, NULL) == SQLITE_OK); + json = utun_bridge_get_messages_json(channel, 1, id, 1); REQUIRE(json); + REQUIRE(!extract(json, "$[0].encryptionState", value, sizeof(value)) && !strcmp(value, states[i].state)); + REQUIRE(!extract(json, "$[0].text", value, sizeof(value)) && !value[0]); + REQUIRE(!extract(json, "$[0].filePath", value, sizeof(value)) && !value[0]); + REQUIRE(!extract(json, "$[0].contentType", value, sizeof(value)) && !strcmp(value, ENCRYPTED_ATTACHMENT_TYPE)); + u_free(json); + } + REQUIRE(sqlite3_exec(instance->topo_sqlite_db, "ROLLBACK", NULL, NULL, NULL) == SQLITE_OK); + printf("[PASS] Android encrypted attachment: private metadata, access states and cache recovery\n"); + return 0; +} + /* JSON адресов для Android сохраняет IPv4/IPv6, протокол, порт, socket_id и тип профиля. */ static int check_addresses(const char *channel) { struct TOPO_SOCKMETA4 meta4 = {.id = 5, .config_type = CFG_SERVER_TYPE_PUBLIC}; @@ -207,6 +265,7 @@ int main(void) { /* Ссылка остаётся в JSON после перезапуска сервиса. */ chat_service_stop(instance); REQUIRE(!chat_service_start(instance)); REQUIRE(check_reply(channel, hex, "audio/opus")); + REQUIRE(!check_encrypted_attachment(channel, directory)); utun_bridge_destroy(); utun_instance_destroy(instance); uasync_destroy(async, 0); printf("test_android_replies: text, media, voice, results, history and restart PASS\n"); diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt index ff24f431..a63f8d73 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt @@ -54,7 +54,7 @@ fun parseNodeStatus(data: ByteArray): NodeStatus? { } data class Channel(val id: String, val name: String, val lastMsgAt: Long = 0, val lastMsg: String = "", - val peersOnline: Int = 0) + val peersOnline: Int = 0, val lastMsgEncrypted: Boolean = false) data class DmConversation(val convId: String, val peerNodeId: Long = 0, val peerName: String = "", val lastTs: Long = 0, val online: Boolean = false, val generation: Long = 1) { @@ -68,7 +68,11 @@ data class Message(val id: Long, val author: String, val text: String, val ts: L val videoWidth: Int = 0, val videoHeight: Int = 0, val videoDurationMs: Int = 0, val downloadState: String = "", /* "", "downloading", "downloaded", "error" */ val deliveryStatusRes: Int = 0, val inviteGroupName: String? = null, val authorNodeId: Long = 0, - val messageId: String = "", val feedback: FeedbackState? = null, val replyTo: String = "") + val messageId: String = "", val feedback: FeedbackState? = null, val replyTo: String = "", + val encryptionState: String = "") { + val isEncrypted: Boolean get() = encryptionState.isNotEmpty() + val isLocked: Boolean get() = isEncrypted && encryptionState != "ready" +} data class ChatMember(val nodeId: Long, val name: String, val online: Boolean = false, val isAdmin: Boolean = false, val isSuper: Boolean = false, @@ -286,7 +290,8 @@ class ChatRepository { name = obj.optString("name", obj.getString("id")), lastMsgAt = obj.optLong("lastMsgAt", 0), lastMsg = obj.optString("lastMsg", ""), - peersOnline = obj.optInt("peersOnline", 0) + peersOnline = obj.optInt("peersOnline", 0), + lastMsgEncrypted = obj.optBoolean("lastMsgEncrypted") )) } } catch (e: Exception) { Log.w("utun-gui", "getChannels error", e) } @@ -313,19 +318,23 @@ class ChatRepository { val text = obj.optString("text", "") val contentType = obj.optString("contentType", "text/plain") val filePath = obj.optString("filePath", "") + val encryptionState = obj.optString("encryptionState") + val encrypted = encryptionState.isNotEmpty() var displayText = text - var fileSize = 0L + var fileSize = obj.optLong("fileSize") var downloadState = "" val localAttrs = obj.optString("localAttrs", "") if (filePath.isNotEmpty()) downloadState = "downloaded" - else if (localAttrs.contains("\"st\":\"fl\"")) downloadState = "downloaded" + else if (!encrypted && localAttrs.contains("\"st\":\"fl\"")) downloadState = "downloaded" else if (localAttrs.contains("\"st\":\"er\"")) downloadState = "error" - var waveform = emptyList() - var voiceDurationMs = 0 - if (contentType == "audio/opus" && text.startsWith("wf=")) { + var waveform = obj.optString("waveform").map { + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".indexOf(it).coerceAtLeast(0) / 63f + } + var voiceDurationMs = obj.optInt("durationMs") + if (!encrypted && contentType == "audio/opus" && text.startsWith("wf=")) { try { val wfEnd = text.indexOf(";dur=") if (wfEnd > 0) { @@ -356,7 +365,7 @@ class ChatRepository { } catch (_: Exception) {} } - if (contentType == "application/octet-stream" || contentType.startsWith("video/") || contentType.startsWith("image/")) { + if (!encrypted && (contentType == "application/octet-stream" || contentType.startsWith("video/") || contentType.startsWith("image/"))) { try { val pipeIdx = text.indexOf('|') if (pipeIdx > 0) { @@ -368,9 +377,9 @@ class ChatRepository { } catch (_: Exception) {} } - var imageWidth = 0 - var imageHeight = 0 - if (contentType.startsWith("image/")) { + var imageWidth = obj.optInt("width") + var imageHeight = obj.optInt("height") + if (!encrypted && contentType.startsWith("image/")) { try { for (part in text.substringAfter('|').split("|")) { when { @@ -381,10 +390,10 @@ class ChatRepository { } catch (_: Exception) {} } - var videoWidth = 0 - var videoHeight = 0 - var videoDurationMs = 0 - if (contentType.startsWith("video/")) { + var videoWidth = obj.optInt("width") + var videoHeight = obj.optInt("height") + var videoDurationMs = obj.optInt("durationMs") + if (!encrypted && contentType.startsWith("video/")) { try { for (part in text.substringAfter('|').split("|")) { when { @@ -415,7 +424,9 @@ class ChatRepository { videoWidth = videoWidth, videoHeight = videoHeight, videoDurationMs = videoDurationMs, - downloadState = downloadState + downloadState = downloadState, + encryptionState = encryptionState, + deliveryStatusRes = if (encryptionState == "ready") R.string.attachment_encrypted else 0 )) } } catch (e: Exception) { Log.w("utun-gui", "getMessages error", e) } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt index 202eb54f..a41e7f48 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt @@ -193,6 +193,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = Spacer(Modifier.height(2.dp)) when { + message.isLocked -> EncryptedAttachmentBubble(message, textColor, onDownloadFile) message.inviteGroupName != null -> { val clipboard = LocalClipboardManager.current LaunchedEffect(message.id, message.inviteGroupName) { @@ -223,6 +224,32 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = } } } +@Composable +private fun EncryptedAttachmentBubble(message: Message, textColor: Color, onDownload: (Message) -> Unit) { + val canDownload = message.encryptionState == "download" || message.encryptionState == "error" + val status = when { + message.downloadState == "error" -> R.string.download_failed_retry + message.downloadState == "downloading" -> R.string.downloading + message.encryptionState == "download" -> R.string.attachment_encrypted_download + message.encryptionState == "requesting" -> R.string.attachment_key_requesting + message.encryptionState == "pending" -> R.string.attachment_key_pending + message.encryptionState == "denied" -> R.string.attachment_key_denied + message.encryptionState == "decrypting" -> R.string.attachment_decrypting + else -> R.string.attachment_decrypt_failed + } + LaunchedEffect(message.messageId, message.encryptionState, message.downloadState) { + LogManager.addLog("DEBUG", "Attachment", "encrypted card id=${message.messageId} " + + "state=${message.encryptionState} download=${message.downloadState} action=$canDownload") + } + Column(Modifier.clickable(enabled = canDownload) { onDownload(message) }) { + Text("🔒 " + stringResource(R.string.attachment_encrypted_title), fontSize = 15.sp, + color = textColor, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(2.dp)) + Text(listOf(formatFileSize(message.fileSize), stringResource(status)).filter { it.isNotEmpty() }.joinToString(" · "), + fontSize = 12.sp, color = ChatColors.Secondary) + } +} + @Composable private fun VoiceBubble(message: Message, textColor: Color, isActive: Boolean, ps: ChatViewModel.PlaybackState, diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageReply.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageReply.kt index 830d9b52..0212484c 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageReply.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageReply.kt @@ -51,6 +51,7 @@ internal fun Modifier.messageLongPress(label: String, onLongPress: () -> Unit): fun ReplyQuote(original: Message?, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null) { val preview = when { original == null -> stringResource(R.string.reply_original_missing) + original.isLocked -> stringResource(R.string.attachment_encrypted_title) original.contentType == "audio/opus" -> stringResource(R.string.reply_voice) original.contentType.startsWith("image/") -> stringResource(R.string.photo) original.contentType.startsWith("video/") -> stringResource(R.string.video_message) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt index 14770a4d..bb576cf8 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt @@ -175,8 +175,8 @@ fun ChannelListScreen( Row(verticalAlignment = Alignment.CenterVertically) { Text(ch.name, fontWeight = FontWeight.Bold, fontSize = 16.sp) } - if (ch.lastMsg.isNotEmpty()) - Text(ch.lastMsg, fontSize = 13.sp, + if (ch.lastMsgEncrypted || ch.lastMsg.isNotEmpty()) + Text(if (ch.lastMsgEncrypted) stringResource(R.string.attachment_encrypted_title) else ch.lastMsg, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index 4bfc044f..136a120e 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -534,6 +534,16 @@ class ChatViewModel : ViewModel() { refreshChannels() } } + 60 -> { /* ATTACHMENT_KEYS_CHANGED: [channel_len:1][channel][success?:1] */ + val size = data?.firstOrNull()?.toInt()?.and(0xFF) ?: 0 + if (size == 0 || data == null || data.size < 1 + size) { + LogManager.addLog("ERROR", "Attachment", "invalid encryption state event bytes=${data?.size}") + return + } + val channel = String(data, 1, size, Charsets.UTF_8) + LogManager.addLog("DEBUG", "Attachment", "encryption state changed ch=$channel") + if (_currentChannel.value?.id == channel) refreshMessages(channel) + } 2 -> { /* CONNECT_RESULT: [node_id:8][result:4][channel_id:8] */ if (repo == null || data == null || data.size < 20) { LogManager.addLog("WARN", "VM", "CONNECT_RESULT skipped repo=${repo != null} data=${data != null}"); return } val buf = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN) diff --git a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml index 799275ca..cb2ac9ec 100644 --- a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml @@ -498,4 +498,12 @@ Всего субблоков: 1–255. Избыточных: от 0 до общего числа минус один. Не удалось сохранить настройки трансляции. Сохранено. Настройки применятся при следующем запуске трансляции. + Зашифрованное вложение + Зашифровано + Нажмите, чтобы скачать и запросить доступ + Отправка запроса ключа… + Ожидание разрешения автора + Автор отказал в доступе + Расшифровка… + Ошибка расшифровки. Нажмите для повтора diff --git a/tools/chatgui-android/app/src/main/res/values/strings.xml b/tools/chatgui-android/app/src/main/res/values/strings.xml index d4a7cc62..7acead29 100644 --- a/tools/chatgui-android/app/src/main/res/values/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values/strings.xml @@ -497,4 +497,12 @@ Total subblocks: 1–255. Redundant subblocks: 0 to total minus one. Failed to save broadcast settings. Saved. Settings apply to the next broadcast. + Encrypted attachment + Encrypted + Tap to download and request access + Sending key request… + Awaiting author’s approval + Access denied by author + Decrypting… + Decryption failed. Tap to retry diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index 7e54d711..6c49c99a 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -27,6 +27,7 @@ #include "../../../lib/mem.h" #include "../../../lib/sqlite3.h" #include "../../../src/chat/chat_core.h" +#include "../../../src/media_async/encrypted_file.h" #include "../../../src/chat/chat_event.h" #include "../../../src/chat/chat_poll.h" #include "../../../lib/strbuf.h" @@ -48,6 +49,7 @@ #include #include #include +#include #ifdef __ANDROID__ #include @@ -372,6 +374,7 @@ static void bridge_get_node_name(sqlite3* db, uint64_t node_id, const char* chan /* Формирует человекочитаемое тело превью последнего сообщения по content_type и "d"-полю. */ static void bridge_format_preview_body(const char* ct, const char* d, char* out, size_t out_sz) { + if (ct && !strcmp(ct, ENCRYPTED_ATTACHMENT_TYPE)) { out[0] = 0; return; } int is_text = (!ct || !ct[0] || strcmp(ct, "text/plain") == 0 || strcmp(ct, "voice_transcription") == 0); if (is_text) { size_t tl = strlen(d); @@ -442,6 +445,7 @@ char* utun_bridge_get_channels_json(void) { if (!ch_id) continue; char tbl_msg[80], tbl_peers[80], last_msg[160]; + int last_encrypted = 0; msg_table_name(ch_id, tbl_msg, sizeof(tbl_msg)); peers_table_name(ch_id, tbl_peers, sizeof(tbl_peers)); last_msg[0] = '\0'; @@ -460,6 +464,7 @@ char* utun_bridge_get_channels_json(void) { char ct[32] = "text/plain"; const char* cts = strstr(dp, "\"ct\":\""); if (cts) { cts += 6; char* cte = strchr((char*)cts, '"'); if (cte) { size_t tlc = (size_t)(cte - cts); if (tlc < sizeof(ct)) { memcpy(ct, cts, tlc); ct[tlc] = '\0'; } } } + last_encrypted = !strcmp(ct, ENCRYPTED_ATTACHMENT_TYPE); const char* ds = strstr(dp, "\"d\":\""); if (ds) { ds += 5; char* de = strchr((char*)ds, '"'); if (de) { size_t tl = (size_t)(de - ds); if (tl > 511) tl = 511; char d[512]; memcpy(d, ds, tl); d[tl] = '\0'; bridge_format_preview_body(ct, d, body, sizeof(body)); } } } @@ -491,12 +496,14 @@ char* utun_bridge_get_channels_json(void) { const char* sep = first ? "" : ","; first = 0; - size_t needed = snprintf(NULL, 0, "%s{\"id\":\"%s\",\"name\":\"%s\",\"lastMsgAt\":%lld,\"lastMsg\":\"%s\",\"peersOnline\":%d}", - sep, ch_id, esc_name, (long long)last_msg_at, esc_msg, online); + size_t needed = snprintf(NULL, 0, "%s{\"id\":\"%s\",\"name\":\"%s\",\"lastMsgAt\":%lld,\"lastMsg\":\"%s\"," + "\"peersOnline\":%d,\"lastMsgEncrypted\":%s}", sep, ch_id, esc_name, (long long)last_msg_at, esc_msg, online, + last_encrypted ? "true" : "false"); while (pos + needed + 2 > cap) { char* tmp = u_realloc(json, cap * 2); if (!tmp) break; json = tmp; cap *= 2; } if (pos + needed + 2 <= cap) - pos += (size_t)snprintf(json + pos, cap - pos, "%s{\"id\":\"%s\",\"name\":\"%s\",\"lastMsgAt\":%lld,\"lastMsg\":\"%s\",\"peersOnline\":%d}", - sep, ch_id, esc_name, (long long)last_msg_at, esc_msg, online); + pos += (size_t)snprintf(json + pos, cap - pos, "%s{\"id\":\"%s\",\"name\":\"%s\",\"lastMsgAt\":%lld,\"lastMsg\":\"%s\"," + "\"peersOnline\":%d,\"lastMsgEncrypted\":%s}", sep, ch_id, esc_name, (long long)last_msg_at, esc_msg, online, + last_encrypted ? "true" : "false"); u_free(esc_msg); u_free(esc_name); } @@ -518,17 +525,21 @@ char* utun_bridge_get_messages_json(const char* channel_id, int limit, const uin "max(0,(SELECT count(*) FROM \"%s\")-?1))", table, table, table, table); else snprintf(selection, sizeof(selection), "SELECT * FROM \"%s\" ORDER BY timestamp DESC,author_signature DESC LIMIT ?1", table); snprintf(sql, sizeof(sql), - "SELECT id,node_id,timestamp,local_attrs,message_id," + "SELECT m.id,m.node_id,m.timestamp,m.local_attrs,m.message_id," "CASE WHEN json_valid(CAST(data AS TEXT)) THEN json_extract(CAST(data AS TEXT),'$.ct') END," "CASE WHEN json_valid(CAST(data AS TEXT)) THEN json_extract(CAST(data AS TEXT),'$.d') END," - "CASE WHEN json_valid(CAST(data AS TEXT)) THEN json_extract(CAST(data AS TEXT),'$.reply_to') END" - " FROM (%s) ORDER BY timestamp,author_signature", selection); + "CASE WHEN json_valid(CAST(data AS TEXT)) THEN json_extract(CAST(data AS TEXT),'$.reply_to') END," + "a.ready,a.downloaded,a.decrypting,a.error,a.plain_path,a.info,a.content_type,COALESCE(r.state,-1),length(a.key)=32" + " FROM (%s) m LEFT JOIN attachment_keys a ON a.channel=?3 AND a.message_id=m.message_id" + " LEFT JOIN attachment_key_requests r ON r.channel=a.channel AND r.message_id=a.message_id AND r.incoming=0" + " ORDER BY m.timestamp,m.author_signature", selection); sqlite3_stmt* st = NULL; if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "bridge: messages query ch=%s: %s", channel_id, sqlite3_errmsg(db)); return u_strdup("[]"); } sqlite3_bind_int(st, 1, limit > 0 ? limit : 50); if (anchor) sqlite3_bind_blob(st, 2, anchor, 32, SQLITE_STATIC); + sqlite3_bind_text(st, 3, channel_id, -1, SQLITE_STATIC); struct strbuf out = strbuf_new(); int step, result = strbuf_addf(&out, "["); unsigned count = 0; while (result >= 0 && (step = sqlite3_step(st)) == SQLITE_ROW) { @@ -542,8 +553,9 @@ char* utun_bridge_get_messages_json(const char* channel_id, int limit, const uin char message_id[65], text[DB_SYNC_MESSAGE_MAX + 1]; for (unsigned i = 0; i < 32; i++) snprintf(message_id + 2 * i, 3, "%02x", id[i]); snprintf(text, sizeof(text), "%s", body ? body : ""); if (!type) type = "text/plain"; + int encrypted = !strcmp(type, ENCRYPTED_ATTACHMENT_TYPE); /* У вложений '*' кодирует отображаемое имя; poll использует отдельный b64: формат. */ - if (strcmp(type, "text/plain") && text[0] == '*') { + if (!encrypted && strcmp(type, "text/plain") && text[0] == '*') { char* pipe = strchr(text, '|'); if (pipe) { uint8_t decoded[1024]; size_t bytes = b64_decode(text + 1, pipe - text - 1, decoded, sizeof(decoded)); @@ -554,7 +566,7 @@ char* utun_bridge_get_messages_json(const char* channel_id, int limit, const uin char author[128], file_path[1024] = ""; bridge_get_node_name(db, (uint64_t)node_id, channel_id, author, sizeof(author)); if (!author[0]) snprintf(author, sizeof(author), "0x%016llx", (unsigned long long)node_id); - if (attrs) { + if (!encrypted && attrs) { sqlite3_stmt* file = NULL; if (sqlite3_prepare_v2(db, "SELECT CASE WHEN json_valid(?1) THEN json_extract(?1,'$.fp') END", -1, &file, NULL) == SQLITE_OK) { sqlite3_bind_text(file, 1, attrs, -1, SQLITE_STATIC); @@ -568,6 +580,46 @@ char* utun_bridge_get_messages_json(const char* channel_id, int limit, const uin sqlite3_finalize(file); } else DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "bridge: local attributes query: %s", sqlite3_errmsg(db)); } + const char* encryption_state = ""; + struct attachment_info info = {0}; + uint64_t file_size = 0; + if (encrypted) { + /* Шифротекст никогда не передаём UI как готовый файл; метаданные доступны только после decrypt. */ + const char* pipe = body ? strchr(body, '|') : NULL; + if (pipe) file_size = strtoull(pipe + 1, NULL, 10); + text[0] = 0; + const char* path = (const char*)sqlite3_column_text(st, 12); + const char* plain_type = (const char*)sqlite3_column_text(st, 14); + const int ready = sqlite3_column_int(st, 8), downloaded = sqlite3_column_int(st, 9); + const int decrypting = sqlite3_column_int(st, 10), error = sqlite3_column_int(st, 11); + const int state = sqlite3_column_int(st, 15), key = sqlite3_column_int(st, 16); + struct stat file; + int available = ready && path && !stat(path, &file) && S_ISREG(file.st_mode); + if (available && (!plain_type || !plain_type[0] || attachment_decode(sqlite3_column_blob(st, 13), + sqlite3_column_bytes(st, 13), &info))) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "bridge: encrypted metadata invalid ch=%s id=%lld", channel_id, (long long)local_id); + available = 0; encryption_state = "error"; + } + if (available) { + int n = snprintf(file_path, sizeof(file_path), "%s", path); + if (n >= (int)sizeof(file_path)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "bridge: decrypted path too long ch=%s id=%lld", channel_id, (long long)local_id); + file_path[0] = 0; encryption_state = "error"; + } else { + encryption_state = "ready"; type = plain_type; file_size = (uint64_t)file.st_size; + snprintf(text, sizeof(text), "%s", info.name); + } + } else if (!encryption_state[0]) { + if (error) encryption_state = "error"; + else if (state == 3) encryption_state = "denied"; + else if (decrypting) encryption_state = "decrypting"; + else if (ready) encryption_state = "download"; /* Открытая копия могла быть удалена кешем. */ + else if (downloaded && key) encryption_state = "decrypting"; + else if (state == 1) encryption_state = "pending"; + else if (state == 0 || downloaded) encryption_state = "requesting"; + else encryption_state = "download"; + } + } result = strbuf_addf(&out, "%s{\"id\":%lld,\"messageId\":\"%s\",\"authorNodeId\":%lld,\"author\":", count++ ? "," : "", (long long)local_id, message_id, (long long)node_id); if (result >= 0) result = strbuf_add_json_string(&out, author); @@ -582,6 +634,12 @@ char* utun_bridge_get_messages_json(const char* channel_id, int limit, const uin if (result >= 0) result = strbuf_add_json_string(&out, attrs); if (result >= 0) result = strbuf_addf(&out, ",\"replyTo\":"); if (result >= 0) result = strbuf_add_json_string(&out, (const char*)sqlite3_column_text(st, 7)); + if (result >= 0 && encrypted) { + result = strbuf_addf(&out, ",\"encryptionState\":\"%s\",\"fileSize\":%llu,\"durationMs\":%u," + "\"width\":%u,\"height\":%u,\"waveform\":", encryption_state, (unsigned long long)file_size, + info.duration_ms, info.width, info.height); + if (result >= 0) result = strbuf_add_json_string(&out, info.waveform); + } if (result >= 0) result = strbuf_addf(&out, "}"); } sqlite3_finalize(st);