Модуль «Дзвінки» (ASP.NET Core + React + SQLite) и JWT на Jitsi (meet.volcanic.tes.gd)

This commit is contained in:
crm-calls agent
2026-10-14 19:49:28 +00:00
parent 117f0736be
commit 1f41307dc0
33 changed files with 3592 additions and 8 deletions

6
.gitignore vendored
View File

@@ -1,2 +1,8 @@
deploy.json deploy.json
.env .env
**/bin/
**/obj/
**/node_modules/
**/dist/
calls/backend/Calls.Api/data/
calls/backend/Calls.Api/wwwroot/

7
calls/.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
**/bin/
**/obj/
**/node_modules/
**/dist/
**/wwwroot/
backend/Calls.Api.Tests/
data/

35
calls/Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
# syntax=docker/dockerfile:1.7
# 1. Фронтенд
FROM node:22-alpine AS web
WORKDIR /src
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY frontend/ ./
RUN npm run build
# 2. Бэкенд
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS api
WORKDIR /src
COPY backend/Calls.Api/Calls.Api.csproj backend/Calls.Api/
RUN dotnet restore backend/Calls.Api/Calls.Api.csproj
COPY backend/Calls.Api/ backend/Calls.Api/
RUN dotnet publish backend/Calls.Api/Calls.Api.csproj -c Release -o /out --no-restore /p:UseAppHost=false
# 3. Runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=api /out ./
COPY --from=web /src/dist ./wwwroot
# /data — постоянный том Coolify (SQLite)
RUN mkdir -p /data
ENV ASPNETCORE_ENVIRONMENT=Production \
ASPNETCORE_URLS=http://+:8080 \
Storage__DataDir=/data
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/health || exit 1
ENTRYPOINT ["dotnet", "Calls.Api.dll"]

View File

@@ -0,0 +1,193 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
namespace Calls.Api.Tests;
/// <summary>
/// HTTP API с подменённым Supabase: два пользователя, у каждого свой custom-workspace (RLS).
/// Проверка 2 спецификации на уровне API: пользователь другого workspace в комнату не попадает.
/// </summary>
public sealed class ApiTests : IClassFixture<ApiTests.App>
{
public const string Anna = "tok-anna", Carl = "tok-carl";
public const string AnnaUid = "11111111-1111-1111-1111-111111111111", CarlUid = "22222222-2222-2222-2222-222222222222";
private readonly App _app;
public ApiTests(App app) => _app = app;
/// <summary>Фальшивый Supabase: /auth/v1/user по токену, /rest/v1/workspaces с «RLS».</summary>
public sealed class FakeSupabase : HttpMessageHandler
{
private static readonly Dictionary<string, (string Uid, string Email, string Name, string[] Ws)> Users = new()
{
[Anna] = (AnnaUid, "anna@test", "Анна", ["wsteam0000001"]),
[Carl] = (CarlUid, "carl@test", "Карл", ["wsother000001"]),
};
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage req, CancellationToken ct)
{
var bearer = req.Headers.Authorization?.Parameter ?? "";
var key = Users.Keys.FirstOrDefault(k => bearer.EndsWith("." + k));
if (key == null) return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized));
var u = Users[key];
var path = req.RequestUri!.AbsolutePath;
string body;
if (path == "/auth/v1/user")
body = JsonSerializer.Serialize(new { id = u.Uid, aud = "authenticated", email = u.Email, user_metadata = new { name = u.Name } });
else if (path == "/rest/v1/workspaces")
{
var q = System.Web.HttpUtility.ParseQueryString(req.RequestUri.Query);
var id = q["id"]?.Replace("eq.", "");
var rows = u.Ws.Where(w => id == null || w == id)
.Select(w => new { id = w, kind = "custom", title = "Команда " + w[^1], owner_uid = u.Uid, members = Array.Empty<string>() });
body = JsonSerializer.Serialize(rows);
}
else return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") });
}
}
public sealed class App : WebApplicationFactory<Program>
{
public readonly string Dir = Path.Combine(Path.GetTempPath(), "calls-api-" + Guid.NewGuid().ToString("N"));
protected override void ConfigureWebHost(IWebHostBuilder b)
{
b.UseSetting("Storage:DataDir", Dir);
b.UseSetting("Jitsi:AppSecret", StoreTests.Secret);
b.UseSetting("Jitsi:Domain", "meet.test");
b.UseSetting("Calls:LinkSecret", "test-link-secret-0123456789abcdef0123456789");
b.UseSetting("Calls:PublicUrl", "https://calls.test");
b.UseSetting("Supabase:Url", "https://supabase.test");
b.UseSetting("Supabase:PublishableKey", "sb_publishable_test");
b.UseSetting("Crm:Origins", "https://crm.test");
b.ConfigureTestServices(s => s.AddHttpClient("supabase").ConfigurePrimaryHttpMessageHandler(() => new FakeSupabase()));
}
}
/// <summary>Похожий на Supabase JWT: exp через час, aud = authenticated; подпись фальшивому Supabase не важна.</summary>
public static string Jwt(string who)
{
static string B(string s) => Convert.ToBase64String(Encoding.UTF8.GetBytes(s)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
var exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds();
return B("{\"alg\":\"HS256\"}") + "." + B($"{{\"exp\":{exp},\"aud\":\"authenticated\"}}") + "." + who;
}
private HttpClient Client(string? who, string? ws)
{
var c = _app.CreateClient();
if (who != null) c.DefaultRequestHeaders.Authorization = new("Bearer", Jwt(who));
if (ws != null) c.DefaultRequestHeaders.Add("X-Calls-Workspace", ws);
return c;
}
private static async Task<JsonElement> Json(HttpResponseMessage r) => JsonDocument.Parse(await r.Content.ReadAsStringAsync()).RootElement;
private async Task<JsonElement> PassFor(string session)
{
var r = await _app.CreateClient().PostAsJsonAsync("/api/call/pass", new { session });
Assert.Equal(HttpStatusCode.OK, r.StatusCode);
return await Json(r);
}
[Fact]
public async Task Member_JoinsOwnWorkspaceRoom_AsModerator()
{
var r = await Client(Anna, "wsteam0000001").PostAsync("/api/join", null);
Assert.Equal(HttpStatusCode.OK, r.StatusCode);
var join = await Json(r);
Assert.StartsWith("https://calls.test/call?code=", join.GetProperty("url").GetString());
var ex = await _app.CreateClient().PostAsJsonAsync("/api/call/exchange", new { code = join.GetProperty("code").GetString() });
Assert.Equal(HttpStatusCode.OK, ex.StatusCode);
var pass = await PassFor((await Json(ex)).GetProperty("session").GetString()!);
Assert.Equal("member", pass.GetProperty("kind").GetString());
var claims = StoreTests.VerifyHs256(pass.GetProperty("jwt").GetString()!);
Assert.True(claims.GetProperty("context").GetProperty("user").GetProperty("moderator").GetBoolean());
Assert.Equal("Анна", claims.GetProperty("context").GetProperty("user").GetProperty("name").GetString());
// код одноразовый
var again = await _app.CreateClient().PostAsJsonAsync("/api/call/exchange", new { code = join.GetProperty("code").GetString() });
Assert.Equal(HttpStatusCode.Gone, again.StatusCode);
}
[Theory]
[InlineData("wsteam0000001")] // custom-workspace Анны
[InlineData("personal:" + AnnaUid)] // личный Анны
public async Task OtherUser_CannotEnterSomeoneElsesWorkspace(string ws)
{
var c = Client(Carl, ws);
Assert.Equal(HttpStatusCode.Forbidden, (await c.PostAsync("/api/join", null)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await c.GetAsync("/api/room")).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await c.PostAsync("/api/room/guest-link", null)).StatusCode);
}
[Fact]
public async Task Shared_IsOpenToAllCrmUsers_SameRoom()
{
async Task<string> RoomOf(string who)
{
var j = await Json(await Client(who, "shared").PostAsync("/api/join", null));
var ex = await Json(await _app.CreateClient().PostAsJsonAsync("/api/call/exchange", new { code = j.GetProperty("code").GetString() }));
return (await PassFor(ex.GetProperty("session").GetString()!)).GetProperty("room").GetString()!;
}
Assert.Equal(await RoomOf(Anna), await RoomOf(Carl));
}
[Fact]
public async Task NoOrBadToken_401()
{
Assert.Equal(HttpStatusCode.Unauthorized, (await Client(null, "shared").PostAsync("/api/join", null)).StatusCode);
var c = _app.CreateClient();
c.DefaultRequestHeaders.Authorization = new("Bearer", Jwt("tok-nobody"));
c.DefaultRequestHeaders.Add("X-Calls-Workspace", "shared");
Assert.Equal(HttpStatusCode.Unauthorized, (await c.PostAsync("/api/join", null)).StatusCode);
}
[Fact]
public async Task Workspaces_ListMineOnly()
{
var j = await Json(await Client(Anna, null).GetAsync("/api/workspaces"));
var ids = j.GetProperty("workspaces").EnumerateArray().Select(w => w.GetProperty("id").GetString()!).ToArray();
Assert.Equal(["personal:" + AnnaUid, "shared", "wsteam0000001"], ids);
}
[Fact]
public async Task Guest_ByLink_NotModerator_RegenerateKillsOldLink()
{
var room = await Json(await Client(Anna, "wsteam0000001").GetAsync("/api/room"));
var url = room.GetProperty("guestLink").GetProperty("url").GetString()!;
Assert.StartsWith("https://calls.test/g/", url);
var token = url[(url.LastIndexOf('/') + 1)..];
var anon = _app.CreateClient();
Assert.Equal(HttpStatusCode.OK, (await anon.GetAsync($"/api/guest/{token}")).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await anon.PostAsJsonAsync($"/api/guest/{token}/join", new { name = " " })).StatusCode);
var g = await Json(await anon.PostAsJsonAsync($"/api/guest/{token}/join", new { name = "Гість" }));
var pass = await PassFor(g.GetProperty("session").GetString()!);
Assert.Equal("guest", pass.GetProperty("kind").GetString());
Assert.False(StoreTests.VerifyHs256(pass.GetProperty("jwt").GetString()!).GetProperty("context").GetProperty("user").GetProperty("moderator").GetBoolean());
var regen = await Json(await Client(Anna, "wsteam0000001").PostAsync("/api/room/guest-link", null));
var newUrl = regen.GetProperty("guestLink").GetProperty("url").GetString()!;
Assert.NotEqual(url, newUrl);
Assert.Equal("Анна", regen.GetProperty("guestLink").GetProperty("updatedByName").GetString());
Assert.Equal(HttpStatusCode.NotFound, (await anon.GetAsync($"/api/guest/{token}")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await anon.PostAsJsonAsync($"/api/guest/{token}/join", new { name = "Гість" })).StatusCode);
var newToken = newUrl[(newUrl.LastIndexOf('/') + 1)..];
Assert.Equal(HttpStatusCode.OK, (await anon.PostAsJsonAsync($"/api/guest/{newToken}/join", new { name = "Гість" })).StatusCode);
}
[Fact]
public async Task Headers_FrameAncestorsCrmOnly_NoXFrameOptions()
{
var r = await _app.CreateClient().GetAsync("/api/config");
var csp = r.Headers.GetValues("Content-Security-Policy").Single();
Assert.Contains("frame-ancestors https://crm.test;", csp);
Assert.Contains("frame-src https://meet.test", csp);
Assert.False(r.Headers.Contains("X-Frame-Options"));
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.10.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Calls.Api\Calls.Api.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,174 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Time.Testing;
namespace Calls.Api.Tests;
/// <summary>Комнаты, гостевые ссылки, одноразовые коды и пропуски Jitsi — на временной базе.</summary>
public sealed class StoreTests : IDisposable
{
public const string Secret = "test-jwt-secret-0123456789abcdef0123456789";
private readonly string _dir = Path.Combine(Path.GetTempPath(), "calls-test-" + Guid.NewGuid().ToString("N"));
private readonly FakeTimeProvider _time = new(DateTimeOffset.Parse("2026-10-14T12:00:00Z"));
private readonly Store _store;
private static readonly CrmUser Anna = new("uid-anna", "anna@test", "Анна", 0);
private static readonly CrmUser Bob = new("uid-bob", "bob@test", null, 0);
public StoreTests()
{
var cfg = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Storage:DataDir"] = _dir,
["Jitsi:AppSecret"] = Secret,
["Jitsi:Domain"] = "meet.test",
["Calls:LinkSecret"] = "test-link-secret-0123456789abcdef0123456789",
}).Build();
var db = new Db(cfg);
db.Init();
_store = new Store(db, new JitsiToken(cfg), cfg, _time);
}
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { Directory.Delete(_dir, true); } catch { /* временная папка */ }
}
[Fact]
public void Room_IsStablePerWorkspace_RandomAndWithoutWorkspaceId()
{
var a1 = _store.Room("wsmv5l7d425re160", Anna.Id);
var a2 = _store.Room("wsmv5l7d425re160", Bob.Id);
var b = _store.Room("shared", Anna.Id);
Assert.Equal(a1, a2);
Assert.NotEqual(a1, b);
Assert.Matches("^ws-[a-z0-9]{20}$", a1);
Assert.DoesNotContain("mv5l7d425re160", a1);
}
[Fact]
public void JoinCode_IsOneTime()
{
var (code, ttl) = _store.CreateJoinCode("shared", Anna);
Assert.Equal(60, ttl);
var s = _store.ExchangeCode(code);
Assert.NotNull(s);
Assert.Equal("member", s.Kind);
Assert.Equal("Анна", s.Name);
Assert.Null(_store.ExchangeCode(code));
}
[Fact]
public void JoinCode_ExpiresAfter60Seconds()
{
var (code, _) = _store.CreateJoinCode("shared", Anna);
_time.Advance(TimeSpan.FromSeconds(61));
Assert.Null(_store.ExchangeCode(code));
}
[Fact]
public void Pass_ForMember_IsModerator_ForOneRoom_15Minutes_SignedHs256()
{
var (code, _) = _store.CreateJoinCode("shared", Anna);
var s = _store.ExchangeCode(code)!;
var p = _store.IssuePass(s.Token)!;
Assert.Equal(_store.Room("shared", Anna.Id), p.Room);
Assert.Equal("meet.test", p.Domain);
var claims = VerifyHs256(p.Jwt);
Assert.Equal("tvorymo-crm", claims.GetProperty("iss").GetString());
Assert.Equal("jitsi", claims.GetProperty("aud").GetString());
Assert.Equal(p.Room, claims.GetProperty("room").GetString());
Assert.Equal(_time.GetUtcNow().AddMinutes(15).ToUnixTimeSeconds(), claims.GetProperty("exp").GetInt64());
var user = claims.GetProperty("context").GetProperty("user");
Assert.True(user.GetProperty("moderator").GetBoolean());
Assert.Equal("Анна", user.GetProperty("name").GetString());
Assert.Equal("anna@test", user.GetProperty("email").GetString());
Assert.Equal(Anna.Id, user.GetProperty("id").GetString());
}
[Fact]
public void Session_GivesFreshPassLater_UntilSessionExpires()
{
var (code, _) = _store.CreateJoinCode("shared", Anna);
var s = _store.ExchangeCode(code)!;
_time.Advance(TimeSpan.FromMinutes(40)); // первый пропуск давно истёк — окно берёт новый
var p = _store.IssuePass(s.Token)!;
Assert.Equal(_time.GetUtcNow().AddMinutes(15).ToUnixTimeSeconds(), p.Exp);
_time.Advance(TimeSpan.FromHours(12));
Assert.Null(_store.IssuePass(s.Token));
}
[Fact]
public void GuestLink_IsPersistent_GuestIsNotModerator()
{
var l1 = _store.GetGuestLink("shared", Anna);
var l2 = _store.GetGuestLink("shared", Bob);
Assert.Equal(l1.Token, l2.Token);
var g = _store.JoinAsGuest(l1.Token, "Гість Іван")!;
Assert.Equal("guest", g.Kind);
var p = _store.IssuePass(g.Token)!;
Assert.Equal(_store.Room("shared", Anna.Id), p.Room);
var user = VerifyHs256(p.Jwt).GetProperty("context").GetProperty("user");
Assert.False(user.GetProperty("moderator").GetBoolean());
Assert.Equal("Гість Іван", user.GetProperty("name").GetString());
}
[Fact]
public void GuestLink_Regenerate_OldStopsWorking_NewWorks_RecordsWho()
{
var old = _store.GetGuestLink("shared", Anna);
var fresh = _store.RegenerateGuestLink("shared", Bob);
Assert.NotEqual(old.Token, fresh.Token);
Assert.Null(_store.JoinAsGuest(old.Token, "Гість"));
Assert.NotNull(_store.JoinAsGuest(fresh.Token, "Гість"));
Assert.Equal("uid-bob", fresh.UpdatedBy);
Assert.Equal("bob@test", fresh.UpdatedByName); // имени нет — email
}
[Fact]
public void GuestLink_OfOtherWorkspace_LeadsToItsOwnRoom()
{
var a = _store.GetGuestLink("shared", Anna);
var b = _store.GetGuestLink("wsother000000", Anna);
var pa = _store.IssuePass(_store.JoinAsGuest(a.Token, "x")!.Token)!;
var pb = _store.IssuePass(_store.JoinAsGuest(b.Token, "x")!.Token)!;
Assert.NotEqual(pa.Room, pb.Room);
}
[Theory]
[InlineData("", null)]
[InlineData(" ", null)]
[InlineData(" Іван Петренко ", "Іван Петренко")]
[InlineData("a\u0000b", "ab")]
public void GuestName_IsCleaned(string input, string? expected) => Assert.Equal(expected, Store.CleanGuestName(input));
[Fact]
public void GuestName_IsCutTo60() => Assert.Equal(60, Store.CleanGuestName(new string('я', 100))!.Length);
[Fact]
public void Garbage_TokensAreRejected()
{
Assert.Null(_store.ExchangeCode("x"));
Assert.Null(_store.IssuePass("'; DROP TABLE call_sessions; --"));
Assert.Null(_store.FindGuestLink(new string('a', 43)));
}
public static JsonElement VerifyHs256(string jwt)
{
var parts = jwt.Split('.');
Assert.Equal(3, parts.Length);
var header = JsonDocument.Parse(B64(parts[0])).RootElement;
Assert.Equal("HS256", header.GetProperty("alg").GetString());
var sig = HMACSHA256.HashData(Encoding.UTF8.GetBytes(Secret), Encoding.ASCII.GetBytes(parts[0] + "." + parts[1]));
Assert.Equal(JitsiToken.B64(sig), parts[2]);
return JsonDocument.Parse(B64(parts[1])).RootElement;
}
private static byte[] B64(string s)
{
s = s.Replace('-', '+').Replace('_', '/');
return Convert.FromBase64String(s.PadRight(s.Length + (4 - s.Length % 4) % 4, '='));
}
}

