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