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:
2026-06-17 15:08:05 +02:00
parent a2f159e971
commit 3990f63f0f
23 changed files with 3281 additions and 97 deletions

View File

@@ -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();

View File

@@ -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

View File

@@ -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!@#$%^&*";

View File

@@ -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};
};

View File

@@ -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);

View File

@@ -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;

View File

@@ -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