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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user