View File

@@ -0,0 +1,181 @@
using System.Collections.Concurrent;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Calls.Api;
public sealed record CrmUser(string Id, string? Email, string? Name, long Exp);
/// <summary>
/// Проверка токена CRM (Supabase access_token) и доступа к workspace.
/// Токены не логируются и не хранятся: в кэше ключ — SHA-256 токена.
/// </summary>
public sealed partial class SupabaseAuth(IHttpClientFactory http, IConfiguration cfg)
{
private readonly string _url = (cfg["Supabase:Url"] ?? "").TrimEnd('/');
private readonly string _key = cfg["Supabase:PublishableKey"] ?? "";
private readonly ConcurrentDictionary<string, (CrmUser User, DateTimeOffset Until)> _users = new();
private readonly ConcurrentDictionary<string, (bool Ok, DateTimeOffset Until)> _ws = new();
private static readonly TimeSpan CacheFor = TimeSpan.FromMinutes(5);
public const string Shared = "shared";
// id пользовательского workspace CRM — текст: 'ws' + base36 (createWorkspace в CRM, напр. wsmv5l7d425re160);
// принимаем и любые похожие текстовые id (в т. ч. uuid). Только безопасные символы — значение идёт в фильтр PostgREST.
[GeneratedRegex("^[A-Za-z0-9_-]{1,64}$")]
private static partial Regex CustomWsRx();
public enum Fail { None, BadToken, Expired, Rejected, Unavailable }
public async Task<(CrmUser? User, Fail Fail)> VerifyAsync(string token, CancellationToken ct)
{
var now = DateTimeOffset.UtcNow;
var key = Hash(token);
if (_users.TryGetValue(key, out var hit) && hit.Until > now && hit.User.Exp > now.ToUnixTimeSeconds())
return (hit.User, Fail.None);
// 1. Разбор JWT: exp и aud.
JsonElement claims;
try
{
var parts = token.Split('.');
if (parts.Length != 3) return (null, Fail.BadToken);
claims = JsonDocument.Parse(Base64Url(parts[1])).RootElement;
}
catch { return (null, Fail.BadToken); }
if (!claims.TryGetProperty("exp", out var expEl) || !expEl.TryGetInt64(out var exp)) return (null, Fail.BadToken);
if (exp <= now.ToUnixTimeSeconds()) return (null, Fail.Expired);
if (!AudIsAuthenticated(claims)) return (null, Fail.Rejected);
// 2. Supabase: GET /auth/v1/user.
HttpResponseMessage r;
try
{
using var req = new HttpRequestMessage(HttpMethod.Get, $"{_url}/auth/v1/user");
req.Headers.Add("apikey", _key);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
r = await http.CreateClient("supabase").SendAsync(req, ct);
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException) { return (null, Fail.Unavailable); }
using (r)
{
if (!r.IsSuccessStatusCode) return (null, (int)r.StatusCode >= 500 ? Fail.Unavailable : Fail.Rejected);
using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct));
var u = doc.RootElement;
if (Str(u, "aud") != "authenticated" || Str(u, "id") is not { } id) return (null, Fail.Rejected);
string? name = null;
if (u.TryGetProperty("user_metadata", out var md) && md.ValueKind == JsonValueKind.Object)
name = Str(md, "name") ?? Str(md, "full_name") ?? Str(md, "display_name");
var user = new CrmUser(id, Str(u, "email"), string.IsNullOrWhiteSpace(name) ? null : name, exp);
_users[key] = (user, now + CacheFor);
if (_users.Count > 5000) Sweep(now);
return (user, Fail.None);
}
}
/// <summary>
/// Нормализует и проверяет workspace. id из клиента не доверяем:
/// shared — для всех, personal:&lt;uid&gt; — только владельцу, остальные (custom, напр. wsmv5l7d425re160) —
/// запросом к таблице workspaces Supabase с токеном пользователя: RLS отдаёт строку, только если он владелец или участник.
/// </summary>
public async Task<bool> CanAccessAsync(CrmUser user, string token, string workspaceId, CancellationToken ct)
{
if (workspaceId == Shared) return true;
if (workspaceId.StartsWith("personal:", StringComparison.Ordinal))
return workspaceId == $"personal:{user.Id}";
if (!CustomWsRx().IsMatch(workspaceId)) return false;
var now = DateTimeOffset.UtcNow;
var key = user.Id + "|" + workspaceId;
if (_ws.TryGetValue(key, out var hit) && hit.Until > now) return hit.Ok;
bool ok;
try
{
using var req = new HttpRequestMessage(HttpMethod.Get,
$"{_url}/rest/v1/workspaces?id=eq.{Uri.EscapeDataString(workspaceId)}&select=id");
req.Headers.Add("apikey", _key);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var r = await http.CreateClient("supabase").SendAsync(req, ct);
if (!r.IsSuccessStatusCode) return false; // ошибку не кэшируем
using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct));
ok = doc.RootElement.ValueKind == JsonValueKind.Array && doc.RootElement.GetArrayLength() > 0;
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException) { return false; }
_ws[key] = (ok, now + CacheFor);
return ok;
}
public sealed record WorkspaceInfo(string Id, string Kind, string Title);
/// <summary>
/// Workspace пользователя для выбора, когда CRM не прислала workspace в crm:auth.
/// Как в CRM (model.js workspaces()): «Мій» (personal:&lt;uid&gt;), «Загальний» (shared) и custom-строки
/// таблицы workspaces, где он владелец или участник (RLS отдаёт только их, фильтр — подстраховка).
/// null — Supabase недоступен.
/// </summary>
public async Task<List<WorkspaceInfo>?> ListWorkspacesAsync(CrmUser user, string token, CancellationToken ct)
{
var list = new List<WorkspaceInfo>
{
new($"personal:{user.Id}", "personal", "Мій"),
new(Shared, "shared", "Загальний"),
};
try
{
using var req = new HttpRequestMessage(HttpMethod.Get,
$"{_url}/rest/v1/workspaces?select=id,kind,title,owner_uid,members&kind=eq.custom");
req.Headers.Add("apikey", _key);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var r = await http.CreateClient("supabase").SendAsync(req, ct);
// Таблицы может не быть (схему CRM не обновили) — тогда только «Мій» и «Загальний».
if ((int)r.StatusCode == 404) return list;
if (!r.IsSuccessStatusCode) return null;
using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct));
if (doc.RootElement.ValueKind != JsonValueKind.Array) return null;
var custom = new List<WorkspaceInfo>();
foreach (var w in doc.RootElement.EnumerateArray())
{
if (Str(w, "id") is not { } id || !CustomWsRx().IsMatch(id)) continue;
var mine = Str(w, "owner_uid") == user.Id;
if (!mine && w.TryGetProperty("members", out var m) && m.ValueKind == JsonValueKind.Array)
mine = m.EnumerateArray().Any(x => x.ValueKind == JsonValueKind.String && x.GetString() == user.Id);
if (!mine) continue;
var title = Str(w, "title");
custom.Add(new(id, "custom", string.IsNullOrWhiteSpace(title) ? "Без назви" : title.Trim()));
_ws[user.Id + "|" + id] = (true, DateTimeOffset.UtcNow + CacheFor);
}
list.AddRange(custom.OrderBy(w => w.Title, StringComparer.Create(new System.Globalization.CultureInfo("uk-UA"), true)));
return list;
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException) { return null; }
}
private void Sweep(DateTimeOffset now)
{
foreach (var (k, v) in _users) if (v.Until <= now) _users.TryRemove(k, out _);
foreach (var (k, v) in _ws) if (v.Until <= now) _ws.TryRemove(k, out _);
}
private static bool AudIsAuthenticated(JsonElement claims)
{
if (!claims.TryGetProperty("aud", out var aud)) return false;
if (aud.ValueKind == JsonValueKind.String) return aud.GetString() == "authenticated";
if (aud.ValueKind == JsonValueKind.Array)
foreach (var a in aud.EnumerateArray()) if (a.ValueKind == JsonValueKind.String && a.GetString() == "authenticated") return true;
return false;
}
private static string? Str(JsonElement e, string p) =>
e.TryGetProperty(p, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
private static string Hash(string s) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(s)));
private static byte[] Base64Url(string s)
{
s = s.Replace('-', '+').Replace('_', '/');
return Convert.FromBase64String(s.PadRight(s.Length + (4 - s.Length % 4) % 4, '='));
}
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.12" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,100 @@
using Microsoft.Data.Sqlite;
namespace Calls.Api;
/// <summary>Подключения к SQLite и схема. Файл базы лежит на постоянном томе (Storage:DataDir).</summary>
public sealed class Db
{
public string DataDir { get; }
private readonly string _cs;
public Db(IConfiguration cfg)
{
DataDir = Path.GetFullPath(cfg["Storage:DataDir"] ?? "data");
Directory.CreateDirectory(DataDir);
_cs = new SqliteConnectionStringBuilder
{
DataSource = Path.Combine(DataDir, "calls.db"),
Cache = SqliteCacheMode.Shared,
}.ToString();
}
public SqliteConnection Open()
{
var c = new SqliteConnection(_cs);
c.Open();
using var cmd = c.CreateCommand();
cmd.CommandText = "PRAGMA busy_timeout=5000;";
cmd.ExecuteNonQuery();
return c;
}
public void Init()
{
using var c = Open();
using var cmd = c.CreateCommand();
// Время — секунды Unix (UTC).
cmd.CommandText = """
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS meta(
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
INSERT OR IGNORE INTO meta(key, value) VALUES ('created_at', strftime('%Y-%m-%dT%H:%M:%fZ','now'));
INSERT OR IGNORE INTO meta(key, value) VALUES ('schema', '1');
-- Одна постоянная комната Jitsi на workspace. Имя случайное, id workspace в нём нет.
CREATE TABLE IF NOT EXISTS rooms(
workspace_id TEXT PRIMARY KEY,
room TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
created_by TEXT NOT NULL
);
-- Постоянная гостевая ссылка workspace. Токен ссылки = HMAC(Calls:LinkSecret, link_id);
-- в базе только link_id и SHA-256 токена, сам токен без секрета не восстановить.
CREATE TABLE IF NOT EXISTS guest_links(
workspace_id TEXT PRIMARY KEY,
link_id TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
updated_at INTEGER NOT NULL,
updated_by TEXT NOT NULL,
updated_by_name TEXT NULL
);
-- Одноразовые коды входа (окно звонка), живут Calls:CodeSeconds. Хранится хеш кода.
CREATE TABLE IF NOT EXISTS join_codes(
code_hash TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
email TEXT NULL,
expires_at INTEGER NOT NULL
);
-- Сессия окна звонка: по ней окно берёт новые пропуски Jitsi без токена CRM. Хранится хеш.
CREATE TABLE IF NOT EXISTS call_sessions(
session_hash TEXT PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('member','guest')),
workspace_id TEXT NOT NULL,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
email TEXT NULL,
link_id TEXT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS call_sessions_exp ON call_sessions(expires_at);
""";
cmd.ExecuteNonQuery();
}
public string CreatedAt()
{
using var c = Open();
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT value FROM meta WHERE key='created_at'";
return (string)cmd.ExecuteScalar()!;
}
}

