using System.Security.Cryptography; using System.Text; using Microsoft.Data.Sqlite; namespace Calls.Api; public sealed class CallsError(int status, string message) : Exception(message) { public int Status { get; } = status; } /// /// Комнаты, гостевые ссылки, одноразовые коды и сессии окна звонка. /// Секреты (код, сессия, токен ссылки) в базе только хешами SHA-256. /// public sealed class Store { private readonly Db _db; private readonly JitsiToken _jitsi; private readonly byte[] _linkSecret; private readonly TimeSpan _codeTtl; private readonly TimeSpan _sessionTtl; private readonly TimeProvider _time; public Store(Db db, JitsiToken jitsi, IConfiguration cfg, TimeProvider? time = null) { _db = db; _jitsi = jitsi; _time = time ?? TimeProvider.System; var ls = cfg["Calls:LinkSecret"] ?? ""; if (ls.Length < 32) throw new InvalidOperationException("Calls:LinkSecret (GUEST_LINK_SECRET) не задан или короче 32 символов"); _linkSecret = Encoding.UTF8.GetBytes(ls); _codeTtl = TimeSpan.FromSeconds(int.TryParse(cfg["Calls:CodeSeconds"], out var s) && s > 0 ? s : 60); _sessionTtl = TimeSpan.FromHours(double.TryParse(cfg["Calls:SessionHours"], out var h) && h > 0 ? h : 12); } private long Now => _time.GetUtcNow().ToUnixTimeSeconds(); // ------------------------------------------------------------ комната /// Имя комнаты workspace; создаётся при первом обращении. Только [a-z0-9]: Jitsi приводит имя к нижнему регистру. public string Room(string workspaceId, string userId) { using var c = _db.Open(); var room = Scalar(c, "SELECT room FROM rooms WHERE workspace_id=$w", ("$w", workspaceId)); if (room != null) return room; Exec(c, "INSERT OR IGNORE INTO rooms(workspace_id, room, created_at, created_by) VALUES ($w,$r,$t,$u)", ("$w", workspaceId), ("$r", "ws-" + RandomLower(20)), ("$t", Now), ("$u", userId)); return Scalar(c, "SELECT room FROM rooms WHERE workspace_id=$w", ("$w", workspaceId))!; } // ------------------------------------------------------------ гостевая ссылка public sealed record GuestLink(string Token, long UpdatedAt, string UpdatedBy, string? UpdatedByName); public GuestLink GetGuestLink(string workspaceId, CrmUser user) { using var c = _db.Open(); var link = ReadLink(c, workspaceId); if (link != null) return link; WriteLink(c, workspaceId, user, insertOnly: true); return ReadLink(c, workspaceId)!; } /// Новая ссылка: старая сразу перестаёт пускать новых гостей. public GuestLink RegenerateGuestLink(string workspaceId, CrmUser user) { using var c = _db.Open(); WriteLink(c, workspaceId, user, insertOnly: false); return ReadLink(c, workspaceId)!; } private GuestLink? ReadLink(SqliteConnection c, string workspaceId) { using var cmd = c.CreateCommand(); cmd.CommandText = "SELECT link_id, updated_at, updated_by, updated_by_name FROM guest_links WHERE workspace_id=$w"; cmd.Parameters.AddWithValue("$w", workspaceId); using var r = cmd.ExecuteReader(); if (!r.Read()) return null; return new GuestLink(LinkToken(r.GetString(0)), r.GetInt64(1), r.GetString(2), r.IsDBNull(3) ? null : r.GetString(3)); } private void WriteLink(SqliteConnection c, string workspaceId, CrmUser user, bool insertOnly) { var linkId = RandomLower(24); var sql = insertOnly ? "INSERT OR IGNORE INTO guest_links(workspace_id, link_id, token_hash, updated_at, updated_by, updated_by_name) VALUES ($w,$l,$h,$t,$u,$n)" : """ INSERT INTO guest_links(workspace_id, link_id, token_hash, updated_at, updated_by, updated_by_name) VALUES ($w,$l,$h,$t,$u,$n) ON CONFLICT(workspace_id) DO UPDATE SET link_id=$l, token_hash=$h, updated_at=$t, updated_by=$u, updated_by_name=$n """; Exec(c, sql, ("$w", workspaceId), ("$l", linkId), ("$h", Hash(LinkToken(linkId))), ("$t", Now), ("$u", user.Id), ("$n", (object?)(user.Name ?? user.Email) ?? DBNull.Value)); } private string LinkToken(string linkId) => JitsiToken.B64(HMACSHA256.HashData(_linkSecret, Encoding.UTF8.GetBytes("guest-link:" + linkId))); /// Действующая ссылка → (workspace, link_id); старая или чужая — null. public (string WorkspaceId, string LinkId)? FindGuestLink(string token) { if (!IsToken(token)) return null; using var c = _db.Open(); using var cmd = c.CreateCommand(); cmd.CommandText = "SELECT workspace_id, link_id FROM guest_links WHERE token_hash=$h"; cmd.Parameters.AddWithValue("$h", Hash(token)); using var r = cmd.ExecuteReader(); return r.Read() ? (r.GetString(0), r.GetString(1)) : null; } // ------------------------------------------------------------ код входа и сессия окна звонка public (string Code, int ExpiresIn) CreateJoinCode(string workspaceId, CrmUser user) { var code = RandomToken(); using var c = _db.Open(); Exec(c, "DELETE FROM join_codes WHERE expires_at < $t", ("$t", Now)); Exec(c, "INSERT INTO join_codes(code_hash, workspace_id, user_id, name, email, expires_at) VALUES ($h,$w,$u,$n,$e,$x)", ("$h", Hash(code)), ("$w", workspaceId), ("$u", user.Id), ("$n", DisplayName(user)), ("$e", (object?)user.Email ?? DBNull.Value), ("$x", Now + (long)_codeTtl.TotalSeconds)); return (code, (int)_codeTtl.TotalSeconds); } public sealed record Session(string Token, string Kind, string Name); /// Код одноразовый: удаляется при обмене, даже если уже истёк. public Session? ExchangeCode(string code) { if (!IsToken(code)) return null; using var c = _db.Open(); using var cmd = c.CreateCommand(); cmd.CommandText = "DELETE FROM join_codes WHERE code_hash=$h RETURNING workspace_id, user_id, name, email, expires_at"; cmd.Parameters.AddWithValue("$h", Hash(code)); string ws, uid, name; string? email; long exp; using (var r = cmd.ExecuteReader()) { if (!r.Read()) return null; ws = r.GetString(0); uid = r.GetString(1); name = r.GetString(2); email = r.IsDBNull(3) ? null : r.GetString(3); exp = r.GetInt64(4); } if (exp < Now) return null; return NewSession(c, "member", ws, uid, name, email, null); } public Session? JoinAsGuest(string linkToken, string name) { var link = FindGuestLink(linkToken); if (link == null) return null; using var c = _db.Open(); return NewSession(c, "guest", link.Value.WorkspaceId, "guest-" + RandomLower(12), name, null, link.Value.LinkId); } private Session NewSession(SqliteConnection c, string kind, string ws, string uid, string name, string? email, string? linkId) { var token = RandomToken(); Exec(c, "DELETE FROM call_sessions WHERE expires_at < $t", ("$t", Now)); Exec(c, """ INSERT INTO call_sessions(session_hash, kind, workspace_id, user_id, name, email, link_id, created_at, expires_at) VALUES ($h,$k,$w,$u,$n,$e,$l,$t,$x) """, ("$h", Hash(token)), ("$k", kind), ("$w", ws), ("$u", uid), ("$n", name), ("$e", (object?)email ?? DBNull.Value), ("$l", (object?)linkId ?? DBNull.Value), ("$t", Now), ("$x", Now + (long)_sessionTtl.TotalSeconds)); return new Session(token, kind, name); } public sealed record PassResult(string Jwt, long Exp, string Room, string Domain, string Name, string Kind); /// /// Новый пропуск Jitsi по сессии окна звонка. Участник workspace — модератор, гость — нет. /// Гостевая сессия живёт, пока жива сессия: перегенерация ссылки не выкидывает уже вошедших гостей. /// public PassResult? IssuePass(string session) { if (!IsToken(session)) return null; using var c = _db.Open(); using var cmd = c.CreateCommand(); cmd.CommandText = "SELECT kind, workspace_id, user_id, name, email, expires_at FROM call_sessions WHERE session_hash=$h"; cmd.Parameters.AddWithValue("$h", Hash(session)); string kind, ws, uid, name; string? email; using (var r = cmd.ExecuteReader()) { if (!r.Read() || r.GetInt64(5) < Now) return null; kind = r.GetString(0); ws = r.GetString(1); uid = r.GetString(2); name = r.GetString(3); email = r.IsDBNull(4) ? null : r.GetString(4); } var room = Room(ws, uid); var pass = _jitsi.Issue(room, uid, name, email, moderator: kind == "member", _time.GetUtcNow()); return new PassResult(pass.Jwt, pass.Exp, room, _jitsi.Domain, name, kind); } // ------------------------------------------------------------ helpers public static string DisplayName(CrmUser u) => !string.IsNullOrWhiteSpace(u.Name) ? u.Name.Trim() : !string.IsNullOrWhiteSpace(u.Email) ? u.Email.Trim() : "Користувач CRM"; /// Имя гостя: 1–60 символов без управляющих. public static string? CleanGuestName(string? name) { if (name == null) return null; var s = new string(name.Where(ch => !char.IsControl(ch)).ToArray()).Trim(); while (s.Contains(" ")) s = s.Replace(" ", " "); if (s.Length == 0) return null; return s.Length > 60 ? s[..60].Trim() : s; } private static bool IsToken(string? s) => s is { Length: >= 20 and <= 100 } && s.All(ch => char.IsAsciiLetterOrDigit(ch) || ch is '-' or '_'); private static string RandomToken() => JitsiToken.B64(RandomNumberGenerator.GetBytes(32)); private static string RandomLower(int n) => RandomNumberGenerator.GetString("abcdefghijklmnopqrstuvwxyz0123456789", n); private static string Hash(string s) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(s))); private static string? Scalar(SqliteConnection c, string sql, params (string, object)[] ps) { using var cmd = c.CreateCommand(); cmd.CommandText = sql; foreach (var (k, v) in ps) cmd.Parameters.AddWithValue(k, v); return cmd.ExecuteScalar() as string; } private static void Exec(SqliteConnection c, string sql, params (string, object)[] ps) { using var cmd = c.CreateCommand(); cmd.CommandText = sql; foreach (var (k, v) in ps) cmd.Parameters.AddWithValue(k, v); cmd.ExecuteNonQuery(); } }