diff --git a/.gitignore b/.gitignore index cb17965..accaae7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,8 @@ deploy.json .env +**/bin/ +**/obj/ +**/node_modules/ +**/dist/ +calls/backend/Calls.Api/data/ +calls/backend/Calls.Api/wwwroot/ diff --git a/calls/.dockerignore b/calls/.dockerignore new file mode 100644 index 0000000..d72ba22 --- /dev/null +++ b/calls/.dockerignore @@ -0,0 +1,7 @@ +**/bin/ +**/obj/ +**/node_modules/ +**/dist/ +**/wwwroot/ +backend/Calls.Api.Tests/ +data/ diff --git a/calls/Dockerfile b/calls/Dockerfile new file mode 100644 index 0000000..63759e0 --- /dev/null +++ b/calls/Dockerfile @@ -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"] diff --git a/calls/backend/Calls.Api.Tests/ApiTests.cs b/calls/backend/Calls.Api.Tests/ApiTests.cs new file mode 100644 index 0000000..a16c68f --- /dev/null +++ b/calls/backend/Calls.Api.Tests/ApiTests.cs @@ -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; + +/// +/// HTTP API с подменённым Supabase: два пользователя, у каждого свой custom-workspace (RLS). +/// Проверка 2 спецификации на уровне API: пользователь другого workspace в комнату не попадает. +/// +public sealed class ApiTests : IClassFixture +{ + 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; + + /// Фальшивый Supabase: /auth/v1/user по токену, /rest/v1/workspaces с «RLS». + public sealed class FakeSupabase : HttpMessageHandler + { + private static readonly Dictionary Users = new() + { + [Anna] = (AnnaUid, "anna@test", "Анна", ["wsteam0000001"]), + [Carl] = (CarlUid, "carl@test", "Карл", ["wsother000001"]), + }; + + protected override Task 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() }); + 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 + { + 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())); + } + } + + /// Похожий на Supabase JWT: exp через час, aud = authenticated; подпись фальшивому Supabase не важна. + 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 Json(HttpResponseMessage r) => JsonDocument.Parse(await r.Content.ReadAsStringAsync()).RootElement; + + private async Task 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 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")); + } +} diff --git a/calls/backend/Calls.Api.Tests/Calls.Api.Tests.csproj b/calls/backend/Calls.Api.Tests/Calls.Api.Tests.csproj new file mode 100644 index 0000000..380fddf --- /dev/null +++ b/calls/backend/Calls.Api.Tests/Calls.Api.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/calls/backend/Calls.Api.Tests/StoreTests.cs b/calls/backend/Calls.Api.Tests/StoreTests.cs new file mode 100644 index 0000000..a5d9adf --- /dev/null +++ b/calls/backend/Calls.Api.Tests/StoreTests.cs @@ -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; + +/// Комнаты, гостевые ссылки, одноразовые коды и пропуски Jitsi — на временной базе. +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 + { + ["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, '=')); + } +} diff --git a/calls/backend/Calls.Api/Auth.cs b/calls/backend/Calls.Api/Auth.cs new file mode 100644 index 0000000..1691dfc --- /dev/null +++ b/calls/backend/Calls.Api/Auth.cs @@ -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); + +/// +/// Проверка токена CRM (Supabase access_token) и доступа к workspace. +/// Токены не логируются и не хранятся: в кэше ключ — SHA-256 токена. +/// +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 _users = new(); + private readonly ConcurrentDictionary _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); + } + } + + /// + /// Нормализует и проверяет workspace. id из клиента не доверяем: + /// shared — для всех, personal:<uid> — только владельцу, остальные (custom, напр. wsmv5l7d425re160) — + /// запросом к таблице workspaces Supabase с токеном пользователя: RLS отдаёт строку, только если он владелец или участник. + /// + public async Task 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); + + /// + /// Workspace пользователя для выбора, когда CRM не прислала workspace в crm:auth. + /// Как в CRM (model.js workspaces()): «Мій» (personal:<uid>), «Загальний» (shared) и custom-строки + /// таблицы workspaces, где он владелец или участник (RLS отдаёт только их, фильтр — подстраховка). + /// null — Supabase недоступен. + /// + public async Task?> ListWorkspacesAsync(CrmUser user, string token, CancellationToken ct) + { + var list = new List + { + 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(); + 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, '=')); + } +} diff --git a/calls/backend/Calls.Api/Calls.Api.csproj b/calls/backend/Calls.Api/Calls.Api.csproj new file mode 100644 index 0000000..6ed35f1 --- /dev/null +++ b/calls/backend/Calls.Api/Calls.Api.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + false + + + + + + + diff --git a/calls/backend/Calls.Api/Db.cs b/calls/backend/Calls.Api/Db.cs new file mode 100644 index 0000000..80d76c2 --- /dev/null +++ b/calls/backend/Calls.Api/Db.cs @@ -0,0 +1,100 @@ +using Microsoft.Data.Sqlite; + +namespace Calls.Api; + +/// Подключения к SQLite и схема. Файл базы лежит на постоянном томе (Storage:DataDir). +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()!; + } +} diff --git a/calls/backend/Calls.Api/JitsiToken.cs b/calls/backend/Calls.Api/JitsiToken.cs new file mode 100644 index 0000000..8f390b2 --- /dev/null +++ b/calls/backend/Calls.Api/JitsiToken.cs @@ -0,0 +1,61 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace Calls.Api; + +/// +/// Пропуск в Jitsi: HS256 JWT на одну комнату (prosody token auth, AUTH_TYPE=jwt). +/// Права модератора — context.user.moderator (модуль prosody token_affiliation). +/// +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 { ["id"] = userId, ["name"] = name, ["moderator"] = moderator }; + if (!string.IsNullOrEmpty(email)) user["email"] = email; + var payload = new Dictionary + { + ["iss"] = AppId, + ["aud"] = Audience, + ["sub"] = Domain, + ["room"] = room, + ["iat"] = now.ToUnixTimeSeconds(), + ["nbf"] = now.ToUnixTimeSeconds() - 30, + ["exp"] = exp, + ["context"] = new Dictionary { ["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('/', '_'); +} diff --git a/calls/backend/Calls.Api/Program.cs b/calls/backend/Calls.Api/Program.cs new file mode 100644 index 0000000..3e8aeee --- /dev/null +++ b/calls/backend/Calls.Api/Program.cs @@ -0,0 +1,227 @@ +using System.Text.Json; +using Calls.Api; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +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().Init(); +var jitsi = app.Services.GetRequiredService(); // проверка секрета при старте +app.Services.GetRequiredService(); // проверка 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(); + 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; diff --git a/calls/backend/Calls.Api/Store.cs b/calls/backend/Calls.Api/Store.cs new file mode 100644 index 0000000..ff3bac2 --- /dev/null +++ b/calls/backend/Calls.Api/Store.cs @@ -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; +} + +/// +/// Комнаты, гостевые ссылки, одноразовые коды и сессии окна звонка. +/// Секреты (код, сессия, токен ссылки) в базе только хешами SHA-256. +/// +public sealed class Store +{ + private readonly Db _db; + private readonly JitsiToken _jitsi; + private readonly byte[] _linkSecret; + private readonly TimeSpan _codeTtl; + private readonly TimeSpan _sessionTtl; + private readonly TimeProvider _time; + + public Store(Db db, JitsiToken jitsi, IConfiguration cfg, TimeProvider? time = null) + { + _db = db; + _jitsi = jitsi; + _time = time ?? TimeProvider.System; + var ls = cfg["Calls:LinkSecret"] ?? ""; + if (ls.Length < 32) throw new InvalidOperationException("Calls:LinkSecret (GUEST_LINK_SECRET) не задан или короче 32 символов"); + _linkSecret = Encoding.UTF8.GetBytes(ls); + _codeTtl = TimeSpan.FromSeconds(int.TryParse(cfg["Calls:CodeSeconds"], out var s) && s > 0 ? s : 60); + _sessionTtl = TimeSpan.FromHours(double.TryParse(cfg["Calls:SessionHours"], out var h) && h > 0 ? h : 12); + } + + private long Now => _time.GetUtcNow().ToUnixTimeSeconds(); + + // ------------------------------------------------------------ комната + + /// Имя комнаты workspace; создаётся при первом обращении. Только [a-z0-9]: Jitsi приводит имя к нижнему регистру. + public string Room(string workspaceId, string userId) + { + using var c = _db.Open(); + var room = Scalar(c, "SELECT room FROM rooms WHERE workspace_id=$w", ("$w", workspaceId)); + if (room != null) return room; + Exec(c, "INSERT OR IGNORE INTO rooms(workspace_id, room, created_at, created_by) VALUES ($w,$r,$t,$u)", + ("$w", workspaceId), ("$r", "ws-" + RandomLower(20)), ("$t", Now), ("$u", userId)); + return Scalar(c, "SELECT room FROM rooms WHERE workspace_id=$w", ("$w", workspaceId))!; + } + + // ------------------------------------------------------------ гостевая ссылка + + public sealed record GuestLink(string Token, long UpdatedAt, string UpdatedBy, string? UpdatedByName); + + public GuestLink GetGuestLink(string workspaceId, CrmUser user) + { + using var c = _db.Open(); + var link = ReadLink(c, workspaceId); + if (link != null) return link; + WriteLink(c, workspaceId, user, insertOnly: true); + return ReadLink(c, workspaceId)!; + } + + /// Новая ссылка: старая сразу перестаёт пускать новых гостей. + public GuestLink RegenerateGuestLink(string workspaceId, CrmUser user) + { + using var c = _db.Open(); + WriteLink(c, workspaceId, user, insertOnly: false); + return ReadLink(c, workspaceId)!; + } + + private GuestLink? ReadLink(SqliteConnection c, string workspaceId) + { + using var cmd = c.CreateCommand(); + cmd.CommandText = "SELECT link_id, updated_at, updated_by, updated_by_name FROM guest_links WHERE workspace_id=$w"; + cmd.Parameters.AddWithValue("$w", workspaceId); + using var r = cmd.ExecuteReader(); + if (!r.Read()) return null; + return new GuestLink(LinkToken(r.GetString(0)), r.GetInt64(1), r.GetString(2), r.IsDBNull(3) ? null : r.GetString(3)); + } + + private void WriteLink(SqliteConnection c, string workspaceId, CrmUser user, bool insertOnly) + { + var linkId = RandomLower(24); + var sql = insertOnly + ? "INSERT OR IGNORE INTO guest_links(workspace_id, link_id, token_hash, updated_at, updated_by, updated_by_name) VALUES ($w,$l,$h,$t,$u,$n)" + : """ + INSERT INTO guest_links(workspace_id, link_id, token_hash, updated_at, updated_by, updated_by_name) VALUES ($w,$l,$h,$t,$u,$n) + ON CONFLICT(workspace_id) DO UPDATE SET link_id=$l, token_hash=$h, updated_at=$t, updated_by=$u, updated_by_name=$n + """; + Exec(c, sql, ("$w", workspaceId), ("$l", linkId), ("$h", Hash(LinkToken(linkId))), ("$t", Now), + ("$u", user.Id), ("$n", (object?)(user.Name ?? user.Email) ?? DBNull.Value)); + } + + private string LinkToken(string linkId) => + JitsiToken.B64(HMACSHA256.HashData(_linkSecret, Encoding.UTF8.GetBytes("guest-link:" + linkId))); + + /// Действующая ссылка → (workspace, link_id); старая или чужая — null. + public (string WorkspaceId, string LinkId)? FindGuestLink(string token) + { + if (!IsToken(token)) return null; + using var c = _db.Open(); + using var cmd = c.CreateCommand(); + cmd.CommandText = "SELECT workspace_id, link_id FROM guest_links WHERE token_hash=$h"; + cmd.Parameters.AddWithValue("$h", Hash(token)); + using var r = cmd.ExecuteReader(); + return r.Read() ? (r.GetString(0), r.GetString(1)) : null; + } + + // ------------------------------------------------------------ код входа и сессия окна звонка + + public (string Code, int ExpiresIn) CreateJoinCode(string workspaceId, CrmUser user) + { + var code = RandomToken(); + using var c = _db.Open(); + Exec(c, "DELETE FROM join_codes WHERE expires_at < $t", ("$t", Now)); + Exec(c, "INSERT INTO join_codes(code_hash, workspace_id, user_id, name, email, expires_at) VALUES ($h,$w,$u,$n,$e,$x)", + ("$h", Hash(code)), ("$w", workspaceId), ("$u", user.Id), ("$n", DisplayName(user)), + ("$e", (object?)user.Email ?? DBNull.Value), ("$x", Now + (long)_codeTtl.TotalSeconds)); + return (code, (int)_codeTtl.TotalSeconds); + } + + public sealed record Session(string Token, string Kind, string Name); + + /// Код одноразовый: удаляется при обмене, даже если уже истёк. + public Session? ExchangeCode(string code) + { + if (!IsToken(code)) return null; + using var c = _db.Open(); + using var cmd = c.CreateCommand(); + cmd.CommandText = "DELETE FROM join_codes WHERE code_hash=$h RETURNING workspace_id, user_id, name, email, expires_at"; + cmd.Parameters.AddWithValue("$h", Hash(code)); + string ws, uid, name; string? email; long exp; + using (var r = cmd.ExecuteReader()) + { + if (!r.Read()) return null; + ws = r.GetString(0); uid = r.GetString(1); name = r.GetString(2); + email = r.IsDBNull(3) ? null : r.GetString(3); exp = r.GetInt64(4); + } + if (exp < Now) return null; + return NewSession(c, "member", ws, uid, name, email, null); + } + + public Session? JoinAsGuest(string linkToken, string name) + { + var link = FindGuestLink(linkToken); + if (link == null) return null; + using var c = _db.Open(); + return NewSession(c, "guest", link.Value.WorkspaceId, "guest-" + RandomLower(12), name, null, link.Value.LinkId); + } + + private Session NewSession(SqliteConnection c, string kind, string ws, string uid, string name, string? email, string? linkId) + { + var token = RandomToken(); + Exec(c, "DELETE FROM call_sessions WHERE expires_at < $t", ("$t", Now)); + Exec(c, """ + INSERT INTO call_sessions(session_hash, kind, workspace_id, user_id, name, email, link_id, created_at, expires_at) + VALUES ($h,$k,$w,$u,$n,$e,$l,$t,$x) + """, + ("$h", Hash(token)), ("$k", kind), ("$w", ws), ("$u", uid), ("$n", name), + ("$e", (object?)email ?? DBNull.Value), ("$l", (object?)linkId ?? DBNull.Value), + ("$t", Now), ("$x", Now + (long)_sessionTtl.TotalSeconds)); + return new Session(token, kind, name); + } + + public sealed record PassResult(string Jwt, long Exp, string Room, string Domain, string Name, string Kind); + + /// + /// Новый пропуск Jitsi по сессии окна звонка. Участник workspace — модератор, гость — нет. + /// Гостевая сессия живёт, пока жива сессия: перегенерация ссылки не выкидывает уже вошедших гостей. + /// + public PassResult? IssuePass(string session) + { + if (!IsToken(session)) return null; + using var c = _db.Open(); + using var cmd = c.CreateCommand(); + cmd.CommandText = "SELECT kind, workspace_id, user_id, name, email, expires_at FROM call_sessions WHERE session_hash=$h"; + cmd.Parameters.AddWithValue("$h", Hash(session)); + string kind, ws, uid, name; string? email; + using (var r = cmd.ExecuteReader()) + { + if (!r.Read() || r.GetInt64(5) < Now) return null; + kind = r.GetString(0); ws = r.GetString(1); uid = r.GetString(2); name = r.GetString(3); + email = r.IsDBNull(4) ? null : r.GetString(4); + } + var room = Room(ws, uid); + var pass = _jitsi.Issue(room, uid, name, email, moderator: kind == "member", _time.GetUtcNow()); + return new PassResult(pass.Jwt, pass.Exp, room, _jitsi.Domain, name, kind); + } + + // ------------------------------------------------------------ helpers + + public static string DisplayName(CrmUser u) => + !string.IsNullOrWhiteSpace(u.Name) ? u.Name.Trim() : !string.IsNullOrWhiteSpace(u.Email) ? u.Email.Trim() : "Користувач CRM"; + + /// Имя гостя: 1–60 символов без управляющих. + public static string? CleanGuestName(string? name) + { + if (name == null) return null; + var s = new string(name.Where(ch => !char.IsControl(ch)).ToArray()).Trim(); + while (s.Contains(" ")) s = s.Replace(" ", " "); + if (s.Length == 0) return null; + return s.Length > 60 ? s[..60].Trim() : s; + } + + private static bool IsToken(string? s) => s is { Length: >= 20 and <= 100 } && s.All(ch => char.IsAsciiLetterOrDigit(ch) || ch is '-' or '_'); + + private static string RandomToken() => JitsiToken.B64(RandomNumberGenerator.GetBytes(32)); + + private static string RandomLower(int n) => RandomNumberGenerator.GetString("abcdefghijklmnopqrstuvwxyz0123456789", n); + + private static string Hash(string s) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(s))); + + private static string? Scalar(SqliteConnection c, string sql, params (string, object)[] ps) + { + using var cmd = c.CreateCommand(); + cmd.CommandText = sql; + foreach (var (k, v) in ps) cmd.Parameters.AddWithValue(k, v); + return cmd.ExecuteScalar() as string; + } + + private static void Exec(SqliteConnection c, string sql, params (string, object)[] ps) + { + using var cmd = c.CreateCommand(); + cmd.CommandText = sql; + foreach (var (k, v) in ps) cmd.Parameters.AddWithValue(k, v); + cmd.ExecuteNonQuery(); + } +} diff --git a/calls/frontend/.oxlintrc.json b/calls/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/calls/frontend/.oxlintrc.json @@ -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 }] + } +} diff --git a/calls/frontend/index.html b/calls/frontend/index.html new file mode 100644 index 0000000..38d06c4 --- /dev/null +++ b/calls/frontend/index.html @@ -0,0 +1,18 @@ + + + + + + +Дзвінки · CRM Tvorymo + + + + + + + + + + + diff --git a/calls/frontend/package-lock.json b/calls/frontend/package-lock.json new file mode 100644 index 0000000..4a2ac3b --- /dev/null +++ b/calls/frontend/package-lock.json @@ -0,0 +1,1295 @@ +{ + "name": "crm-calls-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "crm-calls-frontend", + "version": "0.1.0", + "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" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.154.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", + "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.88.0.tgz", + "integrity": "sha512-k6Fgkf86ubfeH4GvqYsRolfuVAp0pfYVZvifSlXNHPdMaWUOfxUrmLEeDD7hF6U2oJuhsVxhSWq86/xCJ+9MmA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.88.0.tgz", + "integrity": "sha512-AsIyhl9o1ejwtac7NMbhKpMdSml6NWR0ljKjZERxC8OHYDAW8o/Om9n1QHVLi4VCEUo42Zte2/Op9kyoGpJZMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.88.0.tgz", + "integrity": "sha512-qgBjB7eEKcCB4RqjXGuoSZq74g0wD09fWFJ+HWcCJcKaga8TeUHtb+BY0SsxdleSLBgx4RZ7qmgo0EzqEq7JoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.88.0.tgz", + "integrity": "sha512-sCz3X5Mw/xdzEzn4fsCt1Iq8NKJnGNfhR+b9B5k+4DysYAHsmIhYoocnBPiQ9kvY3+pUun2A0Z8Vtun/Nv6YoQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.88.0.tgz", + "integrity": "sha512-xTOdYOIqCufgIj0knvxu86FBfovP6QBiD7ngNva01gb8xPQCD5gXkE5APGczH35zStim1O3zqvFbLQmY85xXRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.88.0.tgz", + "integrity": "sha512-BLadtw88NdYJBrAtC1dYzrPfSP2d5zgx6iuZycdD0YmZBJ2Nqbit9Z4PSaIb6gxK25dweArGMPbOmq/0ugwUPg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.88.0.tgz", + "integrity": "sha512-wILSczl/DaZsCcf7j7tvNbssp8hxEHIJxrv4j7+JjI3TAL1U/JTWbeUtoJJC/vo8+2hkTmHLYoKJjmiNbijdXw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.88.0.tgz", + "integrity": "sha512-jutT2Hy8JCuZuMwIFHo2HtyxfOfBJHyQxlBHBK1SL9W2f8n/WhjAP0FnDMz9d/mjhv9+QA0mx5AdQ9fDjsrFFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.88.0.tgz", + "integrity": "sha512-8N8iGV9hh4Lkeuq7QsEkjoXNRRflqR1G2qyoGYXcA0k5qllrdqmfylKEXyHIVERxqNDyMO4StqDQ0uLxXcBQAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.88.0.tgz", + "integrity": "sha512-5ydcQt3znNQ/rGEWYRvpS2cK9+T18P2VYgnyJ+eloEAwi9YUmsXy5VGrRqNWIK6qzWwHGnEE3ojVqqQNo+f0hA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.88.0.tgz", + "integrity": "sha512-iGx0uvFT54b/5DPJdEBCxYIrVB3v0uTy+JhRh3CyEp8/22BXDkG8rffkFwREdLsdj9mOrPbJYVOZw8ipcBuzjw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.88.0.tgz", + "integrity": "sha512-Wt47mKMWelVjRjuR7BCFxXkB0m79K1sTEbqMHRWciKUgo5iv7sWsF4RXSOyFYHD9zerNrkRZblnN273rhwOA1Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.88.0.tgz", + "integrity": "sha512-g3L12KCS4+1gtUt3RbJ4F+Yxx4LNmmZLEPfhd8rAHEJjJk2lAgNfnjGoP/phJyhG5WS6VbNYbJtadJelNfvTMA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.88.0.tgz", + "integrity": "sha512-og/XHVDOD9ZJgRiZkT9DgzQd4sr+JZW7e4l17Q/jVMZiIbeEd4uIu9iAoUbTF1gIvVZoRc4QDOxyVUvQGp4WHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.88.0.tgz", + "integrity": "sha512-EmhBsp+xuKb6YbZNEMLBo80Xw71AZOIm18do8ni/GLX/Lw1XzNhVYpbxO1KasYtoAvrGtCOdCPULRlIE0bPbTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.88.0.tgz", + "integrity": "sha512-DBUcx/KwYdg1DAz+G01P/GFv512J04EsZadbHptx2FTpWT/MWRg21ZToNHt9pnsfQRBQ3et/g+vqzbvrdAVbuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.88.0.tgz", + "integrity": "sha512-xeVd0n4XG5HvynxfYvXi6uB+JvZbVZataowPS8cYpMbYg9mwE4sSyElFQd4LVJFifq8bRLDSR18XgEildmcXAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.88.0.tgz", + "integrity": "sha512-MA6iOw//amleaz2mVMsZrA7NHlEexjTrhIxSMFCXQi6IUuQO+3Od00GZhr2iCYw6RFzMW36jkKK/WHBXMsoQAw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.88.0.tgz", + "integrity": "sha512-3Sz6rVWe8Dy+ufsXurFN1GX2dX43F/lQMSLelK9gFjiba7nHvLlqlhR5cHlWVnCpSqUY1hN9pyCX9XToe6ve6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", + "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", + "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", + "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", + "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", + "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", + "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", + "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", + "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", + "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", + "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", + "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", + "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", + "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", + "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", + "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", + "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.152.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.88.0.tgz", + "integrity": "sha512-uIcFgw8BmqvFRGzH5+r7d2zJMEexwNSrEmM6WQNnGh3VkO0XYTEFoyxyNE6tjKMUib9Cue4XJA2DoWruO5D6aw==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.88.0", + "@oxlint/binding-android-arm64": "1.88.0", + "@oxlint/binding-darwin-arm64": "1.88.0", + "@oxlint/binding-darwin-x64": "1.88.0", + "@oxlint/binding-freebsd-x64": "1.88.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.88.0", + "@oxlint/binding-linux-arm-musleabihf": "1.88.0", + "@oxlint/binding-linux-arm64-gnu": "1.88.0", + "@oxlint/binding-linux-arm64-musl": "1.88.0", + "@oxlint/binding-linux-ppc64-gnu": "1.88.0", + "@oxlint/binding-linux-riscv64-gnu": "1.88.0", + "@oxlint/binding-linux-riscv64-musl": "1.88.0", + "@oxlint/binding-linux-s390x-gnu": "1.88.0", + "@oxlint/binding-linux-x64-gnu": "1.88.0", + "@oxlint/binding-linux-x64-musl": "1.88.0", + "@oxlint/binding-openharmony-arm64": "1.88.0", + "@oxlint/binding-win32-arm64-msvc": "1.88.0", + "@oxlint/binding-win32-ia32-msvc": "1.88.0", + "@oxlint/binding-win32-x64-msvc": "1.88.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2003", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", + "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.1" + } + }, + "node_modules/rolldown": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", + "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.154.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.14", + "@rolldown/binding-android-arm64": "1.2.14", + "@rolldown/binding-darwin-arm64": "1.2.14", + "@rolldown/binding-darwin-x64": "1.2.14", + "@rolldown/binding-freebsd-x64": "1.2.14", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", + "@rolldown/binding-linux-arm64-gnu": "1.2.14", + "@rolldown/binding-linux-arm64-musl": "1.2.14", + "@rolldown/binding-linux-ppc64-gnu": "1.2.14", + "@rolldown/binding-linux-s390x-gnu": "1.2.14", + "@rolldown/binding-linux-x64-gnu": "1.2.14", + "@rolldown/binding-linux-x64-musl": "1.2.14", + "@rolldown/binding-openharmony-arm64": "1.2.14", + "@rolldown/binding-win32-arm64-msvc": "1.2.14", + "@rolldown/binding-win32-x64-msvc": "1.2.14" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/calls/frontend/package.json b/calls/frontend/package.json new file mode 100644 index 0000000..e4cb1f6 --- /dev/null +++ b/calls/frontend/package.json @@ -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" + } +} diff --git a/calls/frontend/src/App.tsx b/calls/frontend/src/App.tsx new file mode 100644 index 0000000..e6ef22b --- /dev/null +++ b/calls/frontend/src/App.tsx @@ -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 ( + + ДзвінкиВідеодзвінки CRM Tvorymo + {text} + {a.status === "waiting" && late && + CRM не надіслала авторизацію. Перевірте, що для цього посилання в бічній панелі CRM увімкнено галочку + «Передавати авторизацію». + Надіслати запит ще раз + Батьківська сторінка: {d.parentOrigin ?? "невідомо"} · дозволені: {d.allowed.join(", ") || "—"} · + повідомлень отримано: {d.received.length} + } + + + ); +} + +/** Выбор workspace, если CRM его не прислала. */ +function Picker(props: { onPick: (w: Workspace) => void; current?: string }) { + const [list, setList] = useState(null); + const [err, setErr] = useState(""); + useEffect(() => { api.workspaces().then((r) => setList(r.workspaces)).catch((e) => setErr((e as Error).message)); }, []); + if (err) return {err}; + if (!list) return Завантаження воркспейсів…; + return ( + + Оберіть воркспейсКожен воркспейс має свою кімнату для дзвінків. + + {list.map((w) => ( + props.onPick(w)}> + {w.title}{w.kind === "personal" ? "Бачите лише ви" : w.kind === "shared" ? "Усі користувачі CRM" : "Воркспейс на вибраних людей"} + + + ))} + + + ); +} + +function Room(props: { ws: string }) { + const { ws } = props; + const [link, setLink] = useState(null); + const [err, setErr] = useState(""); + const [joinErr, setJoinErr] = useState(""); + const [fallback, setFallback] = useState(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 {err}; + return ( + <> + + Кімната воркспейсуПостійна кімната: заходьте будь-коли, учасники воркспейсу — модератори. + + void join()}>Приєднатися + + {joinErr && {joinErr}} + {fallback && + Браузер не відкрив нове вікно. Відкрити дзвінок (посилання діє хвилину). + } + + + Гостьове посилання + + Для людей без акаунта CRMЗа посиланням можна увійти в кімнату цього воркспейсу без прав модератора. + {!link ? Завантаження… : <> + e.target.select()} aria-label="Гостьове посилання" /> + + + setConfirm(true)}>Нове посилання + + + Оновлено {new Date(link.updatedAt).toLocaleString("uk-UA")}{link.updatedByName ? " · " + link.updatedByName : ""} + + >} + + {confirm && Старе посилання одразу перестане пускати нових гостей. Тим, хто вже в дзвінку, це не завадить.>} + ok="Створити нове" onOk={regenerate} onClose={() => setConfirm(false)} />} + > + ); +} + +export default function App() { + const a = useAuth(); + const uid = a.user?.id; + const [picked, setPicked] = useState(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 ; + const ws = a.crmWorkspace ? { id: a.crmWorkspace.id, title: a.crmWorkspace.title ?? a.crmWorkspace.id } : picked; + const fromCrm = !!a.crmWorkspace; + + return ( + + + Дзвінки + {ws ? <>Воркспейс: {ws.title}> : "Оберіть воркспейс"} + {a.user && <> · {a.user.name ?? a.user.email}>} + + {!fromCrm && ws && !picking && setPicking(true)}>Змінити воркспейс} + + {(!ws || picking) ? : } + + ); +} diff --git a/calls/frontend/src/Call.tsx b/calls/frontend/src/Call.tsx new file mode 100644 index 0000000..3b4f91e --- /dev/null +++ b/calls/frontend/src/Call.tsx @@ -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) => 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 | null = null; +function loadExternalApi(domain: string): Promise { + 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(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 ( + + + {phase !== "in" && ( + + Дзвінок + {phase === "loading" && Підключення…} + {phase === "retry" && Перепідключення… ({msg})} + {phase === "ended" && + Ви вийшли з дзвінка. + + restart.current()}>Повернутися до дзвінка + window.close()}>Закрити вікно + + } + {phase === "error" && + Не вдалося підключитися: {msg} + restart.current()}>Спробувати ще раз + } + + )} + + ); +} + +/** Телефон: «Відкрити в додатку» (Jitsi Meet) или «Продовжити в браузері». */ +export function MobileChoice(props: { session: string; onBrowser: () => void; onSessionLost: (msg: string) => void }) { + const [cfg, setCfg] = useState(null); + const [link, setLink] = useState(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 ( + + ДзвінокЯк приєднатися з телефона? + + + void openApp()}>Відкрити в додатку + Продовжити в браузері + + {err && {err}} + {link && + Якщо додаток не відкрився: відкрити Jitsi Meet (посилання діє 15 хвилин). + } + + Немає додатка Jitsi Meet? Встановіть його з{" "} + {stores[0][0]} або{" "} + {stores[1][0]} і натисніть «Відкрити в додатку» ще раз. + + Якщо зв'язок пропав надовго, додаток сам нового пропуску не візьме: поверніться сюди й натисніть кнопку ще раз. + + + ); +} + +/** Сессия окна → телефон: выбор; компьютер: сразу звонок в браузере. */ +export function CallWithSession(props: { session: string; onSessionLost: (msg: string) => void }) { + const [browser, setBrowser] = useState(!isMobile()); + return browser + ? + : setBrowser(true)} onSessionLost={props.onSessionLost} />; +} + +const SESSION_KEY = "calls_session_v1"; + +/** /call?code=… — обмен одноразового кода на сессию окна. Код сразу убираем из адреса. */ +export function CallPage() { + const [session, setSession] = useState(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 ; + return ( + + Дзвінок + {err ? {err} : Підключення…} + + ); +} diff --git a/calls/frontend/src/CopyButton.tsx b/calls/frontend/src/CopyButton.tsx new file mode 100644 index 0000000..c47a03e --- /dev/null +++ b/calls/frontend/src/CopyButton.tsx @@ -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(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 ( + <> + void click()} aria-live="polite"> + {done ? "Скопійовано" : props.label} + + {manual && setManual(false)} />} + > + ); +} diff --git a/calls/frontend/src/Dialogs.tsx b/calls/frontend/src/Dialogs.tsx new file mode 100644 index 0000000..7b459f1 --- /dev/null +++ b/calls/frontend/src/Dialogs.tsx @@ -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 ( + + + + + + {props.title} + + + {props.children} + {props.footer && {props.footer}} + + + ); +} + +export function ConfirmDialog(props: { + title: string; text: ReactNode; ok: string; danger?: boolean; + onOk: () => Promise | 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 ( + + + Скасувати + {props.ok} + >}> + {props.text} + {err && {err}} + + ); +} + +export function ManualCopyDialog(props: { text: string; onClose: () => void }) { + const ref = useRef(null); + useEffect(() => { ref.current?.focus(); ref.current?.select(); }, []); + return ( + + + Готово + >}> + + Скопіюйте вручну (Ctrl+C) + + e.target.select()} onClick={(e) => e.currentTarget.select()} /> + + Автоматично скопіювати не вдалося: браузер не дав доступу до буфера обміну. + + + ); +} diff --git a/calls/frontend/src/Guest.tsx b/calls/frontend/src/Guest.tsx new file mode 100644 index 0000000..3b2413f --- /dev/null +++ b/calls/frontend/src/Guest.tsx @@ -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(() => 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 ; + return ( + + ДзвінокЗапрошення від CRM Tvorymo + {state === "checking" && Перевірка посилання…} + {state === "bad" && Посилання недійсне або застаріло.Попросіть того, хто вас запросив, надіслати нове посилання.} + {state === "ok" && + + Ваше ім'я + + setName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") void join(); }} /> + + {err && {err}} + + + void join()}>Приєднатися + + } + + ); +} diff --git a/calls/frontend/src/Icon.tsx b/calls/frontend/src/Icon.tsx new file mode 100644 index 0000000..eb752e0 --- /dev/null +++ b/calls/frontend/src/Icon.tsx @@ -0,0 +1,20 @@ +// Иконки по правилам tvorymo-ui.css: , обводка currentColor. +const P: Record = { + 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 ( + + + + ); +} diff --git a/calls/frontend/src/api.ts b/calls/frontend/src/api.ts new file mode 100644 index 0000000..1b4a7f1 --- /dev/null +++ b/calls/frontend/src/api.ts @@ -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(r: Response): Promise { + 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(method: string, path: string, ws?: string): Promise { + const go = async (force: boolean) => { + const headers: Record = { 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(r); +} + +async function post(path: string, data: unknown): Promise { + return body(await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) })); +} + +export const api = { + config: async () => body(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("/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 }), +}; diff --git a/calls/frontend/src/calls.css b/calls/frontend/src/calls.css new file mode 100644 index 0000000..46aeef7 --- /dev/null +++ b/calls/frontend/src/calls.css @@ -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)} diff --git a/calls/frontend/src/clipboard.ts b/calls/frontend/src/clipboard.ts new file mode 100644 index 0000000..828a4f2 --- /dev/null +++ b/calls/frontend/src/clipboard.ts @@ -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 { + // 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); +} diff --git a/calls/frontend/src/crm.ts b/calls/frontend/src/crm.ts new file mode 100644 index 0000000..88ffa80 --- /dev/null +++ b/calls/frontend/src/crm.ts @@ -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: | 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) { + state = { ...state, ...p }; + subs.forEach((f) => f()); +} +function setDiag(p: Partial) { + 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) { + 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 { + 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 { + 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; +} diff --git a/calls/frontend/src/main.tsx b/calls/frontend/src/main.tsx new file mode 100644 index 0000000..19737b1 --- /dev/null +++ b/calls/frontend/src/main.tsx @@ -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 = ; +else if (guest) page = ; +else { void initCrm(); page = ; } + +createRoot(document.getElementById("root")!).render({page}); diff --git a/calls/frontend/tsconfig.app.json b/calls/frontend/tsconfig.app.json new file mode 100644 index 0000000..6830b6f --- /dev/null +++ b/calls/frontend/tsconfig.app.json @@ -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"] +} diff --git a/calls/frontend/tsconfig.json b/calls/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/calls/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/calls/frontend/tsconfig.node.json b/calls/frontend/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/calls/frontend/tsconfig.node.json @@ -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"] +} diff --git a/calls/frontend/vite.config.ts b/calls/frontend/vite.config.ts new file mode 100644 index 0000000..c6ec497 --- /dev/null +++ b/calls/frontend/vite.config.ts @@ -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' } }, +}) diff --git a/docker-compose.calls.yml b/docker-compose.calls.yml new file mode 100644 index 0000000..bcc8dcd --- /dev/null +++ b/docker-compose.calls.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index 637e3ff..8f203eb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,12 +2,16 @@ # Deployed by Coolify (build_pack: dockercompose) on the Volcanic VPS (164.90.233.156). # # 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 # /xmpp-websocket -> prosody:5280 (proxied by web's nginx) # 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: web: @@ -24,7 +28,7 @@ services: - jitsi-web-storage:/storage environment: 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' ENABLE_HTTP_REDIRECT: '0' ENABLE_LETSENCRYPT: '0' @@ -37,9 +41,13 @@ services: XMPP_MUC_DOMAIN: muc.meet.jitsi XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi DEFAULT_LANGUAGE: '${DEFAULT_LANGUAGE:-uk}' - # Embedding into wiki/CRM via iframe: allow *.tes.gd and test.units.tvorymo.com. - # Jitsi's nginx sets no X-Frame-Options; this CSP is the only framing policy. - CSP_HEADER: "frame-ancestors 'self' https://*.tes.gd https://test.units.tvorymo.com" + ENABLE_AUTH: '1' + AUTH_TYPE: jwt + 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: - jvb @@ -60,7 +68,7 @@ services: - jitsi-prosody-data:/var/lib/prosody environment: 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_DOMAIN: meet.jitsi XMPP_AUTH_DOMAIN: auth.meet.jitsi @@ -70,6 +78,16 @@ services: ENABLE_XMPP_WEBSOCKET: '1' JICOFO_AUTH_PASSWORD: '${JICOFO_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: image: ghcr.io/jitsi/jicofo:${JITSI_IMAGE_VERSION:-stable-11248} @@ -88,6 +106,10 @@ services: XMPP_MUC_DOMAIN: muc.meet.jitsi XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi 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: - prosody @@ -106,7 +128,7 @@ services: - jitsi-jvb-config:/config environment: 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_DOMAIN: meet.jitsi XMPP_AUTH_DOMAIN: auth.meet.jitsi