View File

@@ -0,0 +1,61 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace Calls.Api;
/// <summary>
/// Пропуск в Jitsi: HS256 JWT на одну комнату (prosody token auth, AUTH_TYPE=jwt).
/// Права модератора — context.user.moderator (модуль prosody token_affiliation).
/// </summary>
public sealed class JitsiToken
{
public string Domain { get; }
public string AppId { get; }
public string Audience { get; }
public TimeSpan Ttl { get; }
private readonly byte[] _secret;
public JitsiToken(IConfiguration cfg)
{
Domain = (cfg["Jitsi:Domain"] ?? "meet.volcanic.tes.gd").Trim();
AppId = (cfg["Jitsi:AppId"] ?? "tvorymo-crm").Trim();
Audience = (cfg["Jitsi:Audience"] ?? "jitsi").Trim();
Ttl = TimeSpan.FromMinutes(double.TryParse(cfg["Jitsi:TokenTtlMinutes"], out var m) && m > 0 ? m : 15);
var secret = cfg["Jitsi:AppSecret"] ?? "";
if (secret.Length < 32) throw new InvalidOperationException("Jitsi:AppSecret (JWT_APP_SECRET) не задан или короче 32 символов");
_secret = Encoding.UTF8.GetBytes(secret);
}
public sealed record Pass(string Jwt, long Exp);
public Pass Issue(string room, string userId, string name, string? email, bool moderator, DateTimeOffset now)
{
var exp = (now + Ttl).ToUnixTimeSeconds();
var user = new Dictionary<string, object?> { ["id"] = userId, ["name"] = name, ["moderator"] = moderator };
if (!string.IsNullOrEmpty(email)) user["email"] = email;
var payload = new Dictionary<string, object?>
{
["iss"] = AppId,
["aud"] = Audience,
["sub"] = Domain,
["room"] = room,
["iat"] = now.ToUnixTimeSeconds(),
["nbf"] = now.ToUnixTimeSeconds() - 30,
["exp"] = exp,
["context"] = new Dictionary<string, object?> { ["user"] = user },
["moderator"] = moderator,
};
return new Pass(Sign(payload), exp);
}
public string Sign(object payload)
{
var header = B64(JsonSerializer.SerializeToUtf8Bytes(new { alg = "HS256", typ = "JWT" }));
var body = B64(JsonSerializer.SerializeToUtf8Bytes(payload));
var sig = B64(HMACSHA256.HashData(_secret, Encoding.ASCII.GetBytes(header + "." + body)));
return header + "." + body + "." + sig;
}
public static string B64(byte[] b) => Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}

View File

