M5: moderation, permissions, channel CRUD, in-app account management
- Server-side moderation & permissions (kick/ban/move/server-mute, channel CRUD). - Database schema v2: channels, bans; BLAKE2b channel passwords, Argon2id accounts. - C ABI additions and client-side handling (vc_kick_user, vc_ban_user, vc_set_permission, vc_set_server_mute, vc_move_user, vc_create/edit/delete_channel, vc_create/reset/delete/list_account). - vccli flags for all M5 operations plus --username/--password auth. - Four new tests covering permissions, kick/ban/move/mute, admin accounts, channel CRUD. - Docs: protocol.md envelope updates, security.md channel-password hashing, PROGRESS.md.
This commit is contained in:
@@ -22,6 +22,22 @@ static voicecat::v1::Envelope make_env(uint64_t req_id = 0) {
|
||||
return e;
|
||||
}
|
||||
|
||||
static voicecat::v1::Permissions all_permissions() {
|
||||
voicecat::v1::Permissions p;
|
||||
p.set_can_create_temp_channel(true);
|
||||
p.set_can_kick(true);
|
||||
p.set_can_ban(true);
|
||||
p.set_can_move_users(true);
|
||||
p.set_can_admin_accounts(true);
|
||||
p.set_is_admin(true);
|
||||
return p;
|
||||
}
|
||||
|
||||
static voicecat::v1::Permissions no_permissions() {
|
||||
voicecat::v1::Permissions p;
|
||||
return p;
|
||||
}
|
||||
|
||||
ConnSession::ConnSession(std::shared_ptr<Database> db,
|
||||
std::shared_ptr<SessionRegistry> registry,
|
||||
std::shared_ptr<voicecat::WorkerPool> workers,
|
||||
@@ -73,7 +89,7 @@ void ConnSession::on_frame(std::vector<uint8_t> frame) {
|
||||
break;
|
||||
case voicecat::v1::Envelope::kLeaveChannel:
|
||||
if (st == State::Authenticated)
|
||||
registry_->set_user_channel(user_id_.load(), 1);
|
||||
handle_leave_channel();
|
||||
break;
|
||||
case voicecat::v1::Envelope::kUdpBinding:
|
||||
if (st == State::Authenticated)
|
||||
@@ -87,6 +103,54 @@ void ConnSession::on_frame(std::vector<uint8_t> frame) {
|
||||
if (st == State::Authenticated)
|
||||
handle_stream_stop(env.stream_stop());
|
||||
break;
|
||||
|
||||
// ── M5 moderation / admin ─────────────────────────────────────────────
|
||||
case voicecat::v1::Envelope::kKick:
|
||||
if (st == State::Authenticated) handle_kick_request(env.request_id(), env.kick());
|
||||
break;
|
||||
case voicecat::v1::Envelope::kBan:
|
||||
if (st == State::Authenticated) handle_ban_request(env.request_id(), env.ban());
|
||||
break;
|
||||
case voicecat::v1::Envelope::kSetPermission:
|
||||
if (st == State::Authenticated)
|
||||
handle_set_permission(env.request_id(), env.set_permission());
|
||||
break;
|
||||
case voicecat::v1::Envelope::kServerMute:
|
||||
if (st == State::Authenticated)
|
||||
handle_server_mute_request(env.request_id(), env.server_mute());
|
||||
break;
|
||||
case voicecat::v1::Envelope::kMoveUser:
|
||||
if (st == State::Authenticated) handle_move_user(env.request_id(), env.move_user());
|
||||
break;
|
||||
case voicecat::v1::Envelope::kCreateChannel:
|
||||
if (st == State::Authenticated)
|
||||
handle_create_channel(env.request_id(), env.create_channel());
|
||||
break;
|
||||
case voicecat::v1::Envelope::kEditChannel:
|
||||
if (st == State::Authenticated)
|
||||
handle_edit_channel(env.request_id(), env.edit_channel());
|
||||
break;
|
||||
case voicecat::v1::Envelope::kDeleteChannel:
|
||||
if (st == State::Authenticated)
|
||||
handle_delete_channel(env.request_id(), env.delete_channel());
|
||||
break;
|
||||
case voicecat::v1::Envelope::kCreateAccount:
|
||||
if (st == State::Authenticated)
|
||||
handle_create_account(env.request_id(), env.create_account());
|
||||
break;
|
||||
case voicecat::v1::Envelope::kResetPassword:
|
||||
if (st == State::Authenticated)
|
||||
handle_reset_password(env.request_id(), env.reset_password());
|
||||
break;
|
||||
case voicecat::v1::Envelope::kDeleteAccount:
|
||||
if (st == State::Authenticated)
|
||||
handle_delete_account(env.request_id(), env.delete_account());
|
||||
break;
|
||||
case voicecat::v1::Envelope::kListAccounts:
|
||||
if (st == State::Authenticated)
|
||||
handle_list_accounts(env.request_id(), env.list_accounts());
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -149,6 +213,27 @@ asio::ip::udp::endpoint ConnSession::udp_endpoint() const {
|
||||
return udp_ep_;
|
||||
}
|
||||
|
||||
// ── Permission helpers ───────────────────────────────────────────────────────
|
||||
|
||||
bool ConnSession::has_permission(bool (voicecat::v1::Permissions::* getter)() const) const {
|
||||
return (permissions_.*getter)();
|
||||
}
|
||||
|
||||
void ConnSession::set_permissions(const voicecat::v1::Permissions& perms) {
|
||||
permissions_ = perms;
|
||||
registry_->set_session_permissions(session_id_, perms);
|
||||
}
|
||||
|
||||
void ConnSession::send_generic_result(uint64_t req_id, bool ok, uint32_t code,
|
||||
const std::string& message) {
|
||||
auto env = make_env(req_id);
|
||||
auto* gr = env.mutable_generic_result();
|
||||
gr->set_ok(ok);
|
||||
gr->set_code(code);
|
||||
gr->set_message(message);
|
||||
send_envelope(env);
|
||||
}
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
void ConnSession::handle_client_hello(uint64_t req_id, const voicecat::v1::ClientHello& msg) {
|
||||
@@ -190,6 +275,7 @@ void ConnSession::finish_guest_auth(const voicecat::v1::GuestAuth& guest, uint64
|
||||
send_envelope(env);
|
||||
return;
|
||||
}
|
||||
|
||||
voicecat::v1::User user;
|
||||
user.set_nickname(guest.nickname().empty() ? "Guest" : guest.nickname());
|
||||
user.set_is_guest(true);
|
||||
@@ -200,6 +286,8 @@ void ConnSession::finish_guest_auth(const voicecat::v1::GuestAuth& guest, uint64
|
||||
user_id_.store(uid, std::memory_order_relaxed);
|
||||
state_.store(State::Authenticated, std::memory_order_release);
|
||||
|
||||
permissions_ = no_permissions();
|
||||
registry_->set_session_permissions(session_id_, permissions_);
|
||||
registry_->register_udp_token(udp_token_, session_id_);
|
||||
|
||||
{
|
||||
@@ -208,6 +296,7 @@ void ConnSession::finish_guest_auth(const voicecat::v1::GuestAuth& guest, uint64
|
||||
res->set_ok(true);
|
||||
res->set_session_id(session_id_);
|
||||
*res->mutable_self() = user;
|
||||
*res->mutable_permissions() = permissions_;
|
||||
res->set_udp_token(udp_token_.data(), udp_token_.size());
|
||||
send_envelope(env);
|
||||
}
|
||||
@@ -220,6 +309,15 @@ void ConnSession::finish_password_auth(const std::string& username,
|
||||
// Argon2id runs on the worker pool (deliberately slow).
|
||||
auto self = shared_from_this();
|
||||
workers_->post([self, username, password, req_id] {
|
||||
// M5: check username bans before verifying password.
|
||||
if (self->db_->ban_check("username", username)) {
|
||||
auto env = make_env(req_id);
|
||||
env.mutable_auth_result()->set_ok(false);
|
||||
env.mutable_auth_result()->set_error("account banned");
|
||||
self->send_envelope(env);
|
||||
return;
|
||||
}
|
||||
|
||||
auto acc = self->db_->authenticate(username, password);
|
||||
if (!acc) {
|
||||
auto env = make_env(req_id);
|
||||
@@ -238,6 +336,9 @@ void ConnSession::finish_password_auth(const std::string& username,
|
||||
self->user_id_.store(uid, std::memory_order_relaxed);
|
||||
self->state_.store(State::Authenticated, std::memory_order_release);
|
||||
|
||||
voicecat::v1::Permissions perms = acc->is_admin ? all_permissions() : no_permissions();
|
||||
self->permissions_ = perms;
|
||||
self->registry_->set_session_permissions(self->session_id_, perms);
|
||||
self->registry_->register_udp_token(self->udp_token_, self->session_id_);
|
||||
|
||||
{
|
||||
@@ -246,8 +347,7 @@ void ConnSession::finish_password_auth(const std::string& username,
|
||||
res->set_ok(true);
|
||||
res->set_session_id(self->session_id_);
|
||||
*res->mutable_self() = user;
|
||||
auto* perms = res->mutable_permissions();
|
||||
perms->set_is_admin(acc->is_admin);
|
||||
*res->mutable_permissions() = perms;
|
||||
res->set_udp_token(self->udp_token_.data(), self->udp_token_.size());
|
||||
self->send_envelope(env);
|
||||
}
|
||||
@@ -274,15 +374,75 @@ void ConnSession::broadcast_user_joined(const voicecat::v1::User& user) {
|
||||
|
||||
void ConnSession::handle_join_channel(uint64_t req_id,
|
||||
const voicecat::v1::JoinChannelRequest& msg) {
|
||||
bool ok = registry_->set_user_channel(user_id_.load(), msg.channel_id());
|
||||
uint32_t uid = user_id_.load();
|
||||
auto ch = registry_->get_channel(msg.channel_id());
|
||||
if (!ch) {
|
||||
auto env = make_env(req_id);
|
||||
auto* res = env.mutable_join_channel_result();
|
||||
res->set_ok(false);
|
||||
res->set_error("channel not found");
|
||||
send_envelope(env);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ch->password_protected()) {
|
||||
if (!registry_->check_channel_password(msg.channel_id(), msg.password())) {
|
||||
auto env = make_env(req_id);
|
||||
auto* res = env.mutable_join_channel_result();
|
||||
res->set_ok(false);
|
||||
res->set_error("invalid channel password");
|
||||
send_envelope(env);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (ch->max_users() > 0) {
|
||||
auto members = registry_->find_channel_sessions(msg.channel_id(), session_id_);
|
||||
if (members.size() >= ch->max_users()) {
|
||||
auto env = make_env(req_id);
|
||||
auto* res = env.mutable_join_channel_result();
|
||||
res->set_ok(false);
|
||||
res->set_error("channel is full");
|
||||
send_envelope(env);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
bool ok = registry_->set_user_channel(uid, msg.channel_id());
|
||||
auto env = make_env(req_id);
|
||||
auto* res = env.mutable_join_channel_result();
|
||||
res->set_ok(ok);
|
||||
if (!ok) res->set_error("channel not found");
|
||||
else res->set_channel_id(msg.channel_id());
|
||||
if (!ok) {
|
||||
res->set_error("channel not found");
|
||||
} else {
|
||||
res->set_channel_id(msg.channel_id());
|
||||
*res->mutable_audio() = ch->audio();
|
||||
|
||||
// Broadcast that this user changed channel.
|
||||
if (auto updated_user = registry_->user_snapshot_user(uid)) {
|
||||
auto bcast = make_env();
|
||||
auto* ue = bcast.mutable_user_event();
|
||||
ue->set_kind(voicecat::v1::UserEvent::UPDATED);
|
||||
*ue->mutable_user() = *updated_user;
|
||||
registry_->broadcast(bcast, session_id_);
|
||||
}
|
||||
}
|
||||
send_envelope(env);
|
||||
}
|
||||
|
||||
void ConnSession::handle_leave_channel() {
|
||||
uint32_t uid = user_id_.load();
|
||||
if (!uid) return;
|
||||
if (!registry_->set_user_channel(uid, 1)) return;
|
||||
if (auto updated_user = registry_->user_snapshot_user(uid)) {
|
||||
auto bcast = make_env();
|
||||
auto* ue = bcast.mutable_user_event();
|
||||
ue->set_kind(voicecat::v1::UserEvent::UPDATED);
|
||||
*ue->mutable_user() = *updated_user;
|
||||
registry_->broadcast(bcast, session_id_);
|
||||
}
|
||||
}
|
||||
|
||||
void ConnSession::handle_text_message(const voicecat::v1::TextMessage& msg) {
|
||||
using namespace std::chrono;
|
||||
int64_t now_ms = duration_cast<milliseconds>(
|
||||
@@ -404,6 +564,170 @@ void ConnSession::handle_stream_stop(const voicecat::v1::StreamStop& msg) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── M5 handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
void ConnSession::handle_kick_request(uint64_t req_id, const voicecat::v1::KickRequest& msg) {
|
||||
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_kick)) {
|
||||
send_generic_result(req_id, false, 6, "permission denied");
|
||||
return;
|
||||
}
|
||||
bool ok = registry_->kick_user(msg.user_id(), msg.reason());
|
||||
send_generic_result(req_id, ok, ok ? 0 : 3, ok ? "" : "user not found");
|
||||
}
|
||||
|
||||
void ConnSession::handle_ban_request(uint64_t req_id, const voicecat::v1::BanRequest& msg) {
|
||||
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_ban)) {
|
||||
send_generic_result(req_id, false, 6, "permission denied");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ban by username for persistence (nickname == username for password users).
|
||||
// Also ban by runtime user_id for immediate effect.
|
||||
if (auto target = registry_->find_session_by_user_id(msg.user_id())) {
|
||||
if (!target->user_id()) {
|
||||
send_generic_result(req_id, false, 3, "user not found");
|
||||
return;
|
||||
}
|
||||
if (auto nick = registry_->user_nickname(target->user_id())) {
|
||||
std::string err;
|
||||
db_->ban_create("username", *nick, msg.reason(),
|
||||
static_cast<int64_t>(msg.expires_unix_ms()), err);
|
||||
}
|
||||
}
|
||||
|
||||
bool ok = registry_->ban_user(msg.user_id(), msg.reason(),
|
||||
static_cast<int64_t>(msg.expires_unix_ms()));
|
||||
send_generic_result(req_id, ok, ok ? 0 : 3, ok ? "" : "user not found");
|
||||
}
|
||||
|
||||
void ConnSession::handle_set_permission(uint64_t req_id,
|
||||
const voicecat::v1::SetPermissionRequest& msg) {
|
||||
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_admin_accounts)) {
|
||||
send_generic_result(req_id, false, 6, "permission denied");
|
||||
return;
|
||||
}
|
||||
auto target = registry_->find_session_by_user_id(msg.user_id());
|
||||
if (!target) {
|
||||
send_generic_result(req_id, false, 3, "user not online");
|
||||
return;
|
||||
}
|
||||
target->set_permissions(msg.permissions());
|
||||
send_generic_result(req_id, true, 0, "");
|
||||
}
|
||||
|
||||
void ConnSession::handle_server_mute_request(uint64_t req_id,
|
||||
const voicecat::v1::ServerMuteRequest& msg) {
|
||||
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_kick)) {
|
||||
send_generic_result(req_id, false, 6, "permission denied");
|
||||
return;
|
||||
}
|
||||
bool ok = registry_->set_server_mute(msg.user_id(), msg.muted(), msg.deafened());
|
||||
send_generic_result(req_id, ok, ok ? 0 : 3, ok ? "" : "user not found");
|
||||
}
|
||||
|
||||
void ConnSession::handle_move_user(uint64_t req_id, const voicecat::v1::MoveUserRequest& msg) {
|
||||
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_move_users)) {
|
||||
send_generic_result(req_id, false, 6, "permission denied");
|
||||
return;
|
||||
}
|
||||
bool ok = registry_->move_user(msg.user_id(), msg.channel_id());
|
||||
send_generic_result(req_id, ok, ok ? 0 : 3, ok ? "" : "user or channel not found");
|
||||
}
|
||||
|
||||
void ConnSession::handle_create_channel(uint64_t req_id,
|
||||
const voicecat::v1::CreateChannelRequest& msg) {
|
||||
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_create_temp_channel)) {
|
||||
send_generic_result(req_id, false, 6, "permission denied");
|
||||
return;
|
||||
}
|
||||
std::string error;
|
||||
uint32_t id = registry_->create_channel(msg.channel(), msg.password(), error);
|
||||
send_generic_result(req_id, id != 0, id != 0 ? 0 : 3,
|
||||
id != 0 ? "" : (error.empty() ? "create failed" : error));
|
||||
}
|
||||
|
||||
void ConnSession::handle_edit_channel(uint64_t req_id,
|
||||
const voicecat::v1::EditChannelRequest& msg) {
|
||||
if (!is_admin()) {
|
||||
send_generic_result(req_id, false, 6, "permission denied");
|
||||
return;
|
||||
}
|
||||
std::string error;
|
||||
bool ok = registry_->update_channel(msg.channel(), msg.password(), error);
|
||||
send_generic_result(req_id, ok, ok ? 0 : 3,
|
||||
ok ? "" : (error.empty() ? "update failed" : error));
|
||||
}
|
||||
|
||||
void ConnSession::handle_delete_channel(uint64_t req_id,
|
||||
const voicecat::v1::DeleteChannelRequest& msg) {
|
||||
if (!is_admin()) {
|
||||
send_generic_result(req_id, false, 6, "permission denied");
|
||||
return;
|
||||
}
|
||||
std::string error;
|
||||
bool ok = registry_->delete_channel(msg.channel_id(), error);
|
||||
send_generic_result(req_id, ok, ok ? 0 : 3,
|
||||
ok ? "" : (error.empty() ? "delete failed" : error));
|
||||
}
|
||||
|
||||
void ConnSession::handle_create_account(uint64_t req_id,
|
||||
const voicecat::v1::CreateAccountRequest& msg) {
|
||||
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_admin_accounts)) {
|
||||
send_generic_result(req_id, false, 6, "permission denied");
|
||||
return;
|
||||
}
|
||||
auto self = shared_from_this();
|
||||
workers_->post([self, req_id, msg]() mutable {
|
||||
std::string error;
|
||||
auto acc = self->db_->create_account(msg.username(), msg.password(), false, error);
|
||||
self->send_generic_result(req_id, acc.has_value(), acc.has_value() ? 0 : 3,
|
||||
acc.has_value() ? "" : error);
|
||||
});
|
||||
}
|
||||
|
||||
void ConnSession::handle_reset_password(uint64_t req_id,
|
||||
const voicecat::v1::ResetPasswordRequest& msg) {
|
||||
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_admin_accounts)) {
|
||||
send_generic_result(req_id, false, 6, "permission denied");
|
||||
return;
|
||||
}
|
||||
auto self = shared_from_this();
|
||||
workers_->post([self, req_id, msg]() mutable {
|
||||
std::string error;
|
||||
bool ok = self->db_->reset_password(msg.username(), msg.new_password(), error);
|
||||
self->send_generic_result(req_id, ok, ok ? 0 : 3, ok ? "" : error);
|
||||
});
|
||||
}
|
||||
|
||||
void ConnSession::handle_delete_account(uint64_t req_id,
|
||||
const voicecat::v1::DeleteAccountRequest& msg) {
|
||||
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_admin_accounts)) {
|
||||
send_generic_result(req_id, false, 6, "permission denied");
|
||||
return;
|
||||
}
|
||||
std::string error;
|
||||
bool ok = db_->delete_account(msg.username(), error);
|
||||
send_generic_result(req_id, ok, ok ? 0 : 3, ok ? "" : error);
|
||||
}
|
||||
|
||||
void ConnSession::handle_list_accounts(uint64_t req_id,
|
||||
const voicecat::v1::ListAccountsRequest& /*msg*/) {
|
||||
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_admin_accounts)) {
|
||||
send_generic_result(req_id, false, 6, "permission denied");
|
||||
return;
|
||||
}
|
||||
auto env = make_env(req_id);
|
||||
auto* lr = env.mutable_list_accounts_result();
|
||||
for (const auto& acc : db_->list_accounts()) {
|
||||
auto* e = lr->add_accounts();
|
||||
e->set_username(acc.username);
|
||||
e->set_is_admin(acc.is_admin);
|
||||
e->set_created_at_unix_ms(static_cast<uint64_t>(acc.created_at) * 1000);
|
||||
e->set_last_login_unix_ms(static_cast<uint64_t>(acc.last_login) * 1000);
|
||||
}
|
||||
send_envelope(env);
|
||||
}
|
||||
|
||||
void ConnSession::send_disconnect_and_close(uint32_t code, const std::string& reason) {
|
||||
auto env = make_env();
|
||||
auto* d = env.mutable_disconnect();
|
||||
|
||||
@@ -57,6 +57,10 @@ class ConnSession : public std::enable_shared_from_this<ConnSession> {
|
||||
void send_envelope(const voicecat::v1::Envelope& env);
|
||||
void close();
|
||||
|
||||
// Called by SessionRegistry for kick/ban. Public so the registry can forcibly
|
||||
// close a session without making it a friend class.
|
||||
void send_disconnect_and_close(uint32_t code, const std::string& reason);
|
||||
|
||||
// ── M2: media key injection (called from on_tls_ready) ───────────────────
|
||||
void set_media_crypto(std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> send,
|
||||
std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> recv);
|
||||
@@ -87,6 +91,21 @@ class ConnSession : public std::enable_shared_from_this<ConnSession> {
|
||||
void handle_udp_binding(uint64_t req_id, const voicecat::v1::UdpBinding& msg);
|
||||
void handle_stream_announce(uint64_t req_id, const voicecat::v1::StreamAnnounce& msg);
|
||||
void handle_stream_stop(const voicecat::v1::StreamStop& msg);
|
||||
void handle_leave_channel();
|
||||
|
||||
// M5 handlers
|
||||
void handle_kick_request(uint64_t req_id, const voicecat::v1::KickRequest& msg);
|
||||
void handle_ban_request(uint64_t req_id, const voicecat::v1::BanRequest& msg);
|
||||
void handle_set_permission(uint64_t req_id, const voicecat::v1::SetPermissionRequest& msg);
|
||||
void handle_server_mute_request(uint64_t req_id, const voicecat::v1::ServerMuteRequest& msg);
|
||||
void handle_move_user(uint64_t req_id, const voicecat::v1::MoveUserRequest& msg);
|
||||
void handle_create_channel(uint64_t req_id, const voicecat::v1::CreateChannelRequest& msg);
|
||||
void handle_edit_channel(uint64_t req_id, const voicecat::v1::EditChannelRequest& msg);
|
||||
void handle_delete_channel(uint64_t req_id, const voicecat::v1::DeleteChannelRequest& msg);
|
||||
void handle_create_account(uint64_t req_id, const voicecat::v1::CreateAccountRequest& msg);
|
||||
void handle_reset_password(uint64_t req_id, const voicecat::v1::ResetPasswordRequest& msg);
|
||||
void handle_delete_account(uint64_t req_id, const voicecat::v1::DeleteAccountRequest& msg);
|
||||
void handle_list_accounts(uint64_t req_id, const voicecat::v1::ListAccountsRequest& msg);
|
||||
|
||||
void finish_guest_auth(const voicecat::v1::GuestAuth& guest, uint64_t req_id);
|
||||
void finish_password_auth(const std::string& username, const std::string& password,
|
||||
@@ -95,7 +114,13 @@ class ConnSession : public std::enable_shared_from_this<ConnSession> {
|
||||
const voicecat::v1::Permissions* perms = nullptr);
|
||||
void send_state_snapshot();
|
||||
void broadcast_user_joined(const voicecat::v1::User& user);
|
||||
void send_disconnect_and_close(uint32_t code, const std::string& reason);
|
||||
void send_generic_result(uint64_t req_id, bool ok, uint32_t code,
|
||||
const std::string& message);
|
||||
|
||||
// Permission helpers.
|
||||
bool has_permission(bool (voicecat::v1::Permissions::* getter)() const) const;
|
||||
bool is_admin() const { return has_permission(&voicecat::v1::Permissions::is_admin); }
|
||||
void set_permissions(const voicecat::v1::Permissions& perms);
|
||||
|
||||
std::shared_ptr<Database> db_;
|
||||
std::shared_ptr<SessionRegistry> registry_;
|
||||
@@ -126,6 +151,9 @@ class ConnSession : public std::enable_shared_from_this<ConnSession> {
|
||||
// support multiple concurrent streams (MIC + SCREEN_AUDIO + AUX_DEVICE) per user.
|
||||
uint32_t next_stream_id_{1};
|
||||
std::vector<uint32_t> announced_stream_ids_;
|
||||
|
||||
// M5: permissions granted at auth time (server-side authority).
|
||||
voicecat::v1::Permissions permissions_;
|
||||
};
|
||||
|
||||
} // namespace voicecat::server
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <random>
|
||||
#include <stdexcept>
|
||||
|
||||
#include <sodium.h>
|
||||
@@ -13,7 +14,9 @@ namespace voicecat::server {
|
||||
|
||||
// ── Schema ────────────────────────────────────────────────────────────────────
|
||||
|
||||
static constexpr const char* kCreateSchema = R"sql(
|
||||
static constexpr int kCurrentSchemaVersion = 2;
|
||||
|
||||
static constexpr const char* kCreateSchemaV1 = R"sql(
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
@@ -26,7 +29,38 @@ CREATE TABLE IF NOT EXISTS server_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
INSERT OR IGNORE INTO server_meta (key, value) VALUES ('schema_version', '1');
|
||||
)sql";
|
||||
|
||||
static constexpr const char* kCreateSchemaV2 = R"sql(
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
parent_id INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
topic TEXT NOT NULL DEFAULT '',
|
||||
password_hash TEXT NOT NULL DEFAULT '',
|
||||
max_users INTEGER NOT NULL DEFAULT 0,
|
||||
type INTEGER NOT NULL DEFAULT 0,
|
||||
audio_codec INTEGER NOT NULL DEFAULT 0,
|
||||
audio_mode INTEGER NOT NULL DEFAULT 0,
|
||||
audio_sample_rate INTEGER NOT NULL DEFAULT 48000,
|
||||
audio_bitrate_bps INTEGER NOT NULL DEFAULT 24000,
|
||||
audio_frame_ms INTEGER NOT NULL DEFAULT 20,
|
||||
audio_application INTEGER NOT NULL DEFAULT 0,
|
||||
audio_fec INTEGER NOT NULL DEFAULT 1,
|
||||
audio_expected_packet_loss INTEGER NOT NULL DEFAULT 10,
|
||||
audio_dtx INTEGER NOT NULL DEFAULT 1,
|
||||
audio_complexity INTEGER NOT NULL DEFAULT 5,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS bans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
subject_type TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
expires_at INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_bans_subject ON bans(subject_type, subject);
|
||||
)sql";
|
||||
|
||||
// ── Database ──────────────────────────────────────────────────────────────────
|
||||
@@ -50,7 +84,37 @@ bool Database::open(std::string& error) {
|
||||
exec("PRAGMA journal_mode=WAL", error);
|
||||
exec("PRAGMA synchronous=NORMAL", error);
|
||||
error.clear();
|
||||
if (!exec(kCreateSchema, error)) return false;
|
||||
|
||||
if (!exec(kCreateSchemaV1, error)) return false;
|
||||
if (!migrate(error)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Database::migrate(std::string& error) {
|
||||
// Ensure server_meta row exists.
|
||||
if (!exec("INSERT OR IGNORE INTO server_meta (key, value) VALUES ('schema_version', '1')",
|
||||
error))
|
||||
return false;
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
sqlite3_prepare_v2(db_, "SELECT value FROM server_meta WHERE key='schema_version'",
|
||||
-1, &stmt, nullptr);
|
||||
int version = 1;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
version = std::max(1, std::atoi(reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0))));
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
if (version < 2) {
|
||||
if (!exec(kCreateSchemaV2, error)) return false;
|
||||
sqlite3_stmt* upd = nullptr;
|
||||
sqlite3_prepare_v2(db_,
|
||||
"INSERT OR REPLACE INTO server_meta (key, value) VALUES ('schema_version', ?)",
|
||||
-1, &upd, nullptr);
|
||||
sqlite3_bind_text(upd, 1, "2", -1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(upd);
|
||||
sqlite3_finalize(upd);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -63,6 +127,8 @@ bool Database::is_empty() {
|
||||
return count == 0;
|
||||
}
|
||||
|
||||
// ── Accounts ──────────────────────────────────────────────────────────────────
|
||||
|
||||
std::optional<Account> Database::create_account(const std::string& username,
|
||||
const std::string& password,
|
||||
bool is_admin, std::string& error) {
|
||||
@@ -108,6 +174,7 @@ std::optional<Account> Database::create_account(const std::string& username,
|
||||
|
||||
bool Database::reset_password(const std::string& username, const std::string& new_password,
|
||||
std::string& error) {
|
||||
if (new_password.empty()) { error = "password must not be empty"; return false; }
|
||||
char hash[crypto_pwhash_STRBYTES];
|
||||
if (crypto_pwhash_str(hash, new_password.c_str(), new_password.size(),
|
||||
crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
||||
@@ -194,6 +261,326 @@ std::optional<Account> Database::authenticate(const std::string& username,
|
||||
return acc;
|
||||
}
|
||||
|
||||
// ── Channels ──────────────────────────────────────────────────────────────────
|
||||
|
||||
namespace {
|
||||
|
||||
void bind_audio(sqlite3_stmt* stmt, int base_idx, const voicecat::v1::AudioConfig& a) {
|
||||
sqlite3_bind_int(stmt, base_idx + 0, static_cast<int>(a.codec()));
|
||||
sqlite3_bind_int(stmt, base_idx + 1, static_cast<int>(a.mode()));
|
||||
sqlite3_bind_int(stmt, base_idx + 2, static_cast<int>(a.sample_rate()));
|
||||
sqlite3_bind_int(stmt, base_idx + 3, static_cast<int>(a.bitrate_bps()));
|
||||
sqlite3_bind_int(stmt, base_idx + 4, static_cast<int>(a.frame_ms()));
|
||||
sqlite3_bind_int(stmt, base_idx + 5, static_cast<int>(a.application()));
|
||||
sqlite3_bind_int(stmt, base_idx + 6, a.fec() ? 1 : 0);
|
||||
sqlite3_bind_int(stmt, base_idx + 7, static_cast<int>(a.expected_packet_loss()));
|
||||
sqlite3_bind_int(stmt, base_idx + 8, a.dtx() ? 1 : 0);
|
||||
sqlite3_bind_int(stmt, base_idx + 9, static_cast<int>(a.complexity()));
|
||||
}
|
||||
|
||||
voicecat::v1::AudioConfig read_audio(sqlite3_stmt* stmt, int base_idx) {
|
||||
voicecat::v1::AudioConfig a;
|
||||
a.set_codec(static_cast<uint32_t>(sqlite3_column_int(stmt, base_idx + 0)));
|
||||
a.set_mode(static_cast<voicecat::v1::ChannelMode>(sqlite3_column_int(stmt, base_idx + 1)));
|
||||
a.set_sample_rate(static_cast<uint32_t>(sqlite3_column_int(stmt, base_idx + 2)));
|
||||
a.set_bitrate_bps(static_cast<uint32_t>(sqlite3_column_int(stmt, base_idx + 3)));
|
||||
a.set_frame_ms(static_cast<uint32_t>(sqlite3_column_int(stmt, base_idx + 4)));
|
||||
a.set_application(static_cast<voicecat::v1::OpusApplication>(sqlite3_column_int(stmt, base_idx + 5)));
|
||||
a.set_fec(sqlite3_column_int(stmt, base_idx + 6) != 0);
|
||||
a.set_expected_packet_loss(static_cast<uint32_t>(sqlite3_column_int(stmt, base_idx + 7)));
|
||||
a.set_dtx(sqlite3_column_int(stmt, base_idx + 8) != 0);
|
||||
a.set_complexity(static_cast<uint32_t>(sqlite3_column_int(stmt, base_idx + 9)));
|
||||
return a;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<ChannelRecord> Database::create_channel(const ChannelRecord& ch,
|
||||
const std::string& password,
|
||||
std::string& error) {
|
||||
if (ch.name.empty()) { error = "channel name must not be empty"; return std::nullopt; }
|
||||
|
||||
std::string pw_hash = password.empty() ? "" : hash_channel_password(password);
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
const char* sql = R"sql(
|
||||
INSERT INTO channels
|
||||
(parent_id, name, topic, password_hash, max_users, type,
|
||||
audio_codec, audio_mode, audio_sample_rate, audio_bitrate_bps, audio_frame_ms,
|
||||
audio_application, audio_fec, audio_expected_packet_loss, audio_dtx, audio_complexity,
|
||||
sort_order)
|
||||
VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?,?,?, ?)
|
||||
)sql";
|
||||
int rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
|
||||
if (rc != SQLITE_OK) { error = sqlite3_errmsg(db_); return std::nullopt; }
|
||||
|
||||
sqlite3_bind_int(stmt, 1, static_cast<int>(ch.parent_id));
|
||||
sqlite3_bind_text(stmt, 2, ch.name.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, ch.topic.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 4, pw_hash.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(stmt, 5, static_cast<int>(ch.max_users));
|
||||
sqlite3_bind_int(stmt, 6, static_cast<int>(ch.type));
|
||||
bind_audio(stmt, 7, ch.audio); // parameters 7-16
|
||||
sqlite3_bind_int(stmt, 17, static_cast<int>(ch.sort_order));
|
||||
|
||||
|
||||
|
||||
rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
if (rc != SQLITE_DONE) { error = sqlite3_errmsg(db_); return std::nullopt; }
|
||||
|
||||
ChannelRecord out = ch;
|
||||
out.id = static_cast<uint32_t>(sqlite3_last_insert_rowid(db_));
|
||||
out.password_protected = !pw_hash.empty();
|
||||
return out;
|
||||
}
|
||||
|
||||
bool Database::update_channel(const ChannelRecord& ch, const std::string& password,
|
||||
std::string& error) {
|
||||
std::string pw_hash;
|
||||
bool update_password = false;
|
||||
if (!password.empty()) {
|
||||
pw_hash = hash_channel_password(password);
|
||||
update_password = true;
|
||||
}
|
||||
|
||||
std::string sql = R"sql(
|
||||
UPDATE channels SET
|
||||
parent_id=?, name=?, topic=?, max_users=?, type=?,
|
||||
audio_codec=?, audio_mode=?, audio_sample_rate=?, audio_bitrate_bps=?, audio_frame_ms=?,
|
||||
audio_application=?, audio_fec=?, audio_expected_packet_loss=?, audio_dtx=?, audio_complexity=?,
|
||||
sort_order=?
|
||||
)sql";
|
||||
if (update_password) sql += ", password_hash=?";
|
||||
sql += " WHERE id=?";
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
int rc = sqlite3_prepare_v2(db_, sql.c_str(), -1, &stmt, nullptr);
|
||||
if (rc != SQLITE_OK) { error = sqlite3_errmsg(db_); return false; }
|
||||
|
||||
int idx = 1;
|
||||
sqlite3_bind_int(stmt, idx++, static_cast<int>(ch.parent_id));
|
||||
sqlite3_bind_text(stmt, idx++, ch.name.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, idx++, ch.topic.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(stmt, idx++, static_cast<int>(ch.max_users));
|
||||
sqlite3_bind_int(stmt, idx++, static_cast<int>(ch.type));
|
||||
bind_audio(stmt, idx, ch.audio); idx += 10;
|
||||
sqlite3_bind_int(stmt, idx++, static_cast<int>(ch.sort_order));
|
||||
if (update_password) {
|
||||
sqlite3_bind_text(stmt, idx++, pw_hash.c_str(), -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
sqlite3_bind_int(stmt, idx++, static_cast<int>(ch.id));
|
||||
|
||||
rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
if (rc != SQLITE_DONE) { error = sqlite3_errmsg(db_); return false; }
|
||||
if (sqlite3_changes(db_) == 0) { error = "channel not found"; return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Database::delete_channel(uint32_t id, std::string& error) {
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
sqlite3_prepare_v2(db_, "DELETE FROM channels WHERE id=?", -1, &stmt, nullptr);
|
||||
sqlite3_bind_int(stmt, 1, static_cast<int>(id));
|
||||
int rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
if (rc != SQLITE_DONE) { error = sqlite3_errmsg(db_); return false; }
|
||||
if (sqlite3_changes(db_) == 0) { error = "channel not found"; return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<ChannelRecord> Database::list_channels() {
|
||||
std::vector<ChannelRecord> result;
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
sqlite3_prepare_v2(db_, R"sql(
|
||||
SELECT id, parent_id, name, topic, password_hash, max_users, type,
|
||||
audio_codec, audio_mode, audio_sample_rate, audio_bitrate_bps, audio_frame_ms,
|
||||
audio_application, audio_fec, audio_expected_packet_loss, audio_dtx, audio_complexity,
|
||||
sort_order
|
||||
FROM channels
|
||||
ORDER BY sort_order, id
|
||||
)sql", -1, &stmt, nullptr);
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
ChannelRecord ch;
|
||||
ch.id = static_cast<uint32_t>(sqlite3_column_int(stmt, 0));
|
||||
ch.parent_id = static_cast<uint32_t>(sqlite3_column_int(stmt, 1));
|
||||
ch.name = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2));
|
||||
ch.topic = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 3));
|
||||
ch.password_protected = sqlite3_column_text(stmt, 4)[0] != '\0';
|
||||
ch.max_users = static_cast<uint32_t>(sqlite3_column_int(stmt, 5));
|
||||
ch.type = static_cast<voicecat::v1::ChannelType>(sqlite3_column_int(stmt, 6));
|
||||
ch.audio = read_audio(stmt, 7);
|
||||
ch.sort_order = sqlite3_column_int(stmt, 17);
|
||||
result.push_back(std::move(ch));
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::optional<ChannelRecord> Database::get_channel(uint32_t id) {
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
sqlite3_prepare_v2(db_, R"sql(
|
||||
SELECT id, parent_id, name, topic, password_hash, max_users, type,
|
||||
audio_codec, audio_mode, audio_sample_rate, audio_bitrate_bps, audio_frame_ms,
|
||||
audio_application, audio_fec, audio_expected_packet_loss, audio_dtx, audio_complexity,
|
||||
sort_order
|
||||
FROM channels WHERE id=?
|
||||
)sql", -1, &stmt, nullptr);
|
||||
sqlite3_bind_int(stmt, 1, static_cast<int>(id));
|
||||
std::optional<ChannelRecord> result;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
ChannelRecord ch;
|
||||
ch.id = static_cast<uint32_t>(sqlite3_column_int(stmt, 0));
|
||||
ch.parent_id = static_cast<uint32_t>(sqlite3_column_int(stmt, 1));
|
||||
ch.name = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2));
|
||||
ch.topic = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 3));
|
||||
ch.password_protected = sqlite3_column_text(stmt, 4)[0] != '\0';
|
||||
ch.max_users = static_cast<uint32_t>(sqlite3_column_int(stmt, 5));
|
||||
ch.type = static_cast<voicecat::v1::ChannelType>(sqlite3_column_int(stmt, 6));
|
||||
ch.audio = read_audio(stmt, 7);
|
||||
ch.sort_order = sqlite3_column_int(stmt, 17);
|
||||
result = std::move(ch);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string Database::hash_channel_password(const std::string& password) {
|
||||
uint8_t salt[16];
|
||||
randombytes_buf(salt, sizeof(salt));
|
||||
uint8_t hash[32];
|
||||
crypto_generichash(hash, sizeof(hash),
|
||||
reinterpret_cast<const uint8_t*>(password.data()), password.size(),
|
||||
salt, sizeof(salt));
|
||||
char salt_hex[33];
|
||||
char hash_hex[65];
|
||||
sodium_bin2hex(salt_hex, sizeof(salt_hex), salt, sizeof(salt));
|
||||
sodium_bin2hex(hash_hex, sizeof(hash_hex), hash, sizeof(hash));
|
||||
return std::string(salt_hex) + ":" + hash_hex;
|
||||
}
|
||||
|
||||
bool Database::verify_channel_password(const std::string& password, const std::string& stored) {
|
||||
auto pos = stored.find(':');
|
||||
if (pos == std::string::npos || pos != 32) return false;
|
||||
std::string salt_hex = stored.substr(0, pos);
|
||||
std::string hash_hex = stored.substr(pos + 1);
|
||||
uint8_t salt[16];
|
||||
uint8_t expected[32];
|
||||
if (sodium_hex2bin(salt, sizeof(salt), salt_hex.c_str(), salt_hex.size(),
|
||||
nullptr, nullptr, nullptr) != 0)
|
||||
return false;
|
||||
if (sodium_hex2bin(expected, sizeof(expected), hash_hex.c_str(), hash_hex.size(),
|
||||
nullptr, nullptr, nullptr) != 0)
|
||||
return false;
|
||||
uint8_t hash[32];
|
||||
crypto_generichash(hash, sizeof(hash),
|
||||
reinterpret_cast<const uint8_t*>(password.data()), password.size(),
|
||||
salt, sizeof(salt));
|
||||
return sodium_memcmp(hash, expected, sizeof(hash)) == 0;
|
||||
}
|
||||
|
||||
bool Database::check_channel_password(uint32_t id, const std::string& password) {
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
sqlite3_prepare_v2(db_, "SELECT password_hash FROM channels WHERE id=?", -1, &stmt, nullptr);
|
||||
sqlite3_bind_int(stmt, 1, static_cast<int>(id));
|
||||
bool ok = false;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const char* stored = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
|
||||
if (!stored || stored[0] == '\0') {
|
||||
ok = true; // no password
|
||||
} else {
|
||||
ok = verify_channel_password(password, stored);
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// ── Bans ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
std::optional<BanRecord> Database::ban_create(const std::string& subject_type,
|
||||
const std::string& subject,
|
||||
const std::string& reason,
|
||||
int64_t expires_at,
|
||||
std::string& error) {
|
||||
if (subject_type != "user_id" && subject_type != "username" && subject_type != "ip") {
|
||||
error = "invalid subject_type";
|
||||
return std::nullopt;
|
||||
}
|
||||
if (subject.empty()) { error = "subject must not be empty"; return std::nullopt; }
|
||||
|
||||
int64_t now = now_unix();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
int rc = sqlite3_prepare_v2(db_,
|
||||
"INSERT INTO bans (subject_type, subject, reason, expires_at, created_at) VALUES (?,?,?,?,?)",
|
||||
-1, &stmt, nullptr);
|
||||
if (rc != SQLITE_OK) { error = sqlite3_errmsg(db_); return std::nullopt; }
|
||||
sqlite3_bind_text(stmt, 1, subject_type.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, subject.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, reason.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int64(stmt, 4, expires_at);
|
||||
sqlite3_bind_int64(stmt, 5, now);
|
||||
rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
if (rc != SQLITE_DONE) { error = sqlite3_errmsg(db_); return std::nullopt; }
|
||||
|
||||
BanRecord b;
|
||||
b.id = sqlite3_last_insert_rowid(db_);
|
||||
b.subject_type = subject_type;
|
||||
b.subject = subject;
|
||||
b.reason = reason;
|
||||
b.expires_at = expires_at;
|
||||
b.created_at = now;
|
||||
return b;
|
||||
}
|
||||
|
||||
bool Database::ban_remove(int64_t id, std::string& error) {
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
sqlite3_prepare_v2(db_, "DELETE FROM bans WHERE id=?", -1, &stmt, nullptr);
|
||||
sqlite3_bind_int64(stmt, 1, id);
|
||||
int rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
if (rc != SQLITE_DONE) { error = sqlite3_errmsg(db_); return false; }
|
||||
if (sqlite3_changes(db_) == 0) { error = "ban not found"; return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Database::ban_check(const std::string& subject_type, const std::string& subject) {
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
sqlite3_prepare_v2(db_,
|
||||
"SELECT expires_at FROM bans WHERE subject_type=? AND subject=? AND (expires_at=0 OR expires_at>?)",
|
||||
-1, &stmt, nullptr);
|
||||
sqlite3_bind_text(stmt, 1, subject_type.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, subject.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int64(stmt, 3, now_unix());
|
||||
bool banned = false;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) banned = true;
|
||||
sqlite3_finalize(stmt);
|
||||
return banned;
|
||||
}
|
||||
|
||||
std::vector<BanRecord> Database::ban_list(bool include_expired) {
|
||||
std::vector<BanRecord> result;
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
std::string sql = "SELECT id, subject_type, subject, reason, expires_at, created_at FROM bans";
|
||||
if (!include_expired) sql += " WHERE expires_at=0 OR expires_at>?";
|
||||
sql += " ORDER BY created_at DESC";
|
||||
sqlite3_prepare_v2(db_, sql.c_str(), -1, &stmt, nullptr);
|
||||
if (!include_expired) sqlite3_bind_int64(stmt, 1, now_unix());
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
BanRecord b;
|
||||
b.id = sqlite3_column_int64(stmt, 0);
|
||||
b.subject_type = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
|
||||
b.subject = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2));
|
||||
b.reason = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 3));
|
||||
b.expires_at = sqlite3_column_int64(stmt, 4);
|
||||
b.created_at = sqlite3_column_int64(stmt, 5);
|
||||
result.push_back(std::move(b));
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string Database::generate_password(size_t length) {
|
||||
static const char kAlphabet[] =
|
||||
"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*";
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "proto/voicecat.pb.h"
|
||||
|
||||
struct sqlite3;
|
||||
|
||||
namespace voicecat::server {
|
||||
@@ -28,6 +30,29 @@ struct Account {
|
||||
int64_t last_login{};
|
||||
};
|
||||
|
||||
// Persistent channel record. Mirrors voicecat::v1::Channel where applicable.
|
||||
struct ChannelRecord {
|
||||
uint32_t id{0};
|
||||
uint32_t parent_id{0}; // 0 = root
|
||||
std::string name;
|
||||
std::string topic;
|
||||
bool password_protected{false};
|
||||
uint32_t max_users{0}; // 0 = unlimited
|
||||
voicecat::v1::ChannelType type{voicecat::v1::CHANNEL_PERMANENT};
|
||||
voicecat::v1::AudioConfig audio;
|
||||
int32_t sort_order{0};
|
||||
};
|
||||
|
||||
// Persistent ban record.
|
||||
struct BanRecord {
|
||||
int64_t id{0};
|
||||
std::string subject_type; // "user_id" or "ip"
|
||||
std::string subject; // the banned value
|
||||
std::string reason;
|
||||
int64_t expires_at{0}; // 0 = permanent
|
||||
int64_t created_at{0};
|
||||
};
|
||||
|
||||
class Database {
|
||||
public:
|
||||
explicit Database(std::string path);
|
||||
@@ -43,6 +68,8 @@ class Database {
|
||||
// True if the accounts table has no rows.
|
||||
bool is_empty();
|
||||
|
||||
// ── Accounts ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Create a new account. Hashes password with Argon2id. Thread-safe.
|
||||
std::optional<Account> create_account(const std::string& username,
|
||||
const std::string& password,
|
||||
@@ -63,13 +90,62 @@ class Database {
|
||||
std::optional<Account> authenticate(const std::string& username,
|
||||
const std::string& password);
|
||||
|
||||
// ── Channels ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Create a new channel. `password` may be empty. Returns the persisted record
|
||||
// (with assigned id), or nullopt on error. Thread-safe.
|
||||
std::optional<ChannelRecord> create_channel(const ChannelRecord& ch,
|
||||
const std::string& password,
|
||||
std::string& error);
|
||||
|
||||
// Update an existing channel. Returns false if not found or on error.
|
||||
bool update_channel(const ChannelRecord& ch, const std::string& password,
|
||||
std::string& error);
|
||||
|
||||
// Delete a channel. Returns false if not found or on error.
|
||||
bool delete_channel(uint32_t id, std::string& error);
|
||||
|
||||
// Load all channels. Thread-safe.
|
||||
std::vector<ChannelRecord> list_channels();
|
||||
|
||||
// Fetch a single channel. Thread-safe.
|
||||
std::optional<ChannelRecord> get_channel(uint32_t id);
|
||||
|
||||
// Check a plaintext channel password. Returns true if the channel has no
|
||||
// password or if the password matches. Fast (BLAKE2b), safe for net thread.
|
||||
bool check_channel_password(uint32_t id, const std::string& password);
|
||||
|
||||
// ── Bans ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Ban a subject (user_id or ip). Returns the persisted record, or nullopt.
|
||||
std::optional<BanRecord> ban_create(const std::string& subject_type,
|
||||
const std::string& subject,
|
||||
const std::string& reason,
|
||||
int64_t expires_at,
|
||||
std::string& error);
|
||||
|
||||
// Remove a ban by id. Returns false if not found or on error.
|
||||
bool ban_remove(int64_t id, std::string& error);
|
||||
|
||||
// Check whether a subject is currently banned. Thread-safe.
|
||||
bool ban_check(const std::string& subject_type, const std::string& subject);
|
||||
|
||||
// List active bans (optionally all). Thread-safe.
|
||||
std::vector<BanRecord> ban_list(bool include_expired = false);
|
||||
|
||||
// Generate a random printable password of the given length.
|
||||
static std::string generate_password(size_t length = 20);
|
||||
|
||||
private:
|
||||
bool exec(const std::string& sql, std::string& error);
|
||||
bool migrate(std::string& error);
|
||||
int64_t now_unix() const;
|
||||
|
||||
// Channel password hashing (fast, net-thread-safe).
|
||||
static std::string hash_channel_password(const std::string& password);
|
||||
static bool verify_channel_password(const std::string& password,
|
||||
const std::string& stored);
|
||||
|
||||
std::string path_;
|
||||
sqlite3* db_{nullptr};
|
||||
};
|
||||
|
||||
@@ -54,8 +54,8 @@ int Server::run() {
|
||||
}
|
||||
|
||||
// ── Session registry ─────────────────────────────────────────────────────
|
||||
auto registry = std::make_shared<SessionRegistry>();
|
||||
registry->init_default_channels();
|
||||
auto registry = std::make_shared<SessionRegistry>(db);
|
||||
registry->load_channels();
|
||||
|
||||
// ── Worker pool ──────────────────────────────────────────────────────────
|
||||
auto workers = std::make_shared<WorkerPool>(3);
|
||||
|
||||
@@ -10,54 +10,82 @@
|
||||
|
||||
namespace voicecat::server {
|
||||
|
||||
void SessionRegistry::init_default_channels() {
|
||||
SessionRegistry::SessionRegistry(std::shared_ptr<Database> db) : db_(std::move(db)) {}
|
||||
|
||||
void SessionRegistry::load_channels() {
|
||||
std::unique_lock lk(mu_);
|
||||
ChannelEntry lobby;
|
||||
lobby.proto.set_id(1);
|
||||
lobby.proto.set_name("Lobby");
|
||||
lobby.proto.set_type(voicecat::v1::CHANNEL_PERMANENT);
|
||||
lobby.proto.set_order(0);
|
||||
// Non-zero so vc_list_channels/SessionModel round-trip this field for real (a regression
|
||||
// test for the M4 SessionModel field-population fix needs at least one non-default value).
|
||||
lobby.proto.set_max_users(20);
|
||||
{
|
||||
// Speech profile: mono, low bitrate, FEC+DTX on for resilience/silence-suppression.
|
||||
auto* a = lobby.proto.mutable_audio();
|
||||
a->set_codec(0);
|
||||
a->set_mode(voicecat::v1::MODE_MONO);
|
||||
a->set_sample_rate(48000);
|
||||
a->set_bitrate_bps(24000);
|
||||
a->set_frame_ms(20);
|
||||
a->set_application(voicecat::v1::OPUS_VOIP);
|
||||
a->set_fec(true);
|
||||
a->set_expected_packet_loss(10);
|
||||
a->set_dtx(true);
|
||||
a->set_complexity(5);
|
||||
}
|
||||
channels_[1] = std::move(lobby);
|
||||
channels_.clear();
|
||||
|
||||
ChannelEntry music;
|
||||
music.proto.set_id(2);
|
||||
music.proto.set_name("Music Room");
|
||||
music.proto.set_type(voicecat::v1::CHANNEL_PERMANENT);
|
||||
music.proto.set_order(1);
|
||||
{
|
||||
// Music/screen-audio profile: stereo, high bitrate, FEC/DTX off (continuous signal).
|
||||
auto* a = music.proto.mutable_audio();
|
||||
a->set_codec(0);
|
||||
a->set_mode(voicecat::v1::MODE_STEREO);
|
||||
a->set_sample_rate(48000);
|
||||
a->set_bitrate_bps(128000);
|
||||
a->set_frame_ms(20);
|
||||
a->set_application(voicecat::v1::OPUS_AUDIO);
|
||||
a->set_fec(false);
|
||||
a->set_expected_packet_loss(0);
|
||||
a->set_dtx(false);
|
||||
a->set_complexity(8);
|
||||
auto records = db_->list_channels();
|
||||
if (records.empty()) {
|
||||
// First run: seed the default channel tree.
|
||||
seed_default_channels();
|
||||
records = db_->list_channels();
|
||||
}
|
||||
channels_[2] = std::move(music);
|
||||
|
||||
next_channel_id_ = 3; // 1 and 2 are now reserved (Lobby, Music Room)
|
||||
uint32_t max_id = 2;
|
||||
for (auto& rec : records) {
|
||||
ChannelEntry entry;
|
||||
entry.proto.set_id(rec.id);
|
||||
entry.proto.set_parent_id(rec.parent_id);
|
||||
entry.proto.set_name(rec.name);
|
||||
entry.proto.set_topic(rec.topic);
|
||||
entry.proto.set_password_protected(rec.password_protected);
|
||||
entry.proto.set_max_users(rec.max_users);
|
||||
entry.proto.set_type(rec.type);
|
||||
*entry.proto.mutable_audio() = rec.audio;
|
||||
entry.proto.set_order(rec.sort_order);
|
||||
max_id = std::max(max_id, rec.id);
|
||||
channels_[rec.id] = std::move(entry);
|
||||
}
|
||||
next_channel_id_ = max_id + 1;
|
||||
}
|
||||
|
||||
void SessionRegistry::seed_default_channels() {
|
||||
// Lobby: speech profile.
|
||||
ChannelRecord lobby;
|
||||
lobby.id = 1;
|
||||
lobby.name = "Lobby";
|
||||
lobby.type = voicecat::v1::CHANNEL_PERMANENT;
|
||||
lobby.sort_order = 0;
|
||||
lobby.max_users = 20;
|
||||
{
|
||||
auto& a = lobby.audio;
|
||||
a.set_codec(0);
|
||||
a.set_mode(voicecat::v1::MODE_MONO);
|
||||
a.set_sample_rate(48000);
|
||||
a.set_bitrate_bps(24000);
|
||||
a.set_frame_ms(20);
|
||||
a.set_application(voicecat::v1::OPUS_VOIP);
|
||||
a.set_fec(true);
|
||||
a.set_expected_packet_loss(10);
|
||||
a.set_dtx(true);
|
||||
a.set_complexity(5);
|
||||
}
|
||||
|
||||
// Music Room: stereo/music profile.
|
||||
ChannelRecord music;
|
||||
music.id = 2;
|
||||
music.name = "Music Room";
|
||||
music.type = voicecat::v1::CHANNEL_PERMANENT;
|
||||
music.sort_order = 1;
|
||||
{
|
||||
auto& a = music.audio;
|
||||
a.set_codec(0);
|
||||
a.set_mode(voicecat::v1::MODE_STEREO);
|
||||
a.set_sample_rate(48000);
|
||||
a.set_bitrate_bps(128000);
|
||||
a.set_frame_ms(20);
|
||||
a.set_application(voicecat::v1::OPUS_AUDIO);
|
||||
a.set_fec(false);
|
||||
a.set_expected_packet_loss(0);
|
||||
a.set_dtx(false);
|
||||
a.set_complexity(8);
|
||||
}
|
||||
|
||||
std::string err;
|
||||
db_->create_channel(lobby, "", err);
|
||||
db_->create_channel(music, "", err);
|
||||
}
|
||||
|
||||
uint64_t SessionRegistry::register_session(std::weak_ptr<ConnSession> session) {
|
||||
@@ -70,6 +98,7 @@ uint64_t SessionRegistry::register_session(std::weak_ptr<ConnSession> session) {
|
||||
void SessionRegistry::unregister_session(uint64_t session_id) {
|
||||
std::unique_lock lk(mu_);
|
||||
sessions_.erase(session_id);
|
||||
session_permissions_.erase(session_id);
|
||||
}
|
||||
|
||||
uint32_t SessionRegistry::add_user(uint64_t session_id, const voicecat::v1::User& user) {
|
||||
@@ -115,6 +144,20 @@ std::vector<voicecat::v1::User> SessionRegistry::user_snapshot() const {
|
||||
return result;
|
||||
}
|
||||
|
||||
std::optional<voicecat::v1::User> SessionRegistry::user_snapshot_user(uint32_t user_id) const {
|
||||
std::shared_lock lk(mu_);
|
||||
auto it = users_.find(user_id);
|
||||
if (it == users_.end()) return std::nullopt;
|
||||
return it->second.proto;
|
||||
}
|
||||
|
||||
std::optional<std::string> SessionRegistry::user_nickname(uint32_t user_id) const {
|
||||
std::shared_lock lk(mu_);
|
||||
auto it = users_.find(user_id);
|
||||
if (it == users_.end()) return std::nullopt;
|
||||
return it->second.proto.nickname();
|
||||
}
|
||||
|
||||
std::vector<std::shared_ptr<ConnSession>> SessionRegistry::resolve_text_targets(
|
||||
uint64_t sender_session_id, voicecat::v1::TextScope scope, uint32_t target_id) const {
|
||||
std::shared_lock lk(mu_);
|
||||
@@ -145,13 +188,220 @@ std::vector<std::shared_ptr<ConnSession>> SessionRegistry::resolve_text_targets(
|
||||
void SessionRegistry::broadcast(const voicecat::v1::Envelope& env,
|
||||
uint64_t exclude_session_id) const {
|
||||
std::shared_lock lk(mu_);
|
||||
broadcast_unlocked(env, exclude_session_id);
|
||||
}
|
||||
|
||||
void SessionRegistry::broadcast_unlocked(const voicecat::v1::Envelope& env,
|
||||
uint64_t exclude_session_id) const {
|
||||
for (auto& [sid, weak] : sessions_) {
|
||||
if (sid == exclude_session_id) continue;
|
||||
if (auto sess = weak.lock()) sess->send_envelope(env);
|
||||
}
|
||||
}
|
||||
|
||||
// ── M2: UDP / media ──────────────────────────────────────────────────────────
|
||||
// ── Permissions ───────────────────────────────────────────────────────────────
|
||||
|
||||
void SessionRegistry::set_session_permissions(uint64_t session_id,
|
||||
const voicecat::v1::Permissions& perms) {
|
||||
std::unique_lock lk(mu_);
|
||||
session_permissions_[session_id] = perms;
|
||||
}
|
||||
|
||||
std::optional<voicecat::v1::Permissions> SessionRegistry::get_session_permissions(
|
||||
uint64_t session_id) const {
|
||||
std::shared_lock lk(mu_);
|
||||
auto it = session_permissions_.find(session_id);
|
||||
if (it == session_permissions_.end()) return std::nullopt;
|
||||
return it->second;
|
||||
}
|
||||
|
||||
// ── Moderation ────────────────────────────────────────────────────────────────
|
||||
|
||||
std::shared_ptr<ConnSession> SessionRegistry::find_session_by_user_id(uint32_t user_id) const {
|
||||
std::shared_lock lk(mu_);
|
||||
auto it = users_.find(user_id);
|
||||
if (it == users_.end()) return nullptr;
|
||||
auto sit = sessions_.find(it->second.session_id);
|
||||
if (sit == sessions_.end()) return nullptr;
|
||||
return sit->second.lock();
|
||||
}
|
||||
|
||||
namespace {
|
||||
voicecat::v1::Envelope make_left_event(uint32_t user_id, const std::string& reason) {
|
||||
voicecat::v1::Envelope env;
|
||||
auto* ue = env.mutable_user_event();
|
||||
ue->set_kind(voicecat::v1::UserEvent::LEFT);
|
||||
ue->mutable_user()->set_id(user_id);
|
||||
ue->set_left_id(user_id);
|
||||
ue->set_reason(reason); // M5 additive field
|
||||
return env;
|
||||
}
|
||||
}
|
||||
|
||||
bool SessionRegistry::kick_user(uint32_t user_id, const std::string& reason) {
|
||||
auto target = find_session_by_user_id(user_id);
|
||||
if (!target) return false;
|
||||
{
|
||||
std::shared_lock lk(mu_);
|
||||
broadcast(make_left_event(user_id, reason), /*exclude*/ 0);
|
||||
}
|
||||
// Close outside the registry lock: close() may call back into unregister_session().
|
||||
target->send_disconnect_and_close(2, reason); // code 2 = kicked
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SessionRegistry::ban_user(uint32_t user_id, const std::string& reason, int64_t expires_at) {
|
||||
{
|
||||
std::unique_lock lk(mu_);
|
||||
auto it = users_.find(user_id);
|
||||
if (it != users_.end()) {
|
||||
std::string err;
|
||||
db_->ban_create("user_id", std::to_string(user_id), reason, expires_at, err);
|
||||
}
|
||||
}
|
||||
return kick_user(user_id, reason);
|
||||
}
|
||||
|
||||
bool SessionRegistry::set_server_mute(uint32_t user_id, bool muted, bool deafened) {
|
||||
std::unique_lock lk(mu_);
|
||||
auto it = users_.find(user_id);
|
||||
if (it == users_.end()) return false;
|
||||
it->second.proto.set_server_muted(muted);
|
||||
it->second.proto.set_server_deafened(deafened);
|
||||
|
||||
voicecat::v1::Envelope env;
|
||||
auto* ue = env.mutable_user_event();
|
||||
ue->set_kind(voicecat::v1::UserEvent::UPDATED);
|
||||
*ue->mutable_user() = it->second.proto;
|
||||
broadcast_unlocked(env, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SessionRegistry::move_user(uint32_t user_id, uint32_t channel_id) {
|
||||
std::unique_lock lk(mu_);
|
||||
auto ch_it = channels_.find(channel_id);
|
||||
if (ch_it == channels_.end()) return false;
|
||||
auto user_it = users_.find(user_id);
|
||||
if (user_it == users_.end()) return false;
|
||||
|
||||
user_it->second.proto.set_channel_id(channel_id);
|
||||
|
||||
voicecat::v1::Envelope env;
|
||||
auto* ue = env.mutable_user_event();
|
||||
ue->set_kind(voicecat::v1::UserEvent::UPDATED);
|
||||
*ue->mutable_user() = user_it->second.proto;
|
||||
broadcast_unlocked(env, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Channel CRUD ──────────────────────────────────────────────────────────────
|
||||
|
||||
uint32_t SessionRegistry::create_channel(const voicecat::v1::Channel& ch,
|
||||
const std::string& password,
|
||||
std::string& error) {
|
||||
ChannelRecord rec;
|
||||
rec.parent_id = ch.parent_id();
|
||||
rec.name = ch.name();
|
||||
rec.topic = ch.topic();
|
||||
rec.max_users = ch.max_users();
|
||||
rec.type = ch.type();
|
||||
rec.audio = ch.audio();
|
||||
rec.sort_order = ch.order();
|
||||
|
||||
auto result = db_->create_channel(rec, password, error);
|
||||
if (!result) return 0;
|
||||
|
||||
std::unique_lock lk(mu_);
|
||||
uint32_t id = result->id;
|
||||
ChannelEntry entry;
|
||||
entry.proto = ch;
|
||||
entry.proto.set_id(id);
|
||||
entry.proto.set_password_protected(result->password_protected);
|
||||
|
||||
voicecat::v1::Envelope env;
|
||||
auto* ce = env.mutable_channel_event();
|
||||
ce->set_kind(voicecat::v1::ChannelEvent::CREATED);
|
||||
*ce->mutable_channel() = entry.proto;
|
||||
|
||||
channels_[id] = std::move(entry);
|
||||
next_channel_id_ = std::max(next_channel_id_, id + 1);
|
||||
|
||||
broadcast_unlocked(env, 0);
|
||||
return id;
|
||||
}
|
||||
|
||||
bool SessionRegistry::update_channel(const voicecat::v1::Channel& ch,
|
||||
const std::string& password,
|
||||
std::string& error) {
|
||||
ChannelRecord rec;
|
||||
rec.id = ch.id();
|
||||
rec.parent_id = ch.parent_id();
|
||||
rec.name = ch.name();
|
||||
rec.topic = ch.topic();
|
||||
rec.max_users = ch.max_users();
|
||||
rec.type = ch.type();
|
||||
rec.audio = ch.audio();
|
||||
rec.sort_order = ch.order();
|
||||
|
||||
if (!db_->update_channel(rec, password, error)) return false;
|
||||
|
||||
std::unique_lock lk(mu_);
|
||||
auto it = channels_.find(ch.id());
|
||||
if (it == channels_.end()) {
|
||||
error = "channel not found";
|
||||
return false;
|
||||
}
|
||||
it->second.proto = ch;
|
||||
it->second.proto.set_password_protected(
|
||||
!password.empty() || db_->get_channel(ch.id())->password_protected);
|
||||
|
||||
voicecat::v1::Envelope env;
|
||||
auto* ce = env.mutable_channel_event();
|
||||
ce->set_kind(voicecat::v1::ChannelEvent::UPDATED);
|
||||
*ce->mutable_channel() = it->second.proto;
|
||||
broadcast_unlocked(env, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SessionRegistry::delete_channel(uint32_t channel_id, std::string& error) {
|
||||
if (channel_id == 1) { error = "cannot delete root channel"; return false; }
|
||||
if (!db_->delete_channel(channel_id, error)) return false;
|
||||
|
||||
std::unique_lock lk(mu_);
|
||||
channels_.erase(channel_id);
|
||||
|
||||
// Move any users left in the deleted channel to Lobby.
|
||||
for (auto& [uid, entry] : users_) {
|
||||
if (entry.proto.channel_id() != channel_id) continue;
|
||||
entry.proto.set_channel_id(1);
|
||||
voicecat::v1::Envelope uev;
|
||||
auto* ue = uev.mutable_user_event();
|
||||
ue->set_kind(voicecat::v1::UserEvent::UPDATED);
|
||||
*ue->mutable_user() = entry.proto;
|
||||
broadcast_unlocked(uev, 0);
|
||||
}
|
||||
|
||||
voicecat::v1::Envelope env;
|
||||
auto* ce = env.mutable_channel_event();
|
||||
ce->set_kind(voicecat::v1::ChannelEvent::DELETED);
|
||||
ce->set_deleted_id(channel_id);
|
||||
broadcast_unlocked(env, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::optional<voicecat::v1::Channel> SessionRegistry::get_channel(uint32_t channel_id) const {
|
||||
std::shared_lock lk(mu_);
|
||||
auto it = channels_.find(channel_id);
|
||||
if (it == channels_.end()) return std::nullopt;
|
||||
return it->second.proto;
|
||||
}
|
||||
|
||||
bool SessionRegistry::check_channel_password(uint32_t channel_id,
|
||||
const std::string& password) const {
|
||||
return db_->check_channel_password(channel_id, password);
|
||||
}
|
||||
|
||||
// ── M2: UDP / media ───────────────────────────────────────────────────────────
|
||||
|
||||
void SessionRegistry::register_udp_token(const std::array<uint8_t, 16>& token,
|
||||
uint64_t session_id) {
|
||||
@@ -170,7 +420,7 @@ std::shared_ptr<ConnSession> SessionRegistry::find_by_udp_token(
|
||||
}
|
||||
|
||||
void SessionRegistry::register_udp_endpoint(asio::ip::udp::endpoint ep,
|
||||
uint64_t session_id) {
|
||||
uint64_t session_id) {
|
||||
std::unique_lock lk(mu_);
|
||||
udp_endpoints_[ep] = session_id;
|
||||
}
|
||||
@@ -223,7 +473,7 @@ std::optional<voicecat::v1::User> SessionRegistry::set_user_stream(
|
||||
}
|
||||
|
||||
std::optional<voicecat::v1::User> SessionRegistry::clear_user_stream(uint32_t user_id,
|
||||
uint32_t stream_id) {
|
||||
uint32_t stream_id) {
|
||||
std::unique_lock lk(mu_);
|
||||
auto it = users_.find(user_id);
|
||||
if (it == users_.end()) return std::nullopt;
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#define ASIO_STANDALONE 1
|
||||
#include <asio.hpp>
|
||||
|
||||
#include "db.h"
|
||||
#include "proto/voicecat.pb.h"
|
||||
|
||||
namespace voicecat::server {
|
||||
@@ -48,10 +49,10 @@ struct UdpEndpointHash {
|
||||
|
||||
class SessionRegistry {
|
||||
public:
|
||||
SessionRegistry() = default;
|
||||
explicit SessionRegistry(std::shared_ptr<Database> db);
|
||||
|
||||
// Create the default "Lobby" channel (id=1, permanent). Call once at startup.
|
||||
void init_default_channels();
|
||||
// Load channels from the database, seeding defaults on first run.
|
||||
void load_channels();
|
||||
|
||||
// Register a session (before auth). Returns the assigned session_id.
|
||||
uint64_t register_session(std::weak_ptr<ConnSession> session);
|
||||
@@ -71,6 +72,8 @@ class SessionRegistry {
|
||||
// Snapshot for ServerStateSnapshot message.
|
||||
std::vector<voicecat::v1::Channel> channel_snapshot() const;
|
||||
std::vector<voicecat::v1::User> user_snapshot() const;
|
||||
std::optional<voicecat::v1::User> user_snapshot_user(uint32_t user_id) const;
|
||||
std::optional<std::string> user_nickname(uint32_t user_id) const;
|
||||
|
||||
// Resolve target sessions for a text message relay.
|
||||
std::vector<std::shared_ptr<ConnSession>> resolve_text_targets(
|
||||
@@ -79,7 +82,56 @@ class SessionRegistry {
|
||||
// Broadcast an envelope to all sessions except the excluded one.
|
||||
void broadcast(const voicecat::v1::Envelope& env, uint64_t exclude_session_id = 0) const;
|
||||
|
||||
// ── M2: UDP / media ──────────────────────────────────────────────────────
|
||||
private:
|
||||
void broadcast_unlocked(const voicecat::v1::Envelope& env,
|
||||
uint64_t exclude_session_id = 0) const;
|
||||
|
||||
public:
|
||||
// ── Permissions ────────────────────────────────────────────────────────────
|
||||
|
||||
void set_session_permissions(uint64_t session_id,
|
||||
const voicecat::v1::Permissions& perms);
|
||||
std::optional<voicecat::v1::Permissions> get_session_permissions(
|
||||
uint64_t session_id) const;
|
||||
|
||||
// ── Moderation ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Find a live session by its user_id. Returns nullptr if offline.
|
||||
std::shared_ptr<ConnSession> find_session_by_user_id(uint32_t user_id) const;
|
||||
|
||||
// Forcibly disconnect a user with a reason. Broadcasts UserEvent::LEFT.
|
||||
// Returns true if the user was online.
|
||||
bool kick_user(uint32_t user_id, const std::string& reason);
|
||||
|
||||
// Kick a user and insert a persistent ban. Returns true if the user was online.
|
||||
bool ban_user(uint32_t user_id, const std::string& reason, int64_t expires_at);
|
||||
|
||||
// Set server-mute/deafen flags on a user and broadcast the update.
|
||||
bool set_server_mute(uint32_t user_id, bool muted, bool deafened);
|
||||
|
||||
// Move a user to a channel (permission-checked by caller).
|
||||
bool move_user(uint32_t user_id, uint32_t channel_id);
|
||||
|
||||
// ── Channel CRUD ───────────────────────────────────────────────────────────
|
||||
|
||||
// Create a channel. Returns the new channel id, or 0 on error.
|
||||
uint32_t create_channel(const voicecat::v1::Channel& ch, const std::string& password,
|
||||
std::string& error);
|
||||
|
||||
// Update a channel. Returns false on error.
|
||||
bool update_channel(const voicecat::v1::Channel& ch, const std::string& password,
|
||||
std::string& error);
|
||||
|
||||
// Delete a channel. Remaining users are moved to Lobby (id=1). Returns false on error.
|
||||
bool delete_channel(uint32_t channel_id, std::string& error);
|
||||
|
||||
// Return a channel proto by id, or nullopt.
|
||||
std::optional<voicecat::v1::Channel> get_channel(uint32_t channel_id) const;
|
||||
|
||||
// Check a channel password.
|
||||
bool check_channel_password(uint32_t channel_id, const std::string& password) const;
|
||||
|
||||
// ── M2: UDP / media ────────────────────────────────────────────────────────
|
||||
|
||||
// Register a session's UDP token (called at auth success).
|
||||
void register_udp_token(const std::array<uint8_t, 16>& token, uint64_t session_id);
|
||||
@@ -99,7 +151,7 @@ class SessionRegistry {
|
||||
// Add/replace a stream entry on a user (called when StreamAnnounce succeeds).
|
||||
// Returns the updated User proto for broadcasting, or nullopt if user not found.
|
||||
std::optional<voicecat::v1::User> set_user_stream(uint32_t user_id,
|
||||
const voicecat::v1::StreamInfo& info);
|
||||
const voicecat::v1::StreamInfo& info);
|
||||
|
||||
// Remove a stream entry from a user (called on StreamStop). Returns the updated
|
||||
// User proto for broadcasting, or nullopt if user not found.
|
||||
@@ -119,15 +171,20 @@ class SessionRegistry {
|
||||
std::optional<voicecat::v1::AudioConfig> channel_audio_config(uint32_t channel_id) const;
|
||||
|
||||
private:
|
||||
void seed_default_channels();
|
||||
|
||||
mutable std::shared_mutex mu_;
|
||||
|
||||
std::shared_ptr<Database> db_;
|
||||
|
||||
uint64_t next_session_id_{1};
|
||||
uint32_t next_user_id_{1};
|
||||
uint32_t next_channel_id_{2}; // 1 is reserved for Lobby
|
||||
uint32_t next_channel_id_{3}; // 1 and 2 are reserved for Lobby, Music Room
|
||||
|
||||
std::unordered_map<uint64_t, std::weak_ptr<ConnSession>> sessions_;
|
||||
std::unordered_map<uint32_t, UserEntry> users_;
|
||||
std::unordered_map<uint32_t, ChannelEntry> channels_;
|
||||
std::unordered_map<uint64_t, voicecat::v1::Permissions> session_permissions_;
|
||||
|
||||
// M2: token → session_id (populated at auth, cleared on disconnect)
|
||||
struct TokenHash {
|
||||
@@ -150,5 +207,5 @@ class SessionRegistry {
|
||||
|
||||
} // namespace voicecat::server
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
#endif // VOICECAT_SERVER_SESSION_REGISTRY_H
|
||||
#endif // VOICECAT_SERVER_SESSION_REGISTRY_H
|
||||
|
||||
Reference in New Issue
Block a user