Add managed channel administration and moderation
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
using System.Text;
|
||||
using Google.Protobuf;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Voicecat.V1;
|
||||
|
||||
namespace VoiceCat.Server;
|
||||
|
||||
public sealed partial class VoiceServer
|
||||
{
|
||||
private void Moderate(Session actor, Envelope request)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
bool permitted = actor.Permissions.IsAdmin || request.BodyCase switch
|
||||
{
|
||||
Envelope.BodyOneofCase.Kick or Envelope.BodyOneofCase.ServerMute => actor.Permissions.CanKick,
|
||||
Envelope.BodyOneofCase.Ban => actor.Permissions.CanBan,
|
||||
Envelope.BodyOneofCase.MoveUser => actor.Permissions.CanMoveUsers,
|
||||
// Granting arbitrary permissions (including admin) is reserved for administrators.
|
||||
_ => false
|
||||
};
|
||||
if (!permitted) { SendResult(actor, request.RequestId, false, 6, "Permission denied."); return; }
|
||||
uint id = request.Kick?.UserId ?? request.Ban?.UserId ?? request.MoveUser?.UserId ?? request.ServerMute?.UserId ?? request.SetPermission.UserId;
|
||||
Session? target = sessions.Values.FirstOrDefault(p => !p.Closing && p.User?.Id == id);
|
||||
if (target is null) { SendResult(actor, request.RequestId, false, 3, "User not found."); return; }
|
||||
string reason = request.Kick?.Reason ?? request.Ban?.Reason ?? "";
|
||||
if (Encoding.UTF8.GetByteCount(reason) > 4096 || request.SetPermission is not null && request.SetPermission.Permissions is null)
|
||||
{ SendResult(actor, request.RequestId, false, 3, "Invalid moderation request."); return; }
|
||||
if (request.MoveUser is not null)
|
||||
{
|
||||
Channel? destination = channels.FirstOrDefault(c => c.Id == request.MoveUser.ChannelId);
|
||||
if (destination is null || destination.MaxUsers != 0 && sessions.Values.Count(p => p.Id != target.Id && p.User?.ChannelId == destination.Id) >= destination.MaxUsers)
|
||||
{ SendResult(actor, request.RequestId, false, 3, "Channel unavailable."); return; }
|
||||
target.User!.ChannelId = destination.Id;
|
||||
target.User.Streams.Clear();
|
||||
PublishMedia();
|
||||
BroadcastUser(target);
|
||||
}
|
||||
else if (request.ServerMute is not null)
|
||||
{
|
||||
target.User!.ServerMuted = request.ServerMute.Muted;
|
||||
target.User.ServerDeafened = request.ServerMute.Deafened;
|
||||
PublishMedia();
|
||||
BroadcastUser(target);
|
||||
}
|
||||
else if (request.SetPermission is not null) target.Permissions = request.SetPermission.Permissions.Clone();
|
||||
else
|
||||
{
|
||||
if (request.Ban is not null)
|
||||
{
|
||||
// Guest nicknames are not identities; ban their address instead of reserving a nickname.
|
||||
accounts.Ban(target.User!.IsGuest ? "ip" : "username", target.User.IsGuest ? target.Address : target.User.Nickname, reason, request.Ban.ExpiresUnixMs);
|
||||
}
|
||||
target.DepartureReason = reason;
|
||||
target.Closing = true;
|
||||
PublishMedia();
|
||||
Reject(target, reason.Length == 0 ? "Removed by moderator." : reason);
|
||||
}
|
||||
SendResult(actor, request.RequestId, true, 0, "");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AdministerAccountsAsync(Session actor, Envelope request)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (!actor.Permissions.IsAdmin && !actor.Permissions.CanAdminAccounts)
|
||||
{ SendResult(actor, request.RequestId, false, 6, "Permission denied."); return; }
|
||||
}
|
||||
// Authority is checked when accepting the operation; bounded password work runs off the control loop.
|
||||
try
|
||||
{
|
||||
string? username = request.CreateAccount?.Username ?? request.ResetPassword?.Username ?? request.DeleteAccount?.Username;
|
||||
if (username is not null && (string.IsNullOrWhiteSpace(username) || username.Length > 128)) throw new ArgumentException("Invalid username.");
|
||||
bool ok = true;
|
||||
switch (request.BodyCase)
|
||||
{
|
||||
case Envelope.BodyOneofCase.CreateAccount:
|
||||
await accounts.CreateAccountAsync(username!, request.CreateAccount!.Password, cancellationToken: actor.Connection.CancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
case Envelope.BodyOneofCase.ResetPassword:
|
||||
ok = await accounts.ResetPasswordAsync(username!, request.ResetPassword!.NewPassword, actor.Connection.CancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
case Envelope.BodyOneofCase.DeleteAccount: ok = accounts.DeleteAccount(username!); break;
|
||||
case Envelope.BodyOneofCase.ListAccounts:
|
||||
var response = new Envelope { RequestId = request.RequestId, ListAccountsResult = new() };
|
||||
foreach (var account in accounts.ListAccounts())
|
||||
{
|
||||
response.ListAccountsResult.Accounts.Add(new AccountEntry { Username = account.Username, IsAdmin = account.IsAdmin,
|
||||
CreatedAtUnixMs = checked((ulong)account.CreatedAt * 1000), LastLoginUnixMs = checked((ulong)account.LastLogin * 1000) });
|
||||
if (response.CalculateSize() > 65536) { SendResult(actor, request.RequestId, false, 3, "Account list exceeds protocol frame limit."); return; }
|
||||
}
|
||||
actor.Connection.TrySend(response);
|
||||
return;
|
||||
}
|
||||
SendResult(actor, request.RequestId, ok, ok ? 0U : 3U, ok ? "" : "Account not found.");
|
||||
}
|
||||
catch (ArgumentException) { SendResult(actor, request.RequestId, false, 3, "Invalid username or password."); }
|
||||
catch (SqliteException exception) when (exception.SqliteErrorCode == 19) { SendResult(actor, request.RequestId, false, 3, "Account already exists or is invalid."); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Text;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Voicecat.V1;
|
||||
|
||||
namespace VoiceCat.Server;
|
||||
|
||||
public sealed partial class VoiceServer
|
||||
{
|
||||
private void ManageChannel(Session actor, Envelope request)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
bool create = request.CreateChannel is not null;
|
||||
Channel? input = create ? request.CreateChannel!.Channel : request.EditChannel?.Channel;
|
||||
bool permitted = actor.Permissions.IsAdmin || create && actor.Permissions.CanCreateTempChannel && input?.Type == ChannelType.ChannelTemporary;
|
||||
if (!permitted) { SendResult(actor, request.RequestId, false, 6, "Permission denied."); return; }
|
||||
try
|
||||
{
|
||||
if (request.DeleteChannel is not null)
|
||||
{
|
||||
uint id = request.DeleteChannel.ChannelId;
|
||||
if (id == 1 || !channels.Any(c => c.Id == id) || channels.Any(c => c.ParentId == id))
|
||||
throw new ArgumentException("Cannot delete Lobby, a missing channel, or a channel with children.");
|
||||
accounts.DeleteChannel(id);
|
||||
channels.RemoveAll(c => c.Id == id);
|
||||
foreach (Session peer in sessions.Values.Where(p => p.User?.ChannelId == id))
|
||||
{
|
||||
peer.User!.ChannelId = 1;
|
||||
peer.User.Streams.Clear();
|
||||
BroadcastUser(peer);
|
||||
}
|
||||
PublishMedia();
|
||||
Broadcast(new() { ChannelEvent = new() { Kind = ChannelEvent.Types.Kind.Deleted, DeletedId = id } });
|
||||
}
|
||||
else
|
||||
{
|
||||
string password = create ? request.CreateChannel!.Password : request.EditChannel!.Password;
|
||||
ValidateChannel(input, password, create);
|
||||
Channel saved = accounts.SaveChannel(input!, password, create);
|
||||
if (create) channels.Add(saved);
|
||||
else channels[channels.FindIndex(c => c.Id == saved.Id)] = saved;
|
||||
// Existing encoders negotiated the previous configuration. Stop their streams on edits.
|
||||
if (!create)
|
||||
{
|
||||
foreach (Session peer in sessions.Values.Where(p => p.User?.ChannelId == saved.Id))
|
||||
{
|
||||
peer.User!.Streams.Clear();
|
||||
BroadcastUser(peer);
|
||||
}
|
||||
PublishMedia();
|
||||
}
|
||||
Broadcast(new() { ChannelEvent = new() { Kind = create ? ChannelEvent.Types.Kind.Created : ChannelEvent.Types.Kind.Updated, Channel = saved.Clone() } });
|
||||
}
|
||||
SendResult(actor, request.RequestId, true, 0, "");
|
||||
}
|
||||
catch (ArgumentException exception) { SendResult(actor, request.RequestId, false, 3, exception.Message); }
|
||||
catch (SqliteException exception) when (exception.SqliteErrorCode == 19) { SendResult(actor, request.RequestId, false, 3, "Channel name already exists or channel is invalid."); }
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateChannel(Channel? channel, string password, bool create)
|
||||
{
|
||||
var a = channel?.Audio;
|
||||
if (channel is null || string.IsNullOrWhiteSpace(channel.Name) || Encoding.UTF8.GetByteCount(channel.Name) > 128 ||
|
||||
Encoding.UTF8.GetByteCount(channel.Topic) > 4096 || Encoding.UTF8.GetByteCount(password) > 1024 || !Enum.IsDefined(channel.Type) ||
|
||||
channel.MaxUsers > int.MaxValue || a is null || a.Codec != 0 || !Enum.IsDefined(a.Mode) || !Enum.IsDefined(a.Application) ||
|
||||
a.SampleRate != 48000 || a.BitrateBps is < 500 or > 512000 || a.FrameMs is not (5 or 10 or 20 or 40 or 60) ||
|
||||
a.Complexity > 10 || a.ExpectedPacketLoss > 100 || a.Dred ||
|
||||
!create && !channels.Any(c => c.Id == channel.Id) || channel.ParentId != 0 && !channels.Any(c => c.Id == channel.ParentId))
|
||||
throw new ArgumentException("Invalid channel or audio configuration (database v2 cannot persist DRED).");
|
||||
if (channel.Id == 1 && !create && (password.Length != 0 || channel.ParentId != 0)) throw new ArgumentException("Lobby must remain an unprotected root channel.");
|
||||
uint parent = channel.ParentId;
|
||||
var visited = new HashSet<uint>();
|
||||
while (parent != 0)
|
||||
{
|
||||
if (!visited.Add(parent) || !create && parent == channel.Id) throw new ArgumentException("Channel tree cannot contain cycles.");
|
||||
parent = channels.First(c => c.Id == parent).ParentId;
|
||||
}
|
||||
}
|
||||
|
||||
private static void SendResult(Session actor, ulong id, bool ok, uint code, string message) =>
|
||||
actor.Connection.TrySend(new() { RequestId = id, GenericResult = new() { Ok = ok, Code = code, Message = message } });
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
namespace VoiceCat.Server.Data;
|
||||
|
||||
public sealed partial class AccountStore
|
||||
{
|
||||
public async Task<bool> ResetPasswordAsync(string username, string password, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string hash = await PasswordWorkAsync(() => hasher.Hash(password), cancellationToken).ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
using var connection = Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = "UPDATE accounts SET pw_hash=$hash WHERE username=$user";
|
||||
command.Parameters.AddWithValue("$hash", hash);
|
||||
command.Parameters.AddWithValue("$user", username);
|
||||
return command.ExecuteNonQuery() == 1;
|
||||
}
|
||||
|
||||
public bool DeleteAccount(string username)
|
||||
{
|
||||
using var connection = Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = "DELETE FROM accounts WHERE username=$user";
|
||||
command.Parameters.AddWithValue("$user", username);
|
||||
return command.ExecuteNonQuery() == 1;
|
||||
}
|
||||
|
||||
public IReadOnlyList<Account> ListAccounts()
|
||||
{
|
||||
using var connection = Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT id,username,is_admin,created_at,last_login FROM accounts ORDER BY username";
|
||||
using var reader = command.ExecuteReader();
|
||||
var result = new List<Account>();
|
||||
while (reader.Read()) result.Add(new(reader.GetInt64(0), reader.GetString(1), reader.GetBoolean(2), reader.GetInt64(3), reader.GetInt64(4)));
|
||||
return result;
|
||||
}
|
||||
|
||||
internal void Ban(string type, string subject, string reason, ulong expiresUnixMs)
|
||||
{
|
||||
using var connection = Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = "INSERT INTO bans (subject_type,subject,reason,expires_at,created_at) VALUES ($type,$subject,$reason,$expires,$created)";
|
||||
command.Parameters.AddWithValue("$type", type);
|
||||
command.Parameters.AddWithValue("$subject", subject);
|
||||
command.Parameters.AddWithValue("$reason", reason);
|
||||
// Native schema timestamps are seconds; round upwards to avoid expiring early.
|
||||
command.Parameters.AddWithValue("$expires", checked((long)(expiresUnixMs / 1000 + (expiresUnixMs % 1000 == 0 ? 0UL : 1UL))));
|
||||
command.Parameters.AddWithValue("$created", DateTimeOffset.UtcNow.ToUnixTimeSeconds());
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ namespace VoiceCat.Server.Data;
|
||||
|
||||
public sealed record Account(long Id, string Username, bool IsAdmin, long CreatedAt, long LastLogin);
|
||||
|
||||
public sealed class AccountStore : IDisposable
|
||||
public sealed partial class AccountStore : IDisposable
|
||||
{
|
||||
static AccountStore() => SQLitePCL.Batteries_V2.Init();
|
||||
private readonly string connectionString;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Org.BouncyCastle.Crypto.Digests;
|
||||
using Voicecat.V1;
|
||||
|
||||
namespace VoiceCat.Server.Data;
|
||||
|
||||
public sealed partial class AccountStore
|
||||
{
|
||||
private static byte[] ChannelDigest(string password, byte[] salt)
|
||||
{
|
||||
var digest = new Blake2bDigest(salt, 32, null, null);
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(password);
|
||||
byte[] hash = new byte[32];
|
||||
try { digest.BlockUpdate(bytes, 0, bytes.Length); digest.DoFinal(hash, 0); return hash; }
|
||||
finally { CryptographicOperations.ZeroMemory(bytes); }
|
||||
}
|
||||
|
||||
public bool CheckChannelPassword(uint id, string password)
|
||||
{
|
||||
using var connection = Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT password_hash FROM channels WHERE id=$id";
|
||||
command.Parameters.AddWithValue("$id", id);
|
||||
if (command.ExecuteScalar() is not string stored) return false;
|
||||
if (stored.Length == 0) return true;
|
||||
if (stored.Length != 97 || stored[32] != ':') return false;
|
||||
try
|
||||
{
|
||||
byte[] salt = Convert.FromHexString(stored[..32]);
|
||||
return CryptographicOperations.FixedTimeEquals(ChannelDigest(password, salt), Convert.FromHexString(stored[33..]));
|
||||
}
|
||||
catch (FormatException) { return false; }
|
||||
}
|
||||
|
||||
internal Channel SaveChannel(Channel channel, string password, bool create)
|
||||
{
|
||||
using var connection = Open();
|
||||
using var command = connection.CreateCommand();
|
||||
string hash = "";
|
||||
if (password.Length != 0)
|
||||
{
|
||||
byte[] salt = RandomNumberGenerator.GetBytes(16);
|
||||
hash = Convert.ToHexString(salt).ToLowerInvariant() + ":" + Convert.ToHexString(ChannelDigest(password, salt)).ToLowerInvariant();
|
||||
}
|
||||
string[] columns = ["parent_id", "name", "topic", "max_users", "type", "sort_order", "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"];
|
||||
var a = channel.Audio;
|
||||
object[] values = [channel.ParentId, channel.Name, channel.Topic, channel.MaxUsers, (int)channel.Type, channel.Order, a.Codec, (int)a.Mode, a.SampleRate, a.BitrateBps, a.FrameMs, (int)a.Application, a.Fec, a.ExpectedPacketLoss, a.Dtx, a.Complexity];
|
||||
for (int i = 0; i < columns.Length; i++) command.Parameters.AddWithValue("$" + columns[i], values[i]);
|
||||
command.Parameters.AddWithValue("$hash", hash);
|
||||
command.Parameters.AddWithValue("$id", channel.Id);
|
||||
command.CommandText = create
|
||||
? $"INSERT INTO channels ({string.Join(',', columns)},password_hash) VALUES ({string.Join(',', columns.Select(c => "$" + c))},$hash) RETURNING id"
|
||||
: $"UPDATE channels SET {string.Join(',', columns.Select(c => c + "=$" + c))},password_hash=CASE WHEN $hash='' THEN password_hash ELSE $hash END WHERE id=$id RETURNING id";
|
||||
var saved = channel.Clone();
|
||||
saved.Id = checked((uint)(long)(command.ExecuteScalar() ?? throw new InvalidDataException("Channel not found.")));
|
||||
saved.PasswordProtected = hash.Length != 0 || !create && CheckChannelPasswordPresent(saved.Id);
|
||||
return saved;
|
||||
}
|
||||
|
||||
private bool CheckChannelPasswordPresent(uint id)
|
||||
{
|
||||
using var connection = Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT length(password_hash)>0 FROM channels WHERE id=$id";
|
||||
command.Parameters.AddWithValue("$id", id);
|
||||
return (long)command.ExecuteScalar()! != 0;
|
||||
}
|
||||
|
||||
internal void DeleteChannel(uint id)
|
||||
{
|
||||
using var connection = Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = "DELETE FROM channels WHERE id=$id";
|
||||
command.Parameters.AddWithValue("$id", id);
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,13 @@ using Voicecat.V1;
|
||||
|
||||
namespace VoiceCat.Server;
|
||||
|
||||
public sealed class VoiceServer : IAsyncDisposable
|
||||
public sealed partial class VoiceServer : IAsyncDisposable
|
||||
{
|
||||
private readonly Socket listener;
|
||||
private readonly MediaRelay media;
|
||||
private readonly ServerCredentials credentials;
|
||||
private readonly AccountStore accounts;
|
||||
private readonly IReadOnlyList<Voicecat.V1.Channel> channels;
|
||||
private readonly List<Voicecat.V1.Channel> channels;
|
||||
private readonly bool allowGuests;
|
||||
private readonly string name;
|
||||
private readonly VoiceServerOptions options;
|
||||
@@ -51,7 +51,7 @@ public sealed class VoiceServer : IAsyncDisposable
|
||||
try
|
||||
{
|
||||
accounts = new AccountStore(Path.Combine(directory, "voicecat.db"));
|
||||
channels = accounts.LoadChannels();
|
||||
channels = accounts.LoadChannels().ToList();
|
||||
listener = new Socket(endpoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
|
||||
listener.Bind(endpoint);
|
||||
listener.Listen(options.MaximumConnections);
|
||||
@@ -99,6 +99,7 @@ public sealed class VoiceServer : IAsyncDisposable
|
||||
{
|
||||
await foreach (Envelope envelope in session.Connection.ReadAsync(shutdown.Token).ConfigureAwait(false))
|
||||
{
|
||||
if (session.Closing) break;
|
||||
session.Activity.Touch();
|
||||
if (envelope.Ping is not null)
|
||||
{
|
||||
@@ -131,7 +132,20 @@ public sealed class VoiceServer : IAsyncDisposable
|
||||
{
|
||||
case Envelope.BodyOneofCase.TextMessage: RelayText(session, envelope.TextMessage); break;
|
||||
case Envelope.BodyOneofCase.Subscribe: SendSnapshot(session); break;
|
||||
case Envelope.BodyOneofCase.JoinChannel: Join(session, envelope.RequestId, envelope.JoinChannel.ChannelId); break;
|
||||
case Envelope.BodyOneofCase.JoinChannel: Join(session, envelope.RequestId, envelope.JoinChannel.ChannelId, envelope.JoinChannel.Password); break;
|
||||
case Envelope.BodyOneofCase.LeaveChannel: Join(session, envelope.RequestId, 1); break;
|
||||
case Envelope.BodyOneofCase.CreateChannel:
|
||||
case Envelope.BodyOneofCase.EditChannel:
|
||||
case Envelope.BodyOneofCase.DeleteChannel: ManageChannel(session, envelope); break;
|
||||
case Envelope.BodyOneofCase.Kick:
|
||||
case Envelope.BodyOneofCase.Ban:
|
||||
case Envelope.BodyOneofCase.MoveUser:
|
||||
case Envelope.BodyOneofCase.ServerMute:
|
||||
case Envelope.BodyOneofCase.SetPermission: Moderate(session, envelope); break;
|
||||
case Envelope.BodyOneofCase.CreateAccount:
|
||||
case Envelope.BodyOneofCase.ResetPassword:
|
||||
case Envelope.BodyOneofCase.DeleteAccount:
|
||||
case Envelope.BodyOneofCase.ListAccounts: await AdministerAccountsAsync(session, envelope).ConfigureAwait(false); break;
|
||||
case Envelope.BodyOneofCase.SubscribeVoice: SubscribeVoice(session, envelope.RequestId, true); break;
|
||||
case Envelope.BodyOneofCase.UnsubscribeVoice: SubscribeVoice(session, envelope.RequestId, false); break;
|
||||
case Envelope.BodyOneofCase.StreamAnnounce: AnnounceStream(session, envelope.RequestId, envelope.StreamAnnounce); break;
|
||||
@@ -159,7 +173,7 @@ public sealed class VoiceServer : IAsyncDisposable
|
||||
sessions.Remove(session.Id);
|
||||
if (session.User is null) session.Media?.Dispose();
|
||||
else PublishMedia();
|
||||
if (session.User is not null) Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Left, LeftId = session.User.Id } });
|
||||
if (session.User is not null) Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Left, LeftId = session.User.Id, Reason = session.DepartureReason } });
|
||||
}
|
||||
await session.Connection.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
@@ -208,6 +222,7 @@ public sealed class VoiceServer : IAsyncDisposable
|
||||
session.Connection.CancellationToken.ThrowIfCancellationRequested();
|
||||
lock (gate)
|
||||
{
|
||||
if (session.Closing) return;
|
||||
var lobby = channels.FirstOrDefault(channel => channel.Id == 1);
|
||||
if (user is null || lobby is null || lobby.PasswordProtected || lobby.MaxUsers != 0 && sessions.Values.Count(peer => peer.User?.ChannelId == 1) >= lobby.MaxUsers)
|
||||
{
|
||||
@@ -216,10 +231,11 @@ public sealed class VoiceServer : IAsyncDisposable
|
||||
}
|
||||
user.Id = checked(++nextUser);
|
||||
session.User = user;
|
||||
session.Permissions = new() { IsAdmin = admin, CanAdminAccounts = admin, CanBan = admin, CanKick = admin, CanMoveUsers = admin, CanCreateTempChannel = admin };
|
||||
session.Connection.TrySend(new() { RequestId = requestId, AuthResult = new()
|
||||
{
|
||||
Ok = true, SessionId = session.Id, Self = user.Clone(), UdpToken = ByteString.CopyFrom(session.Media!.Token),
|
||||
Permissions = new() { IsAdmin = admin, CanAdminAccounts = admin, CanBan = admin, CanKick = admin, CanMoveUsers = admin, CanCreateTempChannel = admin }
|
||||
Permissions = session.Permissions.Clone()
|
||||
} });
|
||||
PublishMedia();
|
||||
Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Joined, User = user.Clone() } }, session.Id);
|
||||
@@ -238,12 +254,12 @@ public sealed class VoiceServer : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private void Join(Session session, ulong requestId, uint channelId)
|
||||
private void Join(Session session, ulong requestId, uint channelId, string password = "")
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var channel = channels.FirstOrDefault(candidate => candidate.Id == channelId);
|
||||
if (channel is null || channel.PasswordProtected || channel.MaxUsers != 0 && sessions.Values.Count(peer => peer.Id != session.Id && peer.User?.ChannelId == channelId) >= channel.MaxUsers)
|
||||
if (channel is null || Encoding.UTF8.GetByteCount(password) > 1024 || !accounts.CheckChannelPassword(channelId, password) || channel.MaxUsers != 0 && sessions.Values.Count(peer => peer.Id != session.Id && peer.User?.ChannelId == channelId) >= channel.MaxUsers)
|
||||
{
|
||||
session.Connection.TrySend(new() { RequestId = requestId, JoinChannelResult = new() { Error = "Channel unavailable." } });
|
||||
return;
|
||||
@@ -282,7 +298,7 @@ public sealed class VoiceServer : IAsyncDisposable
|
||||
|
||||
private void PublishMedia()
|
||||
{
|
||||
media.Publish(sessions.Values.Where(peer => peer.User is not null).Select(peer => new MediaRoute(
|
||||
media.Publish(sessions.Values.Where(peer => peer.User is not null && !peer.Closing).Select(peer => new MediaRoute(
|
||||
peer.Media!, peer.User!.ChannelId, peer.User.VoiceSubscribed, peer.User.ServerMuted, peer.User.SelfDeafened || peer.User.ServerDeafened,
|
||||
peer.User.Streams.Select(stream => stream.Ssrc).ToArray())).ToArray());
|
||||
}
|
||||
@@ -380,8 +396,10 @@ public sealed class VoiceServer : IAsyncDisposable
|
||||
public string Address { get; } = address;
|
||||
public SessionActivity Activity { get; } = activity;
|
||||
public bool Closing { get; set; }
|
||||
public string DepartureReason { get; set; } = "";
|
||||
public bool HelloReceived { get; set; }
|
||||
public User? User { get; set; }
|
||||
public Permissions Permissions { get; set; } = new();
|
||||
public MediaPeer? Media { get; set; }
|
||||
public uint NextStream;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user