@@ -0,0 +1,227 @@
using System.Text.Json;
using Calls.Api;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<Db>();
builder.Services.AddSingleton<JitsiToken>();
builder.Services.AddSingleton<Store>();
builder.Services.AddSingleton<SupabaseAuth>();
builder.Services.AddHttpClient("supabase", c => c.Timeout = TimeSpan.FromSeconds(10));
var app = builder.Build();
var cfg = app.Configuration;
// Origin CRM, из которых принимаем crm:auth и в которые разрешено встраивание (через пробел или запятую).
var crmOrigins = (cfg["Crm:Origins"] ?? "https://test.units.tvorymo.com")
.Split([' ', ',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(o => o.TrimEnd('/')).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
// Откуда грузятся стили CRM (ссылки в index.html).
var crmAssets = (cfg["Crm:AssetsOrigin"] ?? "https://test.units.tvorymo.com").TrimEnd('/');
var publicUrl = (cfg["Calls:PublicUrl"] ?? "https://crm-calls.volcanic.tes.gd").TrimEnd('/');
var appStoreUrl = cfg["Calls:AppStoreUrl"] ?? "https://apps.apple.com/app/jitsi-meet/id1165103905";
var playStoreUrl = cfg["Calls:PlayStoreUrl"] ?? "https://play.google.com/store/apps/details?id=org.jitsi.meet";
app.Services.GetRequiredService<Db>().Init();
var jitsi = app.Services.GetRequiredService<JitsiToken>(); // проверка секрета при старте
app.Services.GetRequiredService<Store>(); // проверка Calls:LinkSecret при старте
var meetOrigin = "https://" + jitsi.Domain;
// ---------------------------------------------------------------- заголовки
// Встраивание только в CRM; X-Frame-Options не ставим (иначе iframe пустой).
var csp = string.Join("; ",
$"frame-ancestors {string.Join(' ', crmOrigins)}",
"default-src 'self'",
$"script-src 'self' {meetOrigin}",
$"frame-src {meetOrigin}",
$"style-src 'self' 'unsafe-inline' {crmAssets} https://fonts.googleapis.com",
$"font-src 'self' data: {crmAssets} https://fonts.gstatic.com",
"img-src 'self' data: blob: https:",
"connect-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'");
app.Use(async (ctx, next) =>
{
var h = ctx.Response.Headers;
h["Content-Security-Policy"] = csp;
h["X-Content-Type-Options"] = "nosniff";
// Без Referer: в адресе /g/<токен> и /call?code=… секреты.
h["Referrer-Policy"] = "no-referrer";
// Камера и микрофон нужны окну звонка и iframe Jitsi в нём.
h["Permissions-Policy"] = $"camera=(self \"{meetOrigin}\"), microphone=(self \"{meetOrigin}\"), display-capture=(self \"{meetOrigin}\"), fullscreen=(self \"{meetOrigin}\")";
await next();
});
// ---------------------------------------------------------------- ошибки
app.Use(async (ctx, next) =>
{
try { await next(); }
catch (CallsError e) when (!ctx.Response.HasStarted)
{
ctx.Response.StatusCode = e.Status;
await ctx.Response.WriteAsJsonAsync(new { error = e.Message });
}
catch (JsonException) when (!ctx.Response.HasStarted)
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "некоректний JSON" });
}
catch (BadHttpRequestException e) when (!ctx.Response.HasStarted)
{
ctx.Response.StatusCode = e.StatusCode;
await ctx.Response.WriteAsJsonAsync(new { error = "некоректний запит" });
}
});
// ---------------------------------------------------------------- авторизация CRM для /api/*
// Bearer = access_token Supabase из CRM. Workspace — заголовок X-Calls-Workspace (обязателен там, где нужен).
var open = new[] { "/api/verify", "/api/config", "/api/call/", "/api/guest/" };
app.Use(async (ctx, next) =>
{
var path = ctx.Request.Path.Value ?? "";
if (!path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase)) { await next(); return; }
ctx.Response.Headers.CacheControl = "no-store";
if (open.Any(o => o.EndsWith('/') ? path.StartsWith(o, StringComparison.OrdinalIgnoreCase) : path.Equals(o, StringComparison.OrdinalIgnoreCase)))
{ await next(); return; }
var auth = ctx.Request.Headers.Authorization.ToString();
var token = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? auth[7..].Trim() : "";
if (token.Length == 0) { await Deny(ctx, 401, "потрібен заголовок Authorization: Bearer <токен CRM>"); return; }
var sa = ctx.RequestServices.GetRequiredService<SupabaseAuth>();
var (user, fail) = await sa.VerifyAsync(token, ctx.RequestAborted);
if (user is null)
{
await Deny(ctx, fail == SupabaseAuth.Fail.Unavailable ? 502 : 401, fail switch
{
SupabaseAuth.Fail.Expired => "термін дії токена минув",
SupabaseAuth.Fail.Unavailable => "Supabase недоступний",
_ => "токен не прийнято",
});
return;
}
var ws = ctx.Request.Headers["X-Calls-Workspace"].ToString().Trim();
if (ws.Length > 0 && !await sa.CanAccessAsync(user, token, ws, ctx.RequestAborted))
{ await Deny(ctx, 403, "немає доступу до воркспейсу"); return; }
ctx.Items["user"] = user;
ctx.Items["token"] = token;
ctx.Items["ws"] = ws;
await next();
});
static Task Deny(HttpContext ctx, int status, string error)
{
ctx.Response.StatusCode = status;
return ctx.Response.WriteAsJsonAsync(new { error });
}
static CrmUser U(HttpContext c) => (CrmUser)c.Items["user"]!;
static string Ws(HttpContext c) =>
(string)c.Items["ws"]! is { Length: > 0 } ws ? ws : throw new CallsError(400, "потрібен заголовок X-Calls-Workspace");
// ---------------------------------------------------------------- без авторизации
app.MapGet("/health", (Db db) => Results.Json(new { status = "ok", dbCreatedAt = db.CreatedAt() }));
app.MapGet("/api/config", () => Results.Json(new
{
crmOrigins,
meetDomain = jitsi.Domain,
publicUrl,
appStoreUrl,
playStoreUrl,
}));
app.MapPost("/api/verify", async (VerifyReq req, SupabaseAuth sa, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(req.access_token)) return Results.Json(new { ok = false, error = "немає access_token" }, statusCode: 400);
var (user, fail) = await sa.VerifyAsync(req.access_token, ct);
if (user is null)
return Results.Json(new { ok = false, error = fail == SupabaseAuth.Fail.Expired ? "термін дії токена минув" : "токен не прийнято" },
statusCode: fail == SupabaseAuth.Fail.Unavailable ? 502 : 401);
return Results.Json(new { ok = true, id = user.Id, email = user.Email, name = user.Name, exp = user.Exp });
});
// ---------------------------------------------------------------- участник workspace (токен CRM)
app.MapGet("/api/workspaces", async (HttpContext ctx, SupabaseAuth sa, CancellationToken ct) =>
{
var list = await sa.ListWorkspacesAsync(U(ctx), (string)ctx.Items["token"]!, ct)
?? throw new CallsError(502, "не вдалося отримати воркспейси з CRM");
return Results.Json(new { workspaces = list.Select(w => new { id = w.Id, kind = w.Kind, title = w.Title }) });
});
object LinkJson(Store.GuestLink l) => new
{
url = $"{publicUrl}/g/{l.Token}",
updatedAt = DateTimeOffset.FromUnixTimeSeconds(l.UpdatedAt).ToString("O"),
updatedByName = l.UpdatedByName,
};
app.MapGet("/api/room", (HttpContext ctx, Store store) =>
{
var ws = Ws(ctx);
store.Room(ws, U(ctx).Id);
return Results.Json(new { workspace = ws, guestLink = LinkJson(store.GetGuestLink(ws, U(ctx))) });
});
app.MapPost("/api/room/guest-link", (HttpContext ctx, Store store) =>
{
var ws = Ws(ctx);
store.Room(ws, U(ctx).Id);
return Results.Json(new { workspace = ws, guestLink = LinkJson(store.RegenerateGuestLink(ws, U(ctx))) });
});
app.MapPost("/api/join", (HttpContext ctx, Store store) =>
{
var ws = Ws(ctx);
store.Room(ws, U(ctx).Id);
var (code, expiresIn) = store.CreateJoinCode(ws, U(ctx));
return Results.Json(new { code, expiresIn, url = $"{publicUrl}/call?code={code}" });
});
// ---------------------------------------------------------------- окно звонка (без токена CRM)
app.MapPost("/api/call/exchange", (CodeReq req, Store store) =>
{
var s = store.ExchangeCode(req.code ?? "") ?? throw new CallsError(410, "посилання для входу застаріло, натисніть «Приєднатися» ще раз");
return Results.Json(new { session = s.Token, kind = s.Kind, name = s.Name });
});
app.MapPost("/api/call/pass", (SessionReq req, Store store) =>
{
var p = store.IssuePass(req.session ?? "") ?? throw new CallsError(401, "сесія дзвінка завершилась, відкрийте дзвінок знову");
return Results.Json(new { jwt = p.Jwt, exp = p.Exp, room = p.Room, domain = p.Domain, name = p.Name, kind = p.Kind });
});
// ---------------------------------------------------------------- гость
app.MapGet("/api/guest/{token}", (string token, Store store) =>
store.FindGuestLink(token) != null ? Results.Json(new { ok = true }) : throw new CallsError(404, "посилання недійсне"));
app.MapPost("/api/guest/{token}/join", (string token, GuestReq req, Store store) =>
{
var name = Store.CleanGuestName(req.name) ?? throw new CallsError(400, "вкажіть ім'я");
var s = store.JoinAsGuest(token, name) ?? throw new CallsError(404, "посилання недійсне");
return Results.Json(new { session = s.Token, kind = s.Kind, name = s.Name });
});
app.MapFallback("/api/{**rest}", () => Results.Json(new { error = "не знайдено" }, statusCode: 404));
// ---------------------------------------------------------------- фронтенд (SPA: /, /call, /g/<токен>)
app.UseDefaultFiles();
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = c =>
{
// index.html не кэшируем, ассеты с хешем — надолго.
c.Context.Response.Headers.CacheControl = c.File.Name == "index.html" ? "no-cache" : "public, max-age=31536000, immutable";
},
});
app.MapFallbackToFile("index.html", new StaticFileOptions
{
OnPrepareResponse = c => c.Context.Response.Headers.CacheControl = "no-cache",
});
app.Run();
record VerifyReq(string? access_token);
record CodeReq(string? code);
record SessionReq(string? session);
record GuestReq(string? name);
public partial class Program;

View File

@@ -0,0 +1,230 @@
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();
}
}

View File

@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

18
calls/frontend/index.html Normal file
View File

@@ -0,0 +1,18 @@
<!doctype html>
<html lang="uk">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="referrer" content="no-referrer">
<title>Дзвінки · CRM Tvorymo</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- Стили CRM ссылками: сначала дизайн-система, затем crm.css (порядок важен). -->
<link rel="stylesheet" href="https://test.units.tvorymo.com/tvorymo-crm/css/tvorymo-ui.css">
<link rel="stylesheet" href="https://test.units.tvorymo.com/tvorymo-crm/css/crm.css">
</head>
<body class="side-off">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1295
calls/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
{
"name": "crm-calls-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint"
},
"dependencies": {
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"oxlint": "^1.81.0",
"typescript": "~6.0.2",
"vite": "^8.3.0"
}
}

