diff --git a/src/routing_layer/topo_node.c b/src/routing_layer/topo_node.c index 632a04ce..d58a80ee 100644 --- a/src/routing_layer/topo_node.c +++ b/src/routing_layer/topo_node.c @@ -307,7 +307,7 @@ void topo_node_ping_update_rtt(struct TOPO_GROUPS* groups, uint64_t node_id, uin DEBUG_INFO(DEBUG_CATEGORY_BGP, "ping_update_rtt: node=%016llx rtt=%u (%llu) group=%016llx", (unsigned long long)node_id, (unsigned)rtt, (unsigned long long)now, (unsigned long long)g->group_id); if (groups->instance && groups->instance->topo_sqlite_db) - topo_node_sqlite_nodeinfo_updated(groups->instance->topo_sqlite_db, node_id); + topo_node_sqlite_update_rtt(groups->instance->topo_sqlite_db, node_id, rtt); } ge = ge->next; } diff --git a/src/routing_layer/topo_node_sqlite.c b/src/routing_layer/topo_node_sqlite.c index 4d862d2b..d5096730 100644 --- a/src/routing_layer/topo_node_sqlite.c +++ b/src/routing_layer/topo_node_sqlite.c @@ -2,6 +2,7 @@ #include "topo_node.h" #include "etcp_connections.h" #include "../lib/debug_config.h" +#include "../lib/mem.h" #include #include @@ -248,30 +249,39 @@ int topo_node_sqlite_channel_put(sqlite3* db, const char* channel_id, if (!db || !channel_id || !name || !x25519_pub || !ed25519_pub || !signature) return -1; char peers_tbl[80]; peers_table_name(channel_id, peers_tbl, sizeof(peers_tbl)); - char sql[512]; - snprintf(sql, sizeof(sql), - "CREATE TABLE IF NOT EXISTS \"%s\" (" // таблица мемберов канала + + const char* ddl_tmpl = + "CREATE TABLE IF NOT EXISTS \"%s\" (" " id INTEGER PRIMARY KEY AUTOINCREMENT," - " node_id INTEGER NOT NULL UNIQUE," // ид мембера группы - " x25519_pubkey BLOB NOT NULL," // ключ мембера для шифрования - " ed25519_pubkey BLOB NOT NULL," // ключ мембера для подписи - " join_sig BLOB NOT NULL," // подпись мембера неизменяемая (при присоединении) сh_x25519_pubkey(32) || ch_ed25519_pubkey(32) || node_id(8 LE) || node_x25519_pubkey(32) || join_ts(8 LE) - " join_ts INTEGER NOT NULL," // время присоединения - " update_sig BLOB," // подпись мембера изменяемая join_sig(64) || update_ts(8) || userinfo (NUL-terminated) - " update_ts INTEGER DEFAULT 0,"// время подписи - " userinfo TEXT NOT NULL DEFAULT ''," // имя/метаданные узла в канале (json_flat style) - " creator_sig BLOB," // валидация мембера админом канала - " adm_tags TEXT," // тэги которые может менять админ (json_flat style) - " adm_tags_sig BLOB," // подпись тэгов adm_tags (может быть подписана ключом суперадмина или админов - проверяем перебором) - " node_type INTEGER NOT NULL DEFAULT 0," // ENUM(0-'PRIV',1-'NAT',2-'EIM-NAT',3-'PUBLIC',4-SUPERNODE) тип ноды - private (только нода подключается), nat (односторонние подключения), eim-nat (доступны подключения но надо запрашивать ip), public - на прямых ip (но может быть недоступна в чебурнетах), supernode - на прямых ip, рекомендуемая для подключения - " node_RTT INTEGER" // задержка в ms - ")", peers_tbl); - char* err = NULL; - sqlite3_exec(db, sql, NULL, NULL, &err); - if (err) { DEBUG_ERROR(DEBUG_CATEGORY_BGP, "topo_node_sqlite_channel_put peers: %s", err); sqlite3_free(err); } - snprintf(sql, sizeof(sql), + " node_id INTEGER NOT NULL UNIQUE," + " x25519_pubkey BLOB NOT NULL," + " ed25519_pubkey BLOB NOT NULL," + " join_sig BLOB NOT NULL," + " join_ts INTEGER NOT NULL," + " update_sig BLOB," + " update_ts INTEGER DEFAULT 0," + " userinfo TEXT NOT NULL DEFAULT ''," + " creator_sig BLOB," + " adm_tags TEXT," + " adm_tags_sig BLOB," + " node_type INTEGER NOT NULL DEFAULT 0," + " node_RTT INTEGER" + ")"; + + int ddl_sz = snprintf(NULL, 0, ddl_tmpl, peers_tbl); + char* ddl = u_malloc(ddl_sz + 1); + if (ddl) { + snprintf(ddl, ddl_sz + 1, ddl_tmpl, peers_tbl); + char* err = NULL; + sqlite3_exec(db, ddl, NULL, NULL, &err); + if (err) { DEBUG_ERROR(DEBUG_CATEGORY_BGP, "topo_node_sqlite_channel_put peers: %s", err); sqlite3_free(err); } + u_free(ddl); + } + + char idx_sql[128]; + snprintf(idx_sql, sizeof(idx_sql), "CREATE INDEX IF NOT EXISTS \"idx_%s_type_rtt\" ON \"%s\" (node_type, node_RTT)", peers_tbl, peers_tbl); - sqlite3_exec(db, sql, NULL, NULL, NULL); + sqlite3_exec(db, idx_sql, NULL, NULL, NULL); sqlite3_stmt* stmt = NULL; if (sqlite3_prepare_v2(db, @@ -324,17 +334,24 @@ int topo_node_sqlite_member_put(sqlite3* db, const char* channel_id, uint64_t no int topo_node_sqlite_node_update_verified(sqlite3* db, uint64_t node_id, const char* name, const uint8_t* x25519, const uint8_t* ed25519, uint64_t update_ts) { - if (!db || !name || !x25519 || !ed25519) return -1; + if (!db || !x25519 || !ed25519) return -1; sqlite3_stmt* stmt = NULL; if (sqlite3_prepare_v2(db, - "UPDATE nodes SET name=?, x25519_pubkey=?, ed25519_pubkey=?, update_ts=?" - " WHERE node_id=? AND (update_ts IS NULL OR update_ts < ?)", -1, &stmt, NULL) != SQLITE_OK) return -1; - sqlite3_bind_text(stmt, 1, name, -1, SQLITE_STATIC); - sqlite3_bind_blob(stmt, 2, x25519, 32, SQLITE_STATIC); - sqlite3_bind_blob(stmt, 3, ed25519, 32, SQLITE_STATIC); - sqlite3_bind_int64(stmt, 4, (sqlite3_int64)update_ts); - sqlite3_bind_int64(stmt, 5, (sqlite3_int64)node_id); - sqlite3_bind_int64(stmt, 6, (sqlite3_int64)update_ts); + "INSERT INTO nodes(node_id, name, x25519_pubkey, ed25519_pubkey, update_ts)" + " VALUES(?,?,?,?,?)" + " ON CONFLICT(node_id) DO UPDATE SET" + " name=CASE WHEN excluded.name IS NOT NULL AND excluded.name != ''" + " THEN excluded.name ELSE nodes.name END," + " x25519_pubkey=excluded.x25519_pubkey," + " ed25519_pubkey=excluded.ed25519_pubkey," + " update_ts=CASE WHEN nodes.update_ts IS NULL OR excluded.update_ts > nodes.update_ts" + " THEN excluded.update_ts ELSE nodes.update_ts END", + -1, &stmt, NULL) != SQLITE_OK) return -1; + sqlite3_bind_int64(stmt, 1, (sqlite3_int64)node_id); + sqlite3_bind_text(stmt, 2, name && name[0] ? name : "", -1, SQLITE_STATIC); + sqlite3_bind_blob(stmt, 3, x25519, 32, SQLITE_STATIC); + sqlite3_bind_blob(stmt, 4, ed25519, 32, SQLITE_STATIC); + sqlite3_bind_int64(stmt, 5, (sqlite3_int64)update_ts); sqlite3_step(stmt); sqlite3_finalize(stmt); return 0; @@ -584,3 +601,43 @@ int topo_node_sqlite_nodeinfo_updated(sqlite3* db, uint64_t node_id) { (unsigned long long)node_id, best_at, base_type); return 0; } + +void topo_node_sqlite_update_rtt(sqlite3* db, uint64_t node_id, uint16_t rtt) { + if (!db) return; + + { + sqlite3_stmt* s = NULL; + if (sqlite3_prepare_v2(db, + "UPDATE node_addresses SET rtt=? WHERE node_id=?", + -1, &s, NULL) == SQLITE_OK) { + sqlite3_bind_int(s, 1, (int)rtt); + sqlite3_bind_int64(s, 2, (sqlite3_int64)node_id); + sqlite3_step(s); + sqlite3_finalize(s); + } + } + + { + sqlite3_stmt* s = NULL; + if (sqlite3_prepare_v2(db, + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'peers\\_%' ESCAPE '\\'", + -1, &s, NULL) != SQLITE_OK) return; + + char sql[256]; + while (sqlite3_step(s) == SQLITE_ROW) { + const char* tbl = (const char*)sqlite3_column_text(s, 0); + snprintf(sql, sizeof(sql), + "UPDATE \"%s\" SET node_RTT=? WHERE node_id=?", tbl); + sqlite3_stmt* u = NULL; + if (sqlite3_prepare_v2(db, sql, -1, &u, NULL) == SQLITE_OK) { + sqlite3_bind_int(u, 1, (int)rtt); + sqlite3_bind_int64(u, 2, (sqlite3_int64)node_id); + sqlite3_step(u); sqlite3_finalize(u); + } + } + sqlite3_finalize(s); + } + + DEBUG_DEBUG(DEBUG_CATEGORY_BGP, "sqlite_update_rtt: node=%016llx rtt=%u", + (unsigned long long)node_id, (unsigned)rtt); +} diff --git a/src/routing_layer/topo_node_sqlite.h b/src/routing_layer/topo_node_sqlite.h index 08b89485..30a43dd4 100644 --- a/src/routing_layer/topo_node_sqlite.h +++ b/src/routing_layer/topo_node_sqlite.h @@ -52,4 +52,7 @@ int topo_node_sqlite_get_ed25519_pubkey(sqlite3* db, uint64_t node_id, uint8_t p /** Пересчитывает node_type во всех peers_* таблицах на основе addr_type из node_addresses */ int topo_node_sqlite_nodeinfo_updated(sqlite3* db, uint64_t node_id); +/** Обновляет RTT узла: node_addresses.rtt и все peers_*.node_RTT */ +void topo_node_sqlite_update_rtt(sqlite3* db, uint64_t node_id, uint16_t rtt); + #endif diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 77fa56a5..487691fd 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -55,6 +55,8 @@ add_executable(chatgui src/messagedelegate.cpp src/messagelist.cpp src/accountlist.cpp + src/accountdelegate.cpp + src/memberpropsdialog.cpp src/memberlistmodel.cpp src/emoji.cpp src/emojipanel.cpp diff --git a/tools/chatgui/db/db_manager.cpp b/tools/chatgui/db/db_manager.cpp index d4fa35d0..ac8e8dc6 100644 --- a/tools/chatgui/db/db_manager.cpp +++ b/tools/chatgui/db/db_manager.cpp @@ -197,6 +197,19 @@ QList DbManager::getChannelMembers(const QString& chId, int offse return list; } +void DbManager::cleanOrphanedPeers(const QString& chId) const { + if (!m_db) return; + QString sql = QStringLiteral( + "DELETE FROM \"%1\" WHERE node_id NOT IN (SELECT node_id FROM nodes)" + ).arg(peersTableName(chId)); + char* err = nullptr; + int rc = sqlite3_exec(m_db, sql.toUtf8().constData(), nullptr, nullptr, &err); + if (rc != SQLITE_OK) { + qWarning("cleanOrphanedPeers: %s", err ? err : "?"); + sqlite3_free(err); + } +} + QString DbManager::getDisplayName(quint64 nodeId) const { sqlite3_stmt* st = prepareOrNull( "SELECT display_name FROM accounts WHERE node_id=?"); diff --git a/tools/chatgui/db/db_manager.h b/tools/chatgui/db/db_manager.h index 4b570f4b..3eeecc72 100644 --- a/tools/chatgui/db/db_manager.h +++ b/tools/chatgui/db/db_manager.h @@ -88,6 +88,7 @@ public: /* ── Участники канала (UI) ── */ int getChannelMemberCount(const QString& chId) const; QList getChannelMembers(const QString& chId, int offset, int limit) const; + void cleanOrphanedPeers(const QString& chId) const; QString getDisplayName(quint64 nodeId) const; /* ── Узлы ── */ diff --git a/tools/chatgui/src/accountdelegate.cpp b/tools/chatgui/src/accountdelegate.cpp new file mode 100644 index 00000000..7cc03126 --- /dev/null +++ b/tools/chatgui/src/accountdelegate.cpp @@ -0,0 +1,60 @@ +#include "accountdelegate.h" +#include "memberlistmodel.h" +#include +#include + +AccountDelegate::AccountDelegate(QObject *parent) + : QStyledItemDelegate(parent) {} + +void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const { + painter->save(); + + if (option.state & QStyle::State_Selected) + painter->fillRect(option.rect, option.palette.highlight()); + else if (option.state & QStyle::State_MouseOver) + painter->fillRect(option.rect, QColor(240, 240, 240)); + + const int margin = 4; + const int iconSz = 24; + + QIcon icon = qvariant_cast(index.data(Qt::DecorationRole)); + if (!icon.isNull()) + icon.paint(painter, option.rect.left() + margin, option.rect.top() + (option.rect.height() - iconSz) / 2, iconSz, iconSz); + + const int x = option.rect.left() + iconSz + margin * 3; + const int w = option.rect.right() - x - margin; + const int yMid = option.rect.top() + option.rect.height() / 2; + + QFont nameFont = painter->font(); + nameFont.setBold(true); + nameFont.setPixelSize(13); + painter->setFont(nameFont); + + QColor textColor = option.palette.color(QPalette::Text); + if (option.state & QStyle::State_Selected) textColor = option.palette.color(QPalette::HighlightedText); + painter->setPen(textColor); + + QString name = index.data(Qt::DisplayRole).toString(); + painter->drawText(QRect(x, option.rect.top() + 2, w, yMid - option.rect.top() - 2), + Qt::AlignLeft | Qt::AlignBottom, name); + + QFont subFont = painter->font(); + subFont.setBold(false); + subFont.setPixelSize(10); + painter->setFont(subFont); + painter->setPen(QColor(130, 130, 130)); + + QString sub = index.data(Qt::UserRole + 1).toString(); + if (!sub.isEmpty()) + painter->drawText(QRect(x, yMid + 2, w, option.rect.bottom() - yMid - 2), + Qt::AlignLeft | Qt::AlignTop, sub); + + painter->restore(); +} + +QSize AccountDelegate::sizeHint(const QStyleOptionViewItem &option, + const QModelIndex &index) const { + (void)option; (void)index; + return QSize(160, 36); +} diff --git a/tools/chatgui/src/accountdelegate.h b/tools/chatgui/src/accountdelegate.h new file mode 100644 index 00000000..9263852d --- /dev/null +++ b/tools/chatgui/src/accountdelegate.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +class AccountDelegate : public QStyledItemDelegate { + Q_OBJECT +public: + explicit AccountDelegate(QObject *parent = nullptr); + + void paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const override; + QSize sizeHint(const QStyleOptionViewItem &option, + const QModelIndex &index) const override; +}; diff --git a/tools/chatgui/src/accountlist.cpp b/tools/chatgui/src/accountlist.cpp index 8e941bd2..f045f907 100644 --- a/tools/chatgui/src/accountlist.cpp +++ b/tools/chatgui/src/accountlist.cpp @@ -1,5 +1,7 @@ #include "accountlist.h" +#include "accountdelegate.h" #include "memberlistmodel.h" +#include "memberpropsdialog.h" #include "../db/db_manager.h" #include #include @@ -52,11 +54,13 @@ AccountList::AccountList(DbManager* db, QWidget *parent) layout->addWidget(header); m_listView->setModel(m_model); + m_listView->setItemDelegate(new AccountDelegate(m_listView)); m_listView->setEditTriggers(QAbstractItemView::NoEditTriggers); m_listView->setSelectionMode(QAbstractItemView::SingleSelection); m_listView->setIconSize(QSize(24, 24)); m_listView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); m_listView->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + m_listView->setContextMenuPolicy(Qt::CustomContextMenu); layout->addWidget(m_listView, 3); m_detailText = new QPlainTextEdit(this); @@ -69,6 +73,13 @@ AccountList::AccountList(DbManager* db, QWidget *parent) layout->addWidget(m_detailText, 2); connect(m_listView, &QListView::clicked, this, &AccountList::onMemberClicked); + + m_refreshTimer = new QTimer(this); + m_refreshTimer->start(2000); + connect(m_refreshTimer, &QTimer::timeout, m_model, &MemberListModel::refreshRtt); + + connect(m_listView, &QWidget::customContextMenuRequested, + this, &AccountList::onMemberContextMenu); } void AccountList::setChannel(const QString& channelId) { @@ -153,3 +164,21 @@ void AccountList::showMemberDetail(quint64 nodeId) { m_detailText->setPlainText(text); } + +void AccountList::onMemberContextMenu(const QPoint& pos) { + QModelIndex index = m_listView->indexAt(pos); + if (!index.isValid()) return; + + quint64 nodeId = m_model->nodeIdAt(index.row()); + if (!nodeId) return; + + QString name = m_model->data(index, Qt::DisplayRole).toString(); + + QMenu menu(m_listView); + QAction* editAction = menu.addAction("Edit"); + QAction* chosen = menu.exec(m_listView->viewport()->mapToGlobal(pos)); + if (chosen != editAction) return; + + MemberPropsDialog dlg(m_db, m_channelId, nodeId, name, this); + dlg.exec(); +} diff --git a/tools/chatgui/src/accountlist.h b/tools/chatgui/src/accountlist.h index 9e0fe604..678011fd 100644 --- a/tools/chatgui/src/accountlist.h +++ b/tools/chatgui/src/accountlist.h @@ -3,6 +3,8 @@ #include #include #include +#include +#include class MemberListModel; class DbManager; @@ -17,6 +19,7 @@ public: private slots: void onMemberClicked(const QModelIndex& index); + void onMemberContextMenu(const QPoint& pos); private: void showMemberDetail(quint64 nodeId); @@ -25,5 +28,6 @@ private: MemberListModel *m_model; DbManager *m_db; QPlainTextEdit *m_detailText; + QTimer *m_refreshTimer; QString m_channelId; }; diff --git a/tools/chatgui/src/memberlistmodel.cpp b/tools/chatgui/src/memberlistmodel.cpp index 1c8d40d8..94c6f0bd 100644 --- a/tools/chatgui/src/memberlistmodel.cpp +++ b/tools/chatgui/src/memberlistmodel.cpp @@ -20,6 +20,17 @@ QVariant MemberListModel::data(const QModelIndex& index, int role) const { if (it != m_cache.end()) { if (role == Qt::DisplayRole) return it->name; if (role == Qt::DecorationRole) return QVariant::fromValue(QIcon(it->avatar)); + if (role == Qt::UserRole + 1) { + QString sub; + if (it->rtt >= 0) sub += QString("rtt=%1ms").arg(it->rtt); + if (it->natType >= 0) { + static const char* nat[] = {"NET","DIR","EIM","STR"}; + const char* nt = (it->natType <= 3) ? nat[it->natType] : "?"; + if (!sub.isEmpty()) sub += " "; + sub += nt; + } + return sub; + } if (role == Qt::ToolTipRole) return QString(it->online ? "online" : "offline") + " | 0x" + QString("%1").arg(it->nodeId, 16, 16, QChar('0')); @@ -36,6 +47,7 @@ QVariant MemberListModel::data(const QModelIndex& index, int role) const { void MemberListModel::setChannel(const QString& channelId) { beginResetModel(); m_channelId = channelId; + m_db->cleanOrphanedPeers(channelId); m_totalCount = m_db ? m_db->getChannelMemberCount(channelId) : 0; m_cache.clear(); m_pendingFrom = -1; @@ -84,12 +96,83 @@ void MemberListModel::loadBatch(int from, int count) { QColor c = members[i].online ? QColor("#4CAF50") : QColor("#9E9E9E"); item.avatar = makeAvatar(c, item.name[0]); } + + // batch-загрузка RTT и NAT типа из node_addresses + QStringList idList; + for (int i = 0; i < members.size(); i++) + idList.append(QString::number(members[i].nodeId)); + QString ids = idList.isEmpty() ? QString("0") : idList.join(","); + + QByteArray sql = QString( + "SELECT node_id, MIN(rtt), MIN(addr_type) FROM node_addresses" + " WHERE node_id IN (%1) AND rtt > 0 GROUP BY node_id" + ).arg(ids).toUtf8(); + + sqlite3_stmt* na = nullptr; + if (sqlite3_prepare_v2(m_db->m_db, sql.constData(), -1, &na, nullptr) == SQLITE_OK) { + while (sqlite3_step(na) == SQLITE_ROW) { + quint64 nid = (quint64)sqlite3_column_int64(na, 0); + int rttVal = sqlite3_column_int(na, 1); + int at = sqlite3_column_int(na, 2); + for (int i = 0; i < members.size(); i++) { + if (members[i].nodeId == nid) { + int row = from + i; + auto it = m_cache.find(row); + if (it != m_cache.end()) { + it->rtt = rttVal; + it->natType = at; + } + break; + } + } + } + sqlite3_finalize(na); + } + evictDistant(from + BATCH / 2); int last = from + (int)members.size() - 1; if (last >= from) emit dataChanged(index(from), index(last)); } +void MemberListModel::refreshRtt() { + if (m_cache.isEmpty() || !m_db || !m_db->m_db) return; + + QStringList idList; + for (auto it = m_cache.begin(); it != m_cache.end(); ++it) + idList.append(QString::number(it->nodeId)); + if (idList.isEmpty()) return; + QString ids = idList.join(","); + + QByteArray sql = QString( + "SELECT node_id, MIN(rtt), MIN(addr_type) FROM node_addresses" + " WHERE node_id IN (%1) AND rtt > 0 GROUP BY node_id" + ).arg(ids).toUtf8(); + + sqlite3_stmt* na = nullptr; + if (sqlite3_prepare_v2(m_db->m_db, sql.constData(), -1, &na, nullptr) != SQLITE_OK) return; + + QList changed; + while (sqlite3_step(na) == SQLITE_ROW) { + quint64 nid = (quint64)sqlite3_column_int64(na, 0); + int rttVal = sqlite3_column_int(na, 1); + int at = sqlite3_column_int(na, 2); + for (auto it = m_cache.begin(); it != m_cache.end(); ++it) { + if (it->nodeId == nid) { + if (it->rtt != rttVal || it->natType != at) { + it->rtt = rttVal; + it->natType = at; + changed.append(it.key()); + } + break; + } + } + } + sqlite3_finalize(na); + + for (int row : changed) emit dataChanged(index(row), index(row), {Qt::UserRole + 1}); +} + void MemberListModel::evictDistant(int centerRow) { if (m_cache.size() <= MAX_CACHE) return; int half = MAX_CACHE / 2; diff --git a/tools/chatgui/src/memberlistmodel.h b/tools/chatgui/src/memberlistmodel.h index 7d72885c..7e9832e5 100644 --- a/tools/chatgui/src/memberlistmodel.h +++ b/tools/chatgui/src/memberlistmodel.h @@ -15,6 +15,8 @@ struct MemberListItem { bool online = false; QPixmap avatar; QString name; + int rtt = -1; // ms (min из node_addresses, -1 если нет) + int natType = -1; // addr_type: 0=NET, 1=DIR, 2=EIM, 3=STR }; class MemberListModel : public QAbstractListModel { @@ -30,6 +32,7 @@ public: void setChannel(const QString& channelId); void refresh(); + void refreshRtt(); quint64 nodeIdAt(int row) const; private slots: diff --git a/tools/chatgui/src/memberpropsdialog.cpp b/tools/chatgui/src/memberpropsdialog.cpp new file mode 100644 index 00000000..5dd50eae --- /dev/null +++ b/tools/chatgui/src/memberpropsdialog.cpp @@ -0,0 +1,168 @@ +#include "memberpropsdialog.h" +#include "../db/db_manager.h" +#include +#include +#include +#include +#include + +#include + +MemberPropsDialog::MemberPropsDialog(DbManager* db, const QString& channelId, + quint64 nodeId, const QString& memberName, + QWidget* parent) + : QDialog(parent) + , m_db(db) + , m_channelId(channelId) + , m_nodeId(nodeId) +{ + setWindowTitle("Member Properties"); + setMinimumWidth(340); + setModal(true); + + // читаем ed25519_privkey канала + auto chs = m_db->getChannels(); + for (const auto& ch : chs) { + if (ch.channelId == channelId) { + m_edPrivkey = ch.ed25519_privkey; + break; + } + } + bool haveKey = (m_edPrivkey.size() == 32); + + // читаем текущий adm_tags + bool isAdmin = false, isSuper = false; + if (m_db->m_db) { + QString tbl = m_db->peersTableName(channelId); + QByteArray sql = QString( + "SELECT adm_tags FROM \"%1\" WHERE node_id=?" + ).arg(tbl).toUtf8(); + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(m_db->m_db, sql.constData(), -1, &st, nullptr) == SQLITE_OK) { + sqlite3_bind_int64(st, 1, (sqlite3_int64)nodeId); + if (sqlite3_step(st) == SQLITE_ROW) { + const char* tags = (const char*)sqlite3_column_text(st, 0); + if (tags) { + if (strstr(tags, "admin=yes")) isAdmin = true; + if (strstr(tags, "supernode=yes")) isSuper = true; + } + } + sqlite3_finalize(st); + } + // страховка: добавить колонки если нет + QByteArray alt = QString( + "ALTER TABLE \"%1\" ADD COLUMN adm_tags TEXT" + ).arg(tbl).toUtf8(); + sqlite3_exec(m_db->m_db, alt.constData(), nullptr, nullptr, nullptr); + alt = QString( + "ALTER TABLE \"%1\" ADD COLUMN adm_tags_sig BLOB" + ).arg(tbl).toUtf8(); + sqlite3_exec(m_db->m_db, alt.constData(), nullptr, nullptr, nullptr); + } + + auto* layout = new QVBoxLayout(this); + layout->setSpacing(12); + layout->setContentsMargins(20, 16, 20, 16); + + auto* title = new QLabel( + QString("Node: 0x%1").arg(nodeId, 16, 16, QChar('0')) + + (memberName.isEmpty() ? "" : QString(" (%1)").arg(memberName)), + this); + title->setStyleSheet("font-weight: bold; font-size: 13px;"); + layout->addWidget(title); + + m_adminCb = new QCheckBox("Admin", this); + m_adminCb->setChecked(isAdmin); + m_adminCb->setEnabled(haveKey); + layout->addWidget(m_adminCb); + + m_superCb = new QCheckBox("Supernode", this); + m_superCb->setChecked(isSuper); + m_superCb->setEnabled(haveKey); + layout->addWidget(m_superCb); + + if (!haveKey) { + auto* hint = new QLabel("(requires channel private key)", this); + hint->setStyleSheet("color: gray; font-size: 11px;"); + layout->addWidget(hint); + } + + layout->addStretch(); + + auto* btnLayout = new QHBoxLayout(); + btnLayout->addStretch(); + + auto* cancelBtn = new QPushButton("Cancel", this); + cancelBtn->setFixedWidth(90); + cancelBtn->setStyleSheet( + "QPushButton { border: 1px solid palette(mid); border-radius: 4px;" + " padding: 6px 16px; font-size: 13px; background: palette(button); }" + "QPushButton:hover { background: palette(light); }"); + connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); + btnLayout->addWidget(cancelBtn); + + auto* saveBtn = new QPushButton("Save", this); + saveBtn->setFixedWidth(90); + saveBtn->setDefault(true); + saveBtn->setEnabled(haveKey); + saveBtn->setStyleSheet( + "QPushButton { border: none; border-radius: 4px;" + " padding: 6px 16px; font-size: 13px; font-weight: bold;" + " background: #3390EC; color: white; }" + "QPushButton:hover { background: #2B7ED4; }" + "QPushButton:disabled { background: palette(mid); color: palette(midlight); }"); + connect(saveBtn, &QPushButton::clicked, this, &MemberPropsDialog::onSave); + btnLayout->addWidget(saveBtn); + + layout->addLayout(btnLayout); +} + +void MemberPropsDialog::onSave() { + if (m_edPrivkey.size() != 32 || !m_db->m_db) return; + + // собираем adm_tags + QString tags; + if (m_adminCb->isChecked()) tags += "admin=yes"; + if (m_superCb->isChecked()) { + if (!tags.isEmpty()) tags += ","; + tags += "supernode=yes"; + } + + // подписываем: tags || node_id(8 LE) + QByteArray msg = tags.toUtf8(); + uint64_t le = htole64(m_nodeId); + msg.append((const char*)&le, 8); + + uint8_t sig[64] = {}; + EVP_PKEY* pkey = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, nullptr, + (const uint8_t*)m_edPrivkey.constData(), 32); + if (pkey) { + EVP_MD_CTX* mdctx = EVP_MD_CTX_new(); + if (mdctx) { + size_t siglen = 64; + if (EVP_DigestSignInit(mdctx, nullptr, nullptr, nullptr, pkey) == 1) + EVP_DigestSign(mdctx, sig, &siglen, (const uint8_t*)msg.constData(), msg.size()); + EVP_MD_CTX_free(mdctx); + } + EVP_PKEY_free(pkey); + } + + // пишем в БД + QString tbl = m_db->peersTableName(m_channelId); + QByteArray sql = QString( + "UPDATE \"%1\" SET adm_tags=?, adm_tags_sig=?, node_type=? WHERE node_id=?" + ).arg(tbl).toUtf8(); + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(m_db->m_db, sql.constData(), -1, &st, nullptr) == SQLITE_OK) { + sqlite3_bind_text(st, 1, tags.isEmpty() ? nullptr : tags.toUtf8().constData(), + -1, tags.isEmpty() ? SQLITE_STATIC : SQLITE_TRANSIENT); + sqlite3_bind_blob(st, 2, sig, 64, SQLITE_TRANSIENT); + int nt = m_superCb->isChecked() ? 4 : 1; + sqlite3_bind_int(st, 3, nt); + sqlite3_bind_int64(st, 4, (sqlite3_int64)m_nodeId); + sqlite3_step(st); + sqlite3_finalize(st); + } + + accept(); +} diff --git a/tools/chatgui/src/memberpropsdialog.h b/tools/chatgui/src/memberpropsdialog.h new file mode 100644 index 00000000..78535407 --- /dev/null +++ b/tools/chatgui/src/memberpropsdialog.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include +#include + +class DbManager; + +class MemberPropsDialog : public QDialog { + Q_OBJECT +public: + explicit MemberPropsDialog(DbManager* db, const QString& channelId, + quint64 nodeId, const QString& memberName, + QWidget* parent = nullptr); + +private slots: + void onSave(); + +private: + DbManager* m_db; + QString m_channelId; + quint64 m_nodeId; + QByteArray m_edPrivkey; + + QCheckBox* m_adminCb; + QCheckBox* m_superCb; +};