Files
crm-calls/calls/backend/Calls.Api/Store.cs

231 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}