169
calls/frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,169 @@
// Модуль «Дзвінки» в iframe боковой панели CRM: workspace, кнопка «Приєднатися», гостевая ссылка.
import { useCallback, useEffect, useState } from "react";
import { api, type GuestLink, type Workspace } from "./api";
import { CopyButton } from "./CopyButton";
import { retryReady, useAuth } from "./crm";
import { ConfirmDialog } from "./Dialogs";
import { Icon } from "./Icon";
const WS_KEY = "calls_ws_v1:";
function Waiting() {
const a = useAuth();
const d = a.diag;
const [late, setLate] = useState(false);
useEffect(() => { const t = window.setTimeout(() => setLate(true), 10000); return () => window.clearTimeout(t); }, []);
const text = {
standalone: <>Ця сторінка працює всередині CRM Tvorymo. Відкрийте модуль «Дзвінки» з бічної панелі CRM.</>,
waiting: <>Чекаю авторизацію від CRM…</>,
checking: <>Перевіряю користувача…</>,
error: <>Помилка авторизації: {a.error}</>,
logout: <>Ви вийшли з CRM. Увійдіть у CRM знову.</>,
ok: null,
}[a.status];
return (
<main id="main"><div className="page">
<div className="head"><div><h1>Дзвінки</h1><div className="sub">Відеодзвінки <b>CRM Tvorymo</b></div></div></div>
<div className="pcard"><div className="t">{text}</div>
{a.status === "waiting" && late && <div className="small mt-6">
CRM не надіслала авторизацію. Перевірте, що для цього посилання в бічній панелі CRM увімкнено галочку
«Передавати авторизацію».
<div className="row wrap mt-10"><button className="btn sm" onClick={retryReady}>Надіслати запит ще раз</button></div>
<div className="mute mt-6">Батьківська сторінка: {d.parentOrigin ?? "невідомо"} · дозволені: {d.allowed.join(", ") || "—"} ·
повідомлень отримано: {d.received.length}</div>
</div>}
</div>
</div></main>
);
}
/** Выбор workspace, если CRM его не прислала. */
function Picker(props: { onPick: (w: Workspace) => void; current?: string }) {
const [list, setList] = useState<Workspace[] | null>(null);
const [err, setErr] = useState("");
useEffect(() => { api.workspaces().then((r) => setList(r.workspaces)).catch((e) => setErr((e as Error).message)); }, []);
if (err) return <div className="err">{err}</div>;
if (!list) return <div className="empty">Завантаження воркспейсів…</div>;
return (
<div className="pcard">
<div className="t">Оберіть воркспейс<small>Кожен воркспейс має свою кімнату для дзвінків.</small></div>
<div className="mt-10">
{list.map((w) => (
<button key={w.id} className={"mrow" + (w.id === props.current ? " on" : "")} onClick={() => props.onPick(w)}>
<div className="t">{w.title}<small>{w.kind === "personal" ? "Бачите лише ви" : w.kind === "shared" ? "Усі користувачі CRM" : "Воркспейс на вибраних людей"}</small></div>
<Icon name="chev" />
</button>
))}
</div>
</div>
);
}
function Room(props: { ws: string }) {
const { ws } = props;
const [link, setLink] = useState<GuestLink | null>(null);
const [err, setErr] = useState("");
const [joinErr, setJoinErr] = useState("");
const [fallback, setFallback] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [confirm, setConfirm] = useState(false);
useEffect(() => {
setLink(null); setErr("");
api.room(ws).then((r) => setLink(r.guestLink)).catch((e) => setErr((e as Error).message));
}, [ws]);
const join = async () => {
setJoinErr(""); setFallback(null);
// Окно открываем сразу по клику (пока жива активация пользователя), адрес задаём после запроса кода.
// В iframe CRM нет доступа к камере — звонок идёт в отдельном окне.
const w = window.open("", "_blank");
try { if (w) { w.document.title = "Дзвінок"; w.document.body.textContent = "Підключення…"; } } catch { /* не страшно */ }
setBusy(true);
try {
const r = await api.join(ws);
if (w && !w.closed) w.location.href = r.url;
else setFallback(r.url); // браузер заблокировал окно — ссылка вручную (код живёт 60 секунд)
} catch (e) {
try { w?.close(); } catch { /* уже закрыто */ }
setJoinErr((e as Error).message);
} finally { setBusy(false); }
};
const regenerate = async () => {
const r = await api.regenerateLink(ws);
setLink(r.guestLink);
};
if (err) return <div className="err">{err}</div>;
return (
<>
<div className="pcard">
<div className="t">Кімната воркспейсу<small>Постійна кімната: заходьте будь-коли, учасники воркспейсу — модератори.</small></div>
<div className="row wrap mt-14">
<button className="btn gold" disabled={busy} onClick={() => void join()}><Icon name="video" />Приєднатися</button>
</div>
{joinErr && <div className="err mt-6">{joinErr}</div>}
{fallback && <div className="small mt-10">
Браузер не відкрив нове вікно. <a href={fallback} target="_blank" rel="noopener">Відкрити дзвінок</a> (посилання діє хвилину).
</div>}
</div>
<div className="sec"><h2>Гостьове посилання</h2></div>
<div className="pcard">
<div className="t">Для людей без акаунта CRM<small>За посиланням можна увійти в кімнату цього воркспейсу без прав модератора.</small></div>
{!link ? <div className="empty">Завантаження…</div> : <>
<div className="in mt-10"><input readOnly value={link.url} onFocus={(e) => e.target.select()} aria-label="Гостьове посилання" /></div>
<div className="row wrap mt-10 gap-14">
<CopyButton text={link.url} label="Скопіювати" className="btn sm" />
<button className="btn sm ghost" onClick={() => setConfirm(true)}><Icon name="refresh" />Нове посилання</button>
</div>
<div className="small mute mt-6">
Оновлено {new Date(link.updatedAt).toLocaleString("uk-UA")}{link.updatedByName ? " · " + link.updatedByName : ""}
</div>
</>}
</div>
{confirm && <ConfirmDialog title="Нове гостьове посилання"
text={<>Старе посилання одразу перестане пускати нових гостей. Тим, хто вже в дзвінку, це не завадить.</>}
ok="Створити нове" onOk={regenerate} onClose={() => setConfirm(false)} />}
</>
);
}
export default function App() {
const a = useAuth();
const uid = a.user?.id;
const [picked, setPicked] = useState<Workspace | null>(null);
const [picking, setPicking] = useState(false);
// Выбор workspace запоминаем (если CRM не присылает его сама).
useEffect(() => {
if (!uid || a.crmWorkspace) return;
try {
const raw = localStorage.getItem(WS_KEY + uid);
if (raw) setPicked(JSON.parse(raw) as Workspace);
} catch { /* без сховища */ }
}, [uid, a.crmWorkspace]);
const pick = useCallback((w: Workspace) => {
setPicked(w); setPicking(false);
try { if (uid) localStorage.setItem(WS_KEY + uid, JSON.stringify(w)); } catch { /* без сховища */ }
}, [uid]);
if (a.status !== "ok") return <Waiting />;
const ws = a.crmWorkspace ? { id: a.crmWorkspace.id, title: a.crmWorkspace.title ?? a.crmWorkspace.id } : picked;
const fromCrm = !!a.crmWorkspace;
return (
<main id="main"><div className="page">
<div className="head">
<div><h1>Дзвінки</h1>
<div className="sub">{ws ? <>Воркспейс: <b>{ws.title}</b></> : "Оберіть воркспейс"}
{a.user && <> · {a.user.name ?? a.user.email}</>}</div>
</div>
{!fromCrm && ws && !picking && <button className="btn sm ghost" onClick={() => setPicking(true)}>Змінити воркспейс</button>}
</div>
{(!ws || picking) ? <Picker onPick={pick} current={ws?.id} /> : <Room key={ws.id} ws={ws.id} />}
</div></main>
);
}

229
calls/frontend/src/Call.tsx Normal file
View File

