231 lines
11 KiB
C#
231 lines
11 KiB
C#
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Комнаты, гостевые ссылки, одноразовые коды и сессии окна звонка.
|
||
/// Секреты (код, сессия, токен ссылки) в базе только хешами SHA-256.
|
||
/// </summary>
|
||
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();
|
||
|
||
// ------------------------------------------------------------ комната
|
||
|
||
/// <summary>Имя комнаты workspace; создаётся при первом обращении. Только [a-z0-9]: Jitsi приводит имя к нижнему регистру.</summary>
|
||
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)!;
|
||
}
|
||
|
||
/// <summary>Новая ссылка: старая сразу перестаёт пускать новых гостей.</summary>
|
||
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)));
|
||
|
||
/// <summary>Действующая ссылка → (workspace, link_id); старая или чужая — null.</summary>
|
||
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);
|
||
|
||
/// <summary>Код одноразовый: удаляется при обмене, даже если уже истёк.</summary>
|
||
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);
|
||
|
||
/// <summary>
|
||
/// Новый пропуск Jitsi по сессии окна звонка. Участник workspace — модератор, гость — нет.
|
||
/// Гостевая сессия живёт, пока жива сессия: перегенерация ссылки не выкидывает уже вошедших гостей.
|
||
/// </summary>
|
||
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";
|
||
|
||
/// <summary>Имя гостя: 1–60 символов без управляющих.</summary>
|
||
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();
|
||
}
|
||
}
|