@@ -0,0 +1,229 @@
// Окно звонка: /call (участник workspace) и /g/<токен> (гость) после входа.
// Пропуск Jitsi берётся у сервера модуля по сессии окна и в адрес не попадает
// (исключение — кнопка «Відкрити в додатку»: приложению Jitsi Meet пропуск передаётся только в ссылке).
import { useCallback, useEffect, useRef, useState } from "react";
import { api, type Config, type Pass } from "./api";
import { Icon } from "./Icon";
type JitsiApi = {
addListener(ev: string, fn: (e: unknown) => void): void;
dispose(): void;
};
declare global {
interface Window {
JitsiMeetExternalAPI?: new (domain: string, opts: Record<string, unknown>) => JitsiApi;
/** Состояние для автотестов и диагностики (без пропусков и токенов). */
__calls?: { phase: string; starts: number; joined: number; errors: string[] };
}
}
export function isMobile(): boolean {
const ua = navigator.userAgent;
return /Android|iPhone|iPad|iPod|Mobile/i.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
}
const isIos = () => /iPhone|iPad|iPod/i.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
let scriptFor: string | null = null;
let scriptPromise: Promise<void> | null = null;
function loadExternalApi(domain: string): Promise<void> {
if (window.JitsiMeetExternalAPI) return Promise.resolve();
if (scriptPromise && scriptFor === domain) return scriptPromise;
scriptFor = domain;
scriptPromise = new Promise((resolve, reject) => {
const s = document.createElement("script");
s.src = `https://${domain}/external_api.js`;
s.async = true;
s.onload = () => resolve();
s.onerror = () => { scriptPromise = null; reject(new Error("не вдалося завантажити Jitsi")); };
document.head.appendChild(s);
});
return scriptPromise;
}
const diag = (window.__calls = { phase: "init", starts: 0, joined: 0, errors: [] as string[] });
/** Звонок в браузере. Начатый звонок живёт и после истечения пропуска; если Jitsi не смог переподключиться — берём новый пропуск и входим заново. */
export function BrowserCall(props: { session: string; onSessionLost: (msg: string) => void }) {
const { session, onSessionLost } = props;
const box = useRef<HTMLDivElement>(null);
const restart = useRef<() => void>(() => {});
const [phase, setPhaseState] = useState<"loading" | "in" | "ended" | "retry" | "error">("loading");
const [msg, setMsg] = useState("");
useEffect(() => {
let jitsi: JitsiApi | null = null;
let timer: number | undefined;
let fails = 0;
let alive = true;
const setPhase = (p: "loading" | "in" | "ended" | "retry" | "error") => { diag.phase = p; if (alive) setPhaseState(p); };
function stop() {
window.clearTimeout(timer);
try { jitsi?.dispose(); } catch { /* уже закрыт */ }
jitsi = null;
}
function retryLater(why: string) {
stop();
fails++;
setMsg(why);
if (fails > 6) { setPhase("error"); return; }
setPhase("retry");
timer = window.setTimeout(() => void start(), Math.min(30, 2 ** (fails - 1)) * 1000);
}
async function start() {
stop();
setPhase("loading");
let pass: Pass;
try { pass = await api.pass(session); }
catch (e) {
if ((e as { status?: number }).status === 401) { onSessionLost((e as Error).message); return; }
retryLater((e as Error).message); return;
}
try { await loadExternalApi(pass.domain); }
catch (e) { retryLater((e as Error).message); return; }
if (!alive || !box.current || !window.JitsiMeetExternalAPI) return;
diag.starts++;
let hungUp = false;
const j = new window.JitsiMeetExternalAPI(pass.domain, {
roomName: pass.room,
jwt: pass.jwt,
parentNode: box.current,
width: "100%",
height: "100%",
lang: "uk",
configOverwrite: {
disableDeepLinking: true, // страницу «скачайте приложение» заменяет модуль
prejoinConfig: { enabled: diag.starts === 1 }, // при автоматическом повторном входе — без экрана подготовки
disableInviteFunctions: true,
},
});
jitsi = j;
setPhase("in");
j.addListener("videoConferenceJoined", () => { fails = 0; diag.joined++; setMsg(""); });
j.addListener("readyToClose", () => { hungUp = true; stop(); setPhase("ended"); });
j.addListener("errorOccurred", (e) => {
const err = (e as { error?: { name?: string; isFatal?: boolean; type?: string } }).error ?? {};
diag.errors.push(`${err.type ?? ""}:${err.name ?? ""}${err.isFatal ? ":fatal" : ""}`);
if (diag.errors.length > 30) diag.errors.shift();
// Фатальная ошибка соединения или конференции (в т. ч. отказ по истёкшему пропуску при переподключении):
// Jitsi сам уже не восстановится — входим заново с новым пропуском.
if (err.isFatal && !hungUp && jitsi === j) retryLater("з'єднання перервалося");
});
}
restart.current = () => { fails = 0; void start(); };
void start();
return () => { alive = false; stop(); };
}, [session, onSessionLost]);
return (
<div className="calls-root">
<div ref={box} className={phase === "in" ? "calls-frame" : "calls-frame hidden"} />
{phase !== "in" && (
<main id="main"><div className="page">
<div className="head"><div><h1>Дзвінок</h1></div></div>
{phase === "loading" && <div className="empty">Підключення…</div>}
{phase === "retry" && <div className="empty">Перепідключення… <span className="mute">({msg})</span></div>}
{phase === "ended" && <div className="pcard">
<div className="t">Ви вийшли з дзвінка.</div>
<div className="row wrap mt-14 gap-14">
<button className="btn gold" onClick={() => restart.current()}><Icon name="video" />Повернутися до дзвінка</button>
<button className="btn ghost" onClick={() => window.close()}>Закрити вікно</button>
</div>
</div>}
{phase === "error" && <div className="pcard">
<div className="t">Не вдалося підключитися: {msg}</div>
<div className="row wrap mt-14"><button className="btn gold" onClick={() => restart.current()}><Icon name="refresh" />Спробувати ще раз</button></div>
</div>}
</div></main>
)}
</div>
);
}
/** Телефон: «Відкрити в додатку» (Jitsi Meet) или «Продовжити в браузері». */
export function MobileChoice(props: { session: string; onBrowser: () => void; onSessionLost: (msg: string) => void }) {
const [cfg, setCfg] = useState<Config | null>(null);
const [link, setLink] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
useEffect(() => { api.config().then(setCfg).catch(() => { /* ссылки на магазины по умолчанию */ }); }, []);
const openApp = async () => {
setBusy(true); setErr("");
try {
// Пропуск выдаётся только по нажатию: на одну комнату и на 15 минут.
const p = await api.pass(props.session);
const url = `org.jitsi.meet://${p.domain}/${encodeURIComponent(p.room)}?jwt=${encodeURIComponent(p.jwt)}`;
setLink(url);
window.location.href = url;
} catch (e) {
if ((e as { status?: number }).status === 401) props.onSessionLost((e as Error).message);
else setErr((e as Error).message);
} finally { setBusy(false); }
};
const appStore = cfg?.appStoreUrl ?? "https://apps.apple.com/app/jitsi-meet/id1165103905";
const playStore = cfg?.playStoreUrl ?? "https://play.google.com/store/apps/details?id=org.jitsi.meet";
const stores = isIos() ? [["App Store", appStore], ["Google Play", playStore]] : [["Google Play", playStore], ["App Store", appStore]];
return (
<main id="main"><div className="page">
<div className="head"><div><h1>Дзвінок</h1><div className="sub">Як приєднатися з телефона?</div></div></div>
<div className="pcard">
<div className="row wrap gap-14">
<button className="btn gold" disabled={busy} onClick={() => void openApp()}><Icon name="phone" />Відкрити в додатку</button>
<button className="btn" onClick={props.onBrowser}><Icon name="globe" />Продовжити в браузері</button>
</div>
{err && <div className="err mt-6">{err}</div>}
{link && <div className="small mt-14">
Якщо додаток не відкрився: <a href={link}>відкрити Jitsi Meet</a> (посилання діє 15 хвилин).
</div>}
<div className="small mute mt-14">
Немає додатка Jitsi Meet? Встановіть його з{" "}
<a href={stores[0][1]} target="_blank" rel="noopener noreferrer">{stores[0][0]}</a> або{" "}
<a href={stores[1][1]} target="_blank" rel="noopener noreferrer">{stores[1][0]}</a> і натисніть «Відкрити в додатку» ще раз.
</div>
<div className="small mute mt-6">Якщо зв'язок пропав надовго, додаток сам нового пропуску не візьме: поверніться сюди й натисніть кнопку ще раз.</div>
</div>
</div></main>
);
}
/** Сессия окна → телефон: выбор; компьютер: сразу звонок в браузере. */
export function CallWithSession(props: { session: string; onSessionLost: (msg: string) => void }) {
const [browser, setBrowser] = useState(!isMobile());
return browser
? <BrowserCall session={props.session} onSessionLost={props.onSessionLost} />
: <MobileChoice session={props.session} onBrowser={() => setBrowser(true)} onSessionLost={props.onSessionLost} />;
}
const SESSION_KEY = "calls_session_v1";
/** /call?code=… — обмен одноразового кода на сессию окна. Код сразу убираем из адреса. */
export function CallPage() {
const [session, setSession] = useState<string | null>(null);
const [err, setErr] = useState("");
useEffect(() => {
const code = new URLSearchParams(location.search).get("code");
if (code) history.replaceState(null, "", "/call");
const saved = sessionStorage.getItem(SESSION_KEY);
if (code) {
api.exchange(code)
.then((r) => { sessionStorage.setItem(SESSION_KEY, r.session); setSession(r.session); })
.catch((e) => { if (saved) setSession(saved); else setErr((e as Error).message); });
} else if (saved) setSession(saved);
else setErr("Відкрийте дзвінок кнопкою «Приєднатися» в модулі «Дзвінки» CRM.");
}, []);
const lost = useCallback((m: string) => { sessionStorage.removeItem(SESSION_KEY); setSession(null); setErr(m); }, []);
if (session) return <CallWithSession session={session} onSessionLost={lost} />;
return (
<main id="main"><div className="page">
<div className="head"><div><h1>Дзвінок</h1></div></div>
{err ? <div className="pcard"><div className="t">{err}</div></div> : <div className="empty">Підключення…</div>}
</div></main>
);
}

View File

@@ -0,0 +1,27 @@
import { useEffect, useRef, useState } from "react";
import { copyText } from "./clipboard";
import { ManualCopyDialog } from "./Dialogs";
import { Icon } from "./Icon";
/** Кнопка «скопировать»: после успеха пару секунд пишет «Скопійовано», при неудаче — окно для ручного копирования. */
export function CopyButton(props: { text: string; label: string; title?: string; className?: string }) {
const [done, setDone] = useState(false);
const [manual, setManual] = useState(false);
const timer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(timer.current), []);
const click = async () => {
if (await copyText(props.text)) {
setDone(true);
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setDone(false), 2000);
} else setManual(true);
};
return (
<>
<button className={props.className ?? "btn sm ghost"} title={props.title} onClick={() => void click()} aria-live="polite">
<Icon name={done ? "check" : "copy"} />{done ? "Скопійовано" : props.label}
</button>
{manual && <ManualCopyDialog text={props.text} onClose={() => setManual(false)} />}
</>
);
}

View File

@@ -0,0 +1,69 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Icon } from "./Icon";
/** Аркуш CRM (#sheet-root > .backdrop + .sheet). confirm() в iframe CRM не работает — подтверждение своим окном. */
export function Sheet(props: { title: string; onClose: () => void; children: ReactNode; footer?: ReactNode }) {
const { onClose } = props;
useEffect(() => {
const k = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", k);
document.body.classList.add("lock");
return () => { window.removeEventListener("keydown", k); document.body.classList.remove("lock"); };
}, [onClose]);
return (
<div id="sheet-root" className="open">
<div className="backdrop" onClick={onClose} />
<div className="sheet" role="dialog" aria-modal="true" aria-label={props.title}>
<div className="grab" />
<div className="top">
<span className="tt">{props.title}</span>
<button className="icon-btn x" onClick={onClose} title="Закрити"><Icon name="x" /></button>
</div>
<div className="sc">{props.children}</div>
{props.footer && <div className="bot">{props.footer}</div>}
</div>
</div>
);
}
export function ConfirmDialog(props: {
title: string; text: ReactNode; ok: string; danger?: boolean;
onOk: () => Promise<void> | void; onClose: () => void;
}) {
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
const go = async () => {
setBusy(true);
try { await props.onOk(); props.onClose(); }
catch (e) { setErr((e as Error).message); setBusy(false); }
};
return (
<Sheet title={props.title} onClose={props.onClose} footer={<>
<span className="sp" />
<button className="btn ghost" onClick={props.onClose}>Скасувати</button>
<button className={props.danger ? "btn dng" : "btn gold"} disabled={busy} onClick={go}>{props.ok}</button>
</>}>
<div className="block mt-10">{props.text}</div>
{err && <div className="err">{err}</div>}
</Sheet>
);
}
export function ManualCopyDialog(props: { text: string; onClose: () => void }) {
const ref = useRef<HTMLInputElement>(null);
useEffect(() => { ref.current?.focus(); ref.current?.select(); }, []);
return (
<Sheet title="Скопіюйте посилання" onClose={props.onClose} footer={<>
<span className="sp" />
<button className="btn gold" onClick={props.onClose}>Готово</button>
</>}>
<div className="f">
<label>Скопіюйте вручну (Ctrl+C)</label>
<div className="in">
<input ref={ref} readOnly value={props.text} onFocus={(e) => e.target.select()} onClick={(e) => e.currentTarget.select()} />
</div>
<div className="small mute mt-6">Автоматично скопіювати не вдалося: браузер не дав доступу до буфера обміну.</div>
</div>
</Sheet>
);
}

View File

@@ -0,0 +1,60 @@
// Гостевая страница /g/<токен>: имя → сессия гостя (без прав модератора) → звонок.
import { useCallback, useEffect, useState } from "react";
import { api } from "./api";
import { CallWithSession } from "./Call";
import { Icon } from "./Icon";
const NAME_KEY = "calls_guest_name_v1";
export function GuestPage({ token }: { token: string }) {
const sessionKey = "calls_guest_session_v1:" + token.slice(0, 12);
const [state, setState] = useState<"checking" | "ok" | "bad">("checking");
const [name, setName] = useState(() => { try { return localStorage.getItem(NAME_KEY) ?? ""; } catch { return ""; } });
const [session, setSession] = useState<string | null>(() => sessionStorage.getItem(sessionKey));
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => {
if (session) return;
api.guestCheck(token).then(() => setState("ok")).catch(() => setState("bad"));
}, [token, session]);
const join = async () => {
const n = name.trim();
if (!n) { setErr("Вкажіть ім'я"); return; }
setBusy(true); setErr("");
try {
const r = await api.guestJoin(token, n);
try { localStorage.setItem(NAME_KEY, n); } catch { /* без сховища */ }
sessionStorage.setItem(sessionKey, r.session);
setSession(r.session);
} catch (e) {
if ((e as { status?: number }).status === 404) setState("bad");
else setErr((e as Error).message);
} finally { setBusy(false); }
};
const lost = useCallback((m: string) => { sessionStorage.removeItem(sessionKey); setSession(null); setErr(m); }, [sessionKey]);
if (session) return <CallWithSession session={session} onSessionLost={lost} />;
return (
<main id="main"><div className="page">
<div className="head"><div><h1>Дзвінок</h1><div className="sub">Запрошення від <b>CRM Tvorymo</b></div></div></div>
{state === "checking" && <div className="empty">Перевірка посилання…</div>}
{state === "bad" && <div className="pcard"><div className="t">Посилання недійсне або застаріло.<small>Попросіть того, хто вас запросив, надіслати нове посилання.</small></div></div>}
{state === "ok" && <div className="pcard">
<div className="f">
<label htmlFor="gname">Ваше ім'я</label>
<div className="in big">
<input id="gname" value={name} maxLength={60} autoComplete="name" autoFocus
onChange={(e) => setName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") void join(); }} />
</div>
{err && <div className="err">{err}</div>}
</div>
<div className="row wrap mt-14">
<button className="btn gold" disabled={busy} onClick={() => void join()}><Icon name="video" />Приєднатися</button>
</div>
</div>}
</div></main>
);
}

View File

@@ -0,0 +1,20 @@
// Иконки по правилам tvorymo-ui.css: <svg class="ic" viewBox="0 0 24 24">, обводка currentColor.
const P: Record<string, string> = {
x: "M6 6l12 12M18 6 6 18",
video: "M3 7a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM16 10l5-3v10l-5-3",
phone: "M7 3h10v18H7zM11 18h2",
globe: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18",
link: "M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1",
refresh: "M20 11a8 8 0 0 0-14.9-3M4 5v4h4M4 13a8 8 0 0 0 14.9 3M20 19v-4h-4",
copy: "M9 9h11v11H9zM5 15V4h11",
check: "M5 12l5 5 9-10",
chev: "M9 6l6 6-6 6",
};
export function Icon({ name }: { name: string }) {
return (
<svg className="ic" viewBox="0 0 24 24" aria-hidden="true">
<path d={P[name] ?? ""} />
</svg>
);
}

47
calls/frontend/src/api.ts Normal file
View File

@@ -0,0 +1,47 @@
// Запросы к серверу модуля. Участник workspace — с токеном CRM; окно звонка и гость — по своей сессии.
import { getToken } from "./crm";
export type Workspace = { id: string; kind: string; title: string };
export type GuestLink = { url: string; updatedAt: string; updatedByName: string | null };
export type Pass = { jwt: string; exp: number; room: string; domain: string; name: string; kind: "member" | "guest" };
export type Config = { crmOrigins: string[]; meetDomain: string; publicUrl: string; appStoreUrl: string; playStoreUrl: string };
async function body<T>(r: Response): Promise<T> {
let data: unknown = null;
try { data = await r.json(); } catch { /* не JSON */ }
if (!r.ok) {
const e = new Error((data as { error?: string } | null)?.error || `HTTP ${r.status}`) as Error & { status?: number };
e.status = r.status;
throw e;
}
return data as T;
}
/** Запрос с токеном CRM; на 401 — один раз просим у CRM свежий токен и повторяем. */
async function crm<T>(method: string, path: string, ws?: string): Promise<T> {
const go = async (force: boolean) => {
const headers: Record<string, string> = { Authorization: `Bearer ${await getToken(force)}` };
if (ws) headers["X-Calls-Workspace"] = ws;
return fetch(path, { method, headers });
};
let r = await go(false);
if (r.status === 401) r = await go(true);
return body<T>(r);
}
async function post<T>(path: string, data: unknown): Promise<T> {
return body<T>(await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }));
}
export const api = {
config: async () => body<Config>(await fetch("/api/config")),
workspaces: () => crm<{ workspaces: Workspace[] }>("GET", "/api/workspaces"),
room: (ws: string) => crm<{ workspace: string; guestLink: GuestLink }>("GET", "/api/room", ws),
regenerateLink: (ws: string) => crm<{ workspace: string; guestLink: GuestLink }>("POST", "/api/room/guest-link", ws),
join: (ws: string) => crm<{ code: string; expiresIn: number; url: string }>("POST", "/api/join", ws),
exchange: (code: string) => post<{ session: string; kind: string; name: string }>("/api/call/exchange", { code }),
pass: (session: string) => post<Pass>("/api/call/pass", { session }),
guestCheck: async (token: string) => body<{ ok: boolean }>(await fetch(`/api/guest/${encodeURIComponent(token)}`)),
guestJoin: (token: string, name: string) =>
post<{ session: string; kind: string; name: string }>(`/api/guest/${encodeURIComponent(token)}/join`, { name }),
};

View File

@@ -0,0 +1,7 @@
/* Дзвінки: только то, чего нет в tvorymo-ui.css / crm.css — окно звонка на весь экран и кнопка-строка выбора. */
.calls-root{min-height:100dvh}
.calls-frame{position:fixed;inset:0;background:#000}
.calls-frame.hidden{display:none}
.calls-frame iframe{display:block;border:0}
button.mrow{width:100%;text-align:left;background:none;border:0;font:inherit;color:inherit;cursor:pointer}
button.mrow.on{background:var(--gold-dim)}

View File

@@ -0,0 +1,34 @@
// Копирование в буфер обмена внутри iframe CRM.
// CRM создаёт iframe без allow="clipboard-write", поэтому navigator.clipboard.writeText там отклоняется.
// Запасной путь — временный textarea + document.execCommand("copy"): он работает от клика пользователя.
function copyViaTextarea(text: string): boolean {
const prev = document.activeElement as HTMLElement | null;
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
// Вне экрана, но в документе: иначе выделение не сработает.
ta.style.position = "fixed";
ta.style.top = "-1000px";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
ta.setSelectionRange(0, text.length);
let ok = false;
try { ok = document.execCommand("copy"); } catch { ok = false; }
ta.remove();
prev?.focus?.({ preventScroll: true });
return ok;
}
/** true — скопировано; false — оба способа не сработали (показать окно для ручного копирования). */
export async function copyText(text: string): Promise<boolean> {
// Chrome сообщает заранее, что clipboard-write запрещён: тогда сразу синхронный путь, пока жива активация от клика.
const fp = (document as Document & { featurePolicy?: { allowsFeature(f: string): boolean } }).featurePolicy;
const blocked = fp ? !fp.allowsFeature("clipboard-write") : false;
if (!blocked && navigator.clipboard?.writeText && window.isSecureContext) {
try { await navigator.clipboard.writeText(text); return true; }
catch { /* нет разрешения clipboard-write — пробуем запасной путь */ }
}
return copyViaTextarea(text);
}

183
calls/frontend/src/crm.ts Normal file
View File

@@ -0,0 +1,183 @@
// Мост с CRM: postMessage (crm:ready → crm:auth, crm:workspace, crm:refresh, crm:logout).
// Токен живёт только в памяти, никуда не логируется и не выводится.
import { useSyncExternalStore } from "react";
export type User = { id: string; email: string | null; name: string | null };
export type CrmWorkspace = { id?: string; kind?: string; title?: string };
/** Диагностика для экрана ожидания (без токенов). */
export type Diag = {
allowed: string[];
parentOrigin: string | null;
readySentTo: string[];
received: { origin: string; type: string; accepted: boolean }[];
};
export type AuthState = {
status: "standalone" | "waiting" | "checking" | "ok" | "error" | "logout";
error?: string;
user?: User;
/** Workspace из CRM (id для API: shared | personal:<uid> | id custom). null — CRM не прислала, нужен выбор. */
crmWorkspace: { id: string; title?: string } | null;
diag: Diag;
};
let allowed: string[] = [];
let crmOrigin = "";
let token: string | null = null;
let expiresAt: number | null = null; // мс
let lastWs: CrmWorkspace | null = null;
let state: AuthState = {
status: "waiting", crmWorkspace: null,
diag: { allowed: [], parentOrigin: null, readySentTo: [], received: [] },
};
const subs = new Set<() => void>();
let waiters: Array<() => void> = [];
function set(p: Partial<AuthState>) {
state = { ...state, ...p };
subs.forEach((f) => f());
}
function setDiag(p: Partial<Diag>) {
set({ diag: { ...state.diag, ...p } });
}
export function useAuth() {
return useSyncExternalStore((f) => { subs.add(f); return () => subs.delete(f); }, () => state);
}
function toMs(v: unknown): number | null {
if (v == null) return null;
const n = Number(v);
if (Number.isFinite(n)) return n < 1e12 ? n * 1000 : n;
const d = Date.parse(String(v));
return Number.isNaN(d) ? null : d;
}
/** Workspace из сообщения CRM → id для API. Сервер всё равно проверяет доступ сам. */
function wsKey(w: CrmWorkspace | null | undefined, uid: string | undefined): { id: string; title?: string } | null {
if (!w || typeof w !== "object") return null;
const id = String(w.id ?? "");
const kind = String(w.kind ?? "");
const title = typeof w.title === "string" ? w.title : undefined;
if (kind === "shared" || id === "shared") return { id: "shared", title };
if (kind === "personal" || id.startsWith("personal:")) return uid ? { id: `personal:${uid}`, title } : null;
return id ? { id, title } : null;
}
async function onAuth(d: Record<string, unknown>) {
const t = d.access_token;
if (typeof t !== "string" || !t) { set({ status: "error", error: "crm:auth без access_token" }); return; }
const changedUser = !state.user || (d.user as { id?: string } | undefined)?.id !== state.user.id;
token = t;
expiresAt = toMs(d.expires_at);
if ("workspace" in d) lastWs = (d.workspace as CrmWorkspace) ?? null;
if (!changedUser && state.status === "ok") {
set({ crmWorkspace: wsKey(lastWs, state.user?.id) });
flushWaiters();
return;
}
if (state.status !== "ok") set({ status: "checking" });
try {
const r = await fetch("/api/verify", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ access_token: t }),
});
const res = await r.json();
if (!r.ok || !res.ok) { set({ status: "error", error: res.error || `HTTP ${r.status}` }); return; }
if (!expiresAt && res.exp) expiresAt = res.exp * 1000;
const user: User = { id: res.id, email: res.email, name: res.name };
set({ status: "ok", user, error: undefined, crmWorkspace: wsKey(lastWs, user.id) });
flushWaiters();
} catch {
set({ status: "error", error: "не вдалося перевірити токен" });
}
}
function flushWaiters() {
const w = waiters;
waiters = [];
w.forEach((f) => f());
}
/** Origin родителя: location.ancestorOrigins (Chrome/Safari) или document.referrer (iframe с referrerpolicy=strict-origin). */
function detectParent(): string | null {
try {
const ao = (location as Location & { ancestorOrigins?: DOMStringList }).ancestorOrigins;
if (ao && ao.length > 0 && ao[0] && ao[0] !== "null") return ao[0];
} catch { /* недоступно */ }
try { if (document.referrer) return new URL(document.referrer).origin; } catch { /* битый referrer */ }
return null;
}
function sendReady() {
const origin = detectParent();
// Известен родитель из списка — шлём только ему, иначе каждому разрешённому origin (браузер доставит совпавшему).
const targets = origin && allowed.includes(origin) ? [origin] : allowed;
for (const o of targets) window.parent.postMessage({ type: "crm:ready", version: 1 }, o);
setDiag({ readySentTo: targets });
}
export async function initCrm() {
if (window.parent === window) { set({ status: "standalone" }); return; }
setDiag({ parentOrigin: detectParent() });
let pending: MessageEvent[] | null = [];
window.addEventListener("message", (ev) => {
if (ev.source !== window.parent) return;
if (pending) pending.push(ev); else handle(ev);
});
try {
const cfg = await (await fetch("/api/config")).json();
allowed = Array.isArray(cfg.crmOrigins) ? cfg.crmOrigins : [];
} catch {
set({ status: "error", error: "немає зв'язку з сервером модуля" });
return;
}
setDiag({ allowed });
const early = pending ?? [];
pending = null;
early.forEach(handle);
sendReady();
}
function handle(ev: MessageEvent) {
const d = ev.data;
const type = d && typeof d === "object" && typeof d.type === "string" ? d.type : typeof d;
const ok = allowed.includes(ev.origin);
const rec = state.diag.received;
if (rec.length < 20) setDiag({ received: [...rec, { origin: ev.origin, type, accepted: ok }] });
if (!ok || !d || typeof d !== "object") return;
if (d.type === "crm:auth") { crmOrigin = ev.origin; void onAuth(d); }
else if (d.type === "crm:workspace") {
lastWs = d.workspace ?? null;
set({ crmWorkspace: wsKey(lastWs, state.user?.id) });
} else if (d.type === "crm:logout") {
token = null; expiresAt = null;
set({ status: "logout", user: undefined });
}
}
export function retryReady() {
if (window.parent !== window && allowed.length) sendReady();
}
function requestRefresh(): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
waiters = waiters.filter((w) => w !== done);
reject(new Error("CRM не надіслала новий токен"));
}, 15000);
const done = () => { clearTimeout(timer); resolve(); };
waiters.push(done);
if (crmOrigin) window.parent.postMessage({ type: "crm:refresh" }, crmOrigin);
});
}
/** Токен для запроса: если истекает меньше чем через минуту — просим у CRM свежий. */
export async function getToken(force = false): Promise<string> {
const stale = !token || force || (expiresAt != null && expiresAt - Date.now() < 60_000);
if (stale) {
try { await requestRefresh(); } catch (e) { if (!token) throw e; }
}
if (!token) throw new Error("немає авторизації CRM");
return token;
}

View File

@@ -0,0 +1,17 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import { CallPage } from "./Call";
import { initCrm } from "./crm";
import { GuestPage } from "./Guest";
import "./calls.css";
// Три страницы: / — модуль в iframe CRM, /call — окно звонка, /g/<токен> — гость.
const path = location.pathname;
const guest = /^\/g\/([A-Za-z0-9_-]{20,100})\/?$/.exec(path);
let page;
if (path === "/call" || path === "/call/") page = <CallPage />;
else if (guest) page = <GuestPage token={guest[1]} />;
else { void initCrm(); page = <App />; }
createRoot(document.getElementById("root")!).render(<StrictMode>{page}</StrictMode>);

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,7 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [react()],
server: { proxy: { '/api': 'http://127.0.0.1:5090', '/health': 'http://127.0.0.1:5090' } },
})

38
docker-compose.calls.yml Normal file
View File

@@ -0,0 +1,38 @@
# Модуль «Дзвінки» для CRM: https://crm-calls.volcanic.tes.gd (отдельное приложение Coolify).
# Jitsi — docker-compose.yml (другое приложение, https://meet.volcanic.tes.gd).
# Секреты JWT_APP_SECRET (тот же, что у Jitsi) и GUEST_LINK_SECRET — только в env Coolify.
services:
calls:
build:
context: ./calls
dockerfile: Dockerfile
expose:
- "8080"
environment:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: "http://+:8080"
Storage__DataDir: /data
Calls__PublicUrl: '${CALLS_PUBLIC_URL:-https://crm-calls.volcanic.tes.gd}'
Calls__LinkSecret: '${GUEST_LINK_SECRET}'
Calls__SessionHours: '${CALLS_SESSION_HOURS:-12}'
# origin CRM через пробел: из них принимается crm:auth, они же в frame-ancestors
Crm__Origins: '${CRM_ORIGINS:-https://test.units.tvorymo.com}'
Crm__AssetsOrigin: '${CRM_ASSETS_ORIGIN:-https://test.units.tvorymo.com}'
Supabase__Url: '${SUPABASE_URL:-https://yjxbkcqsquwbsyoonfkn.supabase.co}'
Supabase__PublishableKey: '${SUPABASE_PUBLISHABLE_KEY}'
Jitsi__Domain: '${JITSI_DOMAIN:-meet.volcanic.tes.gd}'
Jitsi__AppId: '${JWT_APP_ID:-tvorymo-crm}'
Jitsi__Audience: '${JWT_AUDIENCE:-jitsi}'
Jitsi__AppSecret: '${JWT_APP_SECRET}'
Jitsi__TokenTtlMinutes: '${JITSI_TOKEN_TTL_MINUTES:-15}'
volumes:
- calls-data:/data
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
volumes:
calls-data:

View File

@@ -2,12 +2,16 @@
# Deployed by Coolify (build_pack: dockercompose) on the Volcanic VPS (164.90.233.156). # Deployed by Coolify (build_pack: dockercompose) on the Volcanic VPS (164.90.233.156).
# #
# Routing: # Routing:
# https://crm-calls.volcanic.tes.gd (443, Coolify Traefik) -> web:8000 (nginx) # https://meet.volcanic.tes.gd (443, Coolify Traefik) -> web:8000 (nginx)
# / -> Jitsi web app # / -> Jitsi web app
# /xmpp-websocket -> prosody:5280 (proxied by web's nginx) # /xmpp-websocket -> prosody:5280 (proxied by web's nginx)
# UDP JVB_PORT (10000) -> jvb (media + SCTP bridge channel), published on the host, bypasses Traefik. # UDP JVB_PORT (10000) -> jvb (media + SCTP bridge channel), published on the host, bypasses Traefik.
# #
# Secrets (JICOFO_AUTH_PASSWORD, JVB_AUTH_PASSWORD) live only in Coolify env vars. # Auth: JWT only (AUTH_TYPE=jwt, ENABLE_GUESTS=0). Passes are issued by the CRM calls module
# (docker-compose.calls.yml, https://crm-calls.volcanic.tes.gd): HS256, iss tvorymo-crm, aud jitsi,
# one room, context.user.moderator -> owner via prosody token_affiliation.
#
# Secrets (JICOFO_AUTH_PASSWORD, JVB_AUTH_PASSWORD, JWT_APP_SECRET) live only in Coolify env vars.
services: services:
web: web:
@@ -24,7 +28,7 @@ services:
- jitsi-web-storage:/storage - jitsi-web-storage:/storage
environment: environment:
TZ: '${TZ:-Europe/Kyiv}' TZ: '${TZ:-Europe/Kyiv}'
PUBLIC_URL: '${PUBLIC_URL:-https://crm-calls.volcanic.tes.gd}' PUBLIC_URL: '${PUBLIC_URL:-https://meet.volcanic.tes.gd}'
DISABLE_HTTPS: '1' DISABLE_HTTPS: '1'
ENABLE_HTTP_REDIRECT: '0' ENABLE_HTTP_REDIRECT: '0'
ENABLE_LETSENCRYPT: '0' ENABLE_LETSENCRYPT: '0'
@@ -37,9 +41,13 @@ services:
XMPP_MUC_DOMAIN: muc.meet.jitsi XMPP_MUC_DOMAIN: muc.meet.jitsi
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
DEFAULT_LANGUAGE: '${DEFAULT_LANGUAGE:-uk}' DEFAULT_LANGUAGE: '${DEFAULT_LANGUAGE:-uk}'
# Embedding into wiki/CRM via iframe: allow *.tes.gd and test.units.tvorymo.com. ENABLE_AUTH: '1'
# Jitsi's nginx sets no X-Frame-Options; this CSP is the only framing policy. AUTH_TYPE: jwt
CSP_HEADER: "frame-ancestors 'self' https://*.tes.gd https://test.units.tvorymo.com" ENABLE_GUESTS: '0'
# The calls module shows its own "open in app / continue in browser" page.
DISABLE_DEEP_LINKING: 'true'
# Only the calls module may embed Jitsi (call window). Jitsi's nginx sets no X-Frame-Options.
CSP_HEADER: "frame-ancestors ${CALLS_ORIGIN:-https://crm-calls.volcanic.tes.gd}"
depends_on: depends_on:
- jvb - jvb
@@ -60,7 +68,7 @@ services:
- jitsi-prosody-data:/var/lib/prosody - jitsi-prosody-data:/var/lib/prosody
environment: environment:
TZ: '${TZ:-Europe/Kyiv}' TZ: '${TZ:-Europe/Kyiv}'
PUBLIC_URL: '${PUBLIC_URL:-https://crm-calls.volcanic.tes.gd}' PUBLIC_URL: '${PUBLIC_URL:-https://meet.volcanic.tes.gd}'
XMPP_SERVER: prosody XMPP_SERVER: prosody
XMPP_DOMAIN: meet.jitsi XMPP_DOMAIN: meet.jitsi
XMPP_AUTH_DOMAIN: auth.meet.jitsi XMPP_AUTH_DOMAIN: auth.meet.jitsi
@@ -70,6 +78,16 @@ services:
ENABLE_XMPP_WEBSOCKET: '1' ENABLE_XMPP_WEBSOCKET: '1'
JICOFO_AUTH_PASSWORD: '${JICOFO_AUTH_PASSWORD}' JICOFO_AUTH_PASSWORD: '${JICOFO_AUTH_PASSWORD}'
JVB_AUTH_PASSWORD: '${JVB_AUTH_PASSWORD}' JVB_AUTH_PASSWORD: '${JVB_AUTH_PASSWORD}'
ENABLE_AUTH: '1'
AUTH_TYPE: jwt
ENABLE_GUESTS: '0'
JWT_ALLOW_EMPTY: '0'
JWT_APP_ID: '${JWT_APP_ID:-tvorymo-crm}'
JWT_APP_SECRET: '${JWT_APP_SECRET}'
JWT_ACCEPTED_ISSUERS: '${JWT_APP_ID:-tvorymo-crm}'
JWT_ACCEPTED_AUDIENCES: '${JWT_AUDIENCE:-jitsi}'
# context.user.moderator=true in the pass -> room owner (moderator); guests -> member.
XMPP_MUC_MODULES: token_affiliation
jicofo: jicofo:
image: ghcr.io/jitsi/jicofo:${JITSI_IMAGE_VERSION:-stable-11248} image: ghcr.io/jitsi/jicofo:${JITSI_IMAGE_VERSION:-stable-11248}
@@ -88,6 +106,10 @@ services:
XMPP_MUC_DOMAIN: muc.meet.jitsi XMPP_MUC_DOMAIN: muc.meet.jitsi
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
JICOFO_AUTH_PASSWORD: '${JICOFO_AUTH_PASSWORD}' JICOFO_AUTH_PASSWORD: '${JICOFO_AUTH_PASSWORD}'
ENABLE_AUTH: '1'
AUTH_TYPE: jwt
# Moderators come only from the pass (token_affiliation); a guest joining first must not become owner.
ENABLE_AUTO_OWNER: 'false'
depends_on: depends_on:
- prosody - prosody
@@ -106,7 +128,7 @@ services:
- jitsi-jvb-config:/config - jitsi-jvb-config:/config
environment: environment:
TZ: '${TZ:-Europe/Kyiv}' TZ: '${TZ:-Europe/Kyiv}'
PUBLIC_URL: '${PUBLIC_URL:-https://crm-calls.volcanic.tes.gd}' PUBLIC_URL: '${PUBLIC_URL:-https://meet.volcanic.tes.gd}'
XMPP_SERVER: prosody XMPP_SERVER: prosody
XMPP_DOMAIN: meet.jitsi XMPP_DOMAIN: meet.jitsi
XMPP_AUTH_DOMAIN: auth.meet.jitsi XMPP_AUTH_DOMAIN: auth.meet.jitsi