Модуль «Дзвінки» (ASP.NET Core + React + SQLite) и JWT на Jitsi (meet.volcanic.tes.gd)
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,2 +1,8 @@
|
|||||||
deploy.json
|
deploy.json
|
||||||
.env
|
.env
|
||||||
|
**/bin/
|
||||||
|
**/obj/
|
||||||
|
**/node_modules/
|
||||||
|
**/dist/
|
||||||
|
calls/backend/Calls.Api/data/
|
||||||
|
calls/backend/Calls.Api/wwwroot/
|
||||||
|
|||||||
7
calls/.dockerignore
Normal file
7
calls/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
**/bin/
|
||||||
|
**/obj/
|
||||||
|
**/node_modules/
|
||||||
|
**/dist/
|
||||||
|
**/wwwroot/
|
||||||
|
backend/Calls.Api.Tests/
|
||||||
|
data/
|
||||||
35
calls/Dockerfile
Normal file
35
calls/Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
# 1. Фронтенд
|
||||||
|
FROM node:22-alpine AS web
|
||||||
|
WORKDIR /src
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci --no-audit --no-fund
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# 2. Бэкенд
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS api
|
||||||
|
WORKDIR /src
|
||||||
|
COPY backend/Calls.Api/Calls.Api.csproj backend/Calls.Api/
|
||||||
|
RUN dotnet restore backend/Calls.Api/Calls.Api.csproj
|
||||||
|
COPY backend/Calls.Api/ backend/Calls.Api/
|
||||||
|
RUN dotnet publish backend/Calls.Api/Calls.Api.csproj -c Release -o /out --no-restore /p:UseAppHost=false
|
||||||
|
|
||||||
|
# 3. Runtime
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=api /out ./
|
||||||
|
COPY --from=web /src/dist ./wwwroot
|
||||||
|
# /data — постоянный том Coolify (SQLite)
|
||||||
|
RUN mkdir -p /data
|
||||||
|
ENV ASPNETCORE_ENVIRONMENT=Production \
|
||||||
|
ASPNETCORE_URLS=http://+:8080 \
|
||||||
|
Storage__DataDir=/data
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
|
CMD curl -fsS http://127.0.0.1:8080/health || exit 1
|
||||||
|
ENTRYPOINT ["dotnet", "Calls.Api.dll"]
|
||||||
193
calls/backend/Calls.Api.Tests/ApiTests.cs
Normal file
193
calls/backend/Calls.Api.Tests/ApiTests.cs
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.AspNetCore.TestHost;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Calls.Api.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTTP API с подменённым Supabase: два пользователя, у каждого свой custom-workspace (RLS).
|
||||||
|
/// Проверка 2 спецификации на уровне API: пользователь другого workspace в комнату не попадает.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ApiTests : IClassFixture<ApiTests.App>
|
||||||
|
{
|
||||||
|
public const string Anna = "tok-anna", Carl = "tok-carl";
|
||||||
|
public const string AnnaUid = "11111111-1111-1111-1111-111111111111", CarlUid = "22222222-2222-2222-2222-222222222222";
|
||||||
|
private readonly App _app;
|
||||||
|
|
||||||
|
public ApiTests(App app) => _app = app;
|
||||||
|
|
||||||
|
/// <summary>Фальшивый Supabase: /auth/v1/user по токену, /rest/v1/workspaces с «RLS».</summary>
|
||||||
|
public sealed class FakeSupabase : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<string, (string Uid, string Email, string Name, string[] Ws)> Users = new()
|
||||||
|
{
|
||||||
|
[Anna] = (AnnaUid, "anna@test", "Анна", ["wsteam0000001"]),
|
||||||
|
[Carl] = (CarlUid, "carl@test", "Карл", ["wsother000001"]),
|
||||||
|
};
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var bearer = req.Headers.Authorization?.Parameter ?? "";
|
||||||
|
var key = Users.Keys.FirstOrDefault(k => bearer.EndsWith("." + k));
|
||||||
|
if (key == null) return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized));
|
||||||
|
var u = Users[key];
|
||||||
|
var path = req.RequestUri!.AbsolutePath;
|
||||||
|
string body;
|
||||||
|
if (path == "/auth/v1/user")
|
||||||
|
body = JsonSerializer.Serialize(new { id = u.Uid, aud = "authenticated", email = u.Email, user_metadata = new { name = u.Name } });
|
||||||
|
else if (path == "/rest/v1/workspaces")
|
||||||
|
{
|
||||||
|
var q = System.Web.HttpUtility.ParseQueryString(req.RequestUri.Query);
|
||||||
|
var id = q["id"]?.Replace("eq.", "");
|
||||||
|
var rows = u.Ws.Where(w => id == null || w == id)
|
||||||
|
.Select(w => new { id = w, kind = "custom", title = "Команда " + w[^1], owner_uid = u.Uid, members = Array.Empty<string>() });
|
||||||
|
body = JsonSerializer.Serialize(rows);
|
||||||
|
}
|
||||||
|
else return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
|
||||||
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class App : WebApplicationFactory<Program>
|
||||||
|
{
|
||||||
|
public readonly string Dir = Path.Combine(Path.GetTempPath(), "calls-api-" + Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
protected override void ConfigureWebHost(IWebHostBuilder b)
|
||||||
|
{
|
||||||
|
b.UseSetting("Storage:DataDir", Dir);
|
||||||
|
b.UseSetting("Jitsi:AppSecret", StoreTests.Secret);
|
||||||
|
b.UseSetting("Jitsi:Domain", "meet.test");
|
||||||
|
b.UseSetting("Calls:LinkSecret", "test-link-secret-0123456789abcdef0123456789");
|
||||||
|
b.UseSetting("Calls:PublicUrl", "https://calls.test");
|
||||||
|
b.UseSetting("Supabase:Url", "https://supabase.test");
|
||||||
|
b.UseSetting("Supabase:PublishableKey", "sb_publishable_test");
|
||||||
|
b.UseSetting("Crm:Origins", "https://crm.test");
|
||||||
|
b.ConfigureTestServices(s => s.AddHttpClient("supabase").ConfigurePrimaryHttpMessageHandler(() => new FakeSupabase()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Похожий на Supabase JWT: exp через час, aud = authenticated; подпись фальшивому Supabase не важна.</summary>
|
||||||
|
public static string Jwt(string who)
|
||||||
|
{
|
||||||
|
static string B(string s) => Convert.ToBase64String(Encoding.UTF8.GetBytes(s)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||||
|
var exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds();
|
||||||
|
return B("{\"alg\":\"HS256\"}") + "." + B($"{{\"exp\":{exp},\"aud\":\"authenticated\"}}") + "." + who;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClient Client(string? who, string? ws)
|
||||||
|
{
|
||||||
|
var c = _app.CreateClient();
|
||||||
|
if (who != null) c.DefaultRequestHeaders.Authorization = new("Bearer", Jwt(who));
|
||||||
|
if (ws != null) c.DefaultRequestHeaders.Add("X-Calls-Workspace", ws);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<JsonElement> Json(HttpResponseMessage r) => JsonDocument.Parse(await r.Content.ReadAsStringAsync()).RootElement;
|
||||||
|
|
||||||
|
private async Task<JsonElement> PassFor(string session)
|
||||||
|
{
|
||||||
|
var r = await _app.CreateClient().PostAsJsonAsync("/api/call/pass", new { session });
|
||||||
|
Assert.Equal(HttpStatusCode.OK, r.StatusCode);
|
||||||
|
return await Json(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Member_JoinsOwnWorkspaceRoom_AsModerator()
|
||||||
|
{
|
||||||
|
var r = await Client(Anna, "wsteam0000001").PostAsync("/api/join", null);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, r.StatusCode);
|
||||||
|
var join = await Json(r);
|
||||||
|
Assert.StartsWith("https://calls.test/call?code=", join.GetProperty("url").GetString());
|
||||||
|
var ex = await _app.CreateClient().PostAsJsonAsync("/api/call/exchange", new { code = join.GetProperty("code").GetString() });
|
||||||
|
Assert.Equal(HttpStatusCode.OK, ex.StatusCode);
|
||||||
|
var pass = await PassFor((await Json(ex)).GetProperty("session").GetString()!);
|
||||||
|
Assert.Equal("member", pass.GetProperty("kind").GetString());
|
||||||
|
var claims = StoreTests.VerifyHs256(pass.GetProperty("jwt").GetString()!);
|
||||||
|
Assert.True(claims.GetProperty("context").GetProperty("user").GetProperty("moderator").GetBoolean());
|
||||||
|
Assert.Equal("Анна", claims.GetProperty("context").GetProperty("user").GetProperty("name").GetString());
|
||||||
|
// код одноразовый
|
||||||
|
var again = await _app.CreateClient().PostAsJsonAsync("/api/call/exchange", new { code = join.GetProperty("code").GetString() });
|
||||||
|
Assert.Equal(HttpStatusCode.Gone, again.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("wsteam0000001")] // custom-workspace Анны
|
||||||
|
[InlineData("personal:" + AnnaUid)] // личный Анны
|
||||||
|
public async Task OtherUser_CannotEnterSomeoneElsesWorkspace(string ws)
|
||||||
|
{
|
||||||
|
var c = Client(Carl, ws);
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, (await c.PostAsync("/api/join", null)).StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, (await c.GetAsync("/api/room")).StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, (await c.PostAsync("/api/room/guest-link", null)).StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Shared_IsOpenToAllCrmUsers_SameRoom()
|
||||||
|
{
|
||||||
|
async Task<string> RoomOf(string who)
|
||||||
|
{
|
||||||
|
var j = await Json(await Client(who, "shared").PostAsync("/api/join", null));
|
||||||
|
var ex = await Json(await _app.CreateClient().PostAsJsonAsync("/api/call/exchange", new { code = j.GetProperty("code").GetString() }));
|
||||||
|
return (await PassFor(ex.GetProperty("session").GetString()!)).GetProperty("room").GetString()!;
|
||||||
|
}
|
||||||
|
Assert.Equal(await RoomOf(Anna), await RoomOf(Carl));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NoOrBadToken_401()
|
||||||
|
{
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, (await Client(null, "shared").PostAsync("/api/join", null)).StatusCode);
|
||||||
|
var c = _app.CreateClient();
|
||||||
|
c.DefaultRequestHeaders.Authorization = new("Bearer", Jwt("tok-nobody"));
|
||||||
|
c.DefaultRequestHeaders.Add("X-Calls-Workspace", "shared");
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, (await c.PostAsync("/api/join", null)).StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Workspaces_ListMineOnly()
|
||||||
|
{
|
||||||
|
var j = await Json(await Client(Anna, null).GetAsync("/api/workspaces"));
|
||||||
|
var ids = j.GetProperty("workspaces").EnumerateArray().Select(w => w.GetProperty("id").GetString()!).ToArray();
|
||||||
|
Assert.Equal(["personal:" + AnnaUid, "shared", "wsteam0000001"], ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Guest_ByLink_NotModerator_RegenerateKillsOldLink()
|
||||||
|
{
|
||||||
|
var room = await Json(await Client(Anna, "wsteam0000001").GetAsync("/api/room"));
|
||||||
|
var url = room.GetProperty("guestLink").GetProperty("url").GetString()!;
|
||||||
|
Assert.StartsWith("https://calls.test/g/", url);
|
||||||
|
var token = url[(url.LastIndexOf('/') + 1)..];
|
||||||
|
var anon = _app.CreateClient();
|
||||||
|
Assert.Equal(HttpStatusCode.OK, (await anon.GetAsync($"/api/guest/{token}")).StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, (await anon.PostAsJsonAsync($"/api/guest/{token}/join", new { name = " " })).StatusCode);
|
||||||
|
var g = await Json(await anon.PostAsJsonAsync($"/api/guest/{token}/join", new { name = "Гість" }));
|
||||||
|
var pass = await PassFor(g.GetProperty("session").GetString()!);
|
||||||
|
Assert.Equal("guest", pass.GetProperty("kind").GetString());
|
||||||
|
Assert.False(StoreTests.VerifyHs256(pass.GetProperty("jwt").GetString()!).GetProperty("context").GetProperty("user").GetProperty("moderator").GetBoolean());
|
||||||
|
|
||||||
|
var regen = await Json(await Client(Anna, "wsteam0000001").PostAsync("/api/room/guest-link", null));
|
||||||
|
var newUrl = regen.GetProperty("guestLink").GetProperty("url").GetString()!;
|
||||||
|
Assert.NotEqual(url, newUrl);
|
||||||
|
Assert.Equal("Анна", regen.GetProperty("guestLink").GetProperty("updatedByName").GetString());
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await anon.GetAsync($"/api/guest/{token}")).StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await anon.PostAsJsonAsync($"/api/guest/{token}/join", new { name = "Гість" })).StatusCode);
|
||||||
|
var newToken = newUrl[(newUrl.LastIndexOf('/') + 1)..];
|
||||||
|
Assert.Equal(HttpStatusCode.OK, (await anon.PostAsJsonAsync($"/api/guest/{newToken}/join", new { name = "Гість" })).StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Headers_FrameAncestorsCrmOnly_NoXFrameOptions()
|
||||||
|
{
|
||||||
|
var r = await _app.CreateClient().GetAsync("/api/config");
|
||||||
|
var csp = r.Headers.GetValues("Content-Security-Policy").Single();
|
||||||
|
Assert.Contains("frame-ancestors https://crm.test;", csp);
|
||||||
|
Assert.Contains("frame-src https://meet.test", csp);
|
||||||
|
Assert.False(r.Headers.Contains("X-Frame-Options"));
|
||||||
|
}
|
||||||
|
}
|
||||||
26
calls/backend/Calls.Api.Tests/Calls.Api.Tests.csproj
Normal file
26
calls/backend/Calls.Api.Tests/Calls.Api.Tests.csproj
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.10.0" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Calls.Api\Calls.Api.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
174
calls/backend/Calls.Api.Tests/StoreTests.cs
Normal file
174
calls/backend/Calls.Api.Tests/StoreTests.cs
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Time.Testing;
|
||||||
|
|
||||||
|
namespace Calls.Api.Tests;
|
||||||
|
|
||||||
|
/// <summary>Комнаты, гостевые ссылки, одноразовые коды и пропуски Jitsi — на временной базе.</summary>
|
||||||
|
public sealed class StoreTests : IDisposable
|
||||||
|
{
|
||||||
|
public const string Secret = "test-jwt-secret-0123456789abcdef0123456789";
|
||||||
|
private readonly string _dir = Path.Combine(Path.GetTempPath(), "calls-test-" + Guid.NewGuid().ToString("N"));
|
||||||
|
private readonly FakeTimeProvider _time = new(DateTimeOffset.Parse("2026-10-14T12:00:00Z"));
|
||||||
|
private readonly Store _store;
|
||||||
|
private static readonly CrmUser Anna = new("uid-anna", "anna@test", "Анна", 0);
|
||||||
|
private static readonly CrmUser Bob = new("uid-bob", "bob@test", null, 0);
|
||||||
|
|
||||||
|
public StoreTests()
|
||||||
|
{
|
||||||
|
var cfg = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Storage:DataDir"] = _dir,
|
||||||
|
["Jitsi:AppSecret"] = Secret,
|
||||||
|
["Jitsi:Domain"] = "meet.test",
|
||||||
|
["Calls:LinkSecret"] = "test-link-secret-0123456789abcdef0123456789",
|
||||||
|
}).Build();
|
||||||
|
var db = new Db(cfg);
|
||||||
|
db.Init();
|
||||||
|
_store = new Store(db, new JitsiToken(cfg), cfg, _time);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||||
|
try { Directory.Delete(_dir, true); } catch { /* временная папка */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Room_IsStablePerWorkspace_RandomAndWithoutWorkspaceId()
|
||||||
|
{
|
||||||
|
var a1 = _store.Room("wsmv5l7d425re160", Anna.Id);
|
||||||
|
var a2 = _store.Room("wsmv5l7d425re160", Bob.Id);
|
||||||
|
var b = _store.Room("shared", Anna.Id);
|
||||||
|
Assert.Equal(a1, a2);
|
||||||
|
Assert.NotEqual(a1, b);
|
||||||
|
Assert.Matches("^ws-[a-z0-9]{20}$", a1);
|
||||||
|
Assert.DoesNotContain("mv5l7d425re160", a1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JoinCode_IsOneTime()
|
||||||
|
{
|
||||||
|
var (code, ttl) = _store.CreateJoinCode("shared", Anna);
|
||||||
|
Assert.Equal(60, ttl);
|
||||||
|
var s = _store.ExchangeCode(code);
|
||||||
|
Assert.NotNull(s);
|
||||||
|
Assert.Equal("member", s.Kind);
|
||||||
|
Assert.Equal("Анна", s.Name);
|
||||||
|
Assert.Null(_store.ExchangeCode(code));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JoinCode_ExpiresAfter60Seconds()
|
||||||
|
{
|
||||||
|
var (code, _) = _store.CreateJoinCode("shared", Anna);
|
||||||
|
_time.Advance(TimeSpan.FromSeconds(61));
|
||||||
|
Assert.Null(_store.ExchangeCode(code));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pass_ForMember_IsModerator_ForOneRoom_15Minutes_SignedHs256()
|
||||||
|
{
|
||||||
|
var (code, _) = _store.CreateJoinCode("shared", Anna);
|
||||||
|
var s = _store.ExchangeCode(code)!;
|
||||||
|
var p = _store.IssuePass(s.Token)!;
|
||||||
|
Assert.Equal(_store.Room("shared", Anna.Id), p.Room);
|
||||||
|
Assert.Equal("meet.test", p.Domain);
|
||||||
|
var claims = VerifyHs256(p.Jwt);
|
||||||
|
Assert.Equal("tvorymo-crm", claims.GetProperty("iss").GetString());
|
||||||
|
Assert.Equal("jitsi", claims.GetProperty("aud").GetString());
|
||||||
|
Assert.Equal(p.Room, claims.GetProperty("room").GetString());
|
||||||
|
Assert.Equal(_time.GetUtcNow().AddMinutes(15).ToUnixTimeSeconds(), claims.GetProperty("exp").GetInt64());
|
||||||
|
var user = claims.GetProperty("context").GetProperty("user");
|
||||||
|
Assert.True(user.GetProperty("moderator").GetBoolean());
|
||||||
|
Assert.Equal("Анна", user.GetProperty("name").GetString());
|
||||||
|
Assert.Equal("anna@test", user.GetProperty("email").GetString());
|
||||||
|
Assert.Equal(Anna.Id, user.GetProperty("id").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Session_GivesFreshPassLater_UntilSessionExpires()
|
||||||
|
{
|
||||||
|
var (code, _) = _store.CreateJoinCode("shared", Anna);
|
||||||
|
var s = _store.ExchangeCode(code)!;
|
||||||
|
_time.Advance(TimeSpan.FromMinutes(40)); // первый пропуск давно истёк — окно берёт новый
|
||||||
|
var p = _store.IssuePass(s.Token)!;
|
||||||
|
Assert.Equal(_time.GetUtcNow().AddMinutes(15).ToUnixTimeSeconds(), p.Exp);
|
||||||
|
_time.Advance(TimeSpan.FromHours(12));
|
||||||
|
Assert.Null(_store.IssuePass(s.Token));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GuestLink_IsPersistent_GuestIsNotModerator()
|
||||||
|
{
|
||||||
|
var l1 = _store.GetGuestLink("shared", Anna);
|
||||||
|
var l2 = _store.GetGuestLink("shared", Bob);
|
||||||
|
Assert.Equal(l1.Token, l2.Token);
|
||||||
|
var g = _store.JoinAsGuest(l1.Token, "Гість Іван")!;
|
||||||
|
Assert.Equal("guest", g.Kind);
|
||||||
|
var p = _store.IssuePass(g.Token)!;
|
||||||
|
Assert.Equal(_store.Room("shared", Anna.Id), p.Room);
|
||||||
|
var user = VerifyHs256(p.Jwt).GetProperty("context").GetProperty("user");
|
||||||
|
Assert.False(user.GetProperty("moderator").GetBoolean());
|
||||||
|
Assert.Equal("Гість Іван", user.GetProperty("name").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GuestLink_Regenerate_OldStopsWorking_NewWorks_RecordsWho()
|
||||||
|
{
|
||||||
|
var old = _store.GetGuestLink("shared", Anna);
|
||||||
|
var fresh = _store.RegenerateGuestLink("shared", Bob);
|
||||||
|
Assert.NotEqual(old.Token, fresh.Token);
|
||||||
|
Assert.Null(_store.JoinAsGuest(old.Token, "Гість"));
|
||||||
|
Assert.NotNull(_store.JoinAsGuest(fresh.Token, "Гість"));
|
||||||
|
Assert.Equal("uid-bob", fresh.UpdatedBy);
|
||||||
|
Assert.Equal("bob@test", fresh.UpdatedByName); // имени нет — email
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GuestLink_OfOtherWorkspace_LeadsToItsOwnRoom()
|
||||||
|
{
|
||||||
|
var a = _store.GetGuestLink("shared", Anna);
|
||||||
|
var b = _store.GetGuestLink("wsother000000", Anna);
|
||||||
|
var pa = _store.IssuePass(_store.JoinAsGuest(a.Token, "x")!.Token)!;
|
||||||
|
var pb = _store.IssuePass(_store.JoinAsGuest(b.Token, "x")!.Token)!;
|
||||||
|
Assert.NotEqual(pa.Room, pb.Room);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("", null)]
|
||||||
|
[InlineData(" ", null)]
|
||||||
|
[InlineData(" Іван Петренко ", "Іван Петренко")]
|
||||||
|
[InlineData("a\u0000b", "ab")]
|
||||||
|
public void GuestName_IsCleaned(string input, string? expected) => Assert.Equal(expected, Store.CleanGuestName(input));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GuestName_IsCutTo60() => Assert.Equal(60, Store.CleanGuestName(new string('я', 100))!.Length);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Garbage_TokensAreRejected()
|
||||||
|
{
|
||||||
|
Assert.Null(_store.ExchangeCode("x"));
|
||||||
|
Assert.Null(_store.IssuePass("'; DROP TABLE call_sessions; --"));
|
||||||
|
Assert.Null(_store.FindGuestLink(new string('a', 43)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static JsonElement VerifyHs256(string jwt)
|
||||||
|
{
|
||||||
|
var parts = jwt.Split('.');
|
||||||
|
Assert.Equal(3, parts.Length);
|
||||||
|
var header = JsonDocument.Parse(B64(parts[0])).RootElement;
|
||||||
|
Assert.Equal("HS256", header.GetProperty("alg").GetString());
|
||||||
|
var sig = HMACSHA256.HashData(Encoding.UTF8.GetBytes(Secret), Encoding.ASCII.GetBytes(parts[0] + "." + parts[1]));
|
||||||
|
Assert.Equal(JitsiToken.B64(sig), parts[2]);
|
||||||
|
return JsonDocument.Parse(B64(parts[1])).RootElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] B64(string s)
|
||||||
|
{
|
||||||
|
s = s.Replace('-', '+').Replace('_', '/');
|
||||||
|
return Convert.FromBase64String(s.PadRight(s.Length + (4 - s.Length % 4) % 4, '='));
|
||||||
|
}
|
||||||
|
}
|
||||||
181
calls/backend/Calls.Api/Auth.cs
Normal file
181
calls/backend/Calls.Api/Auth.cs
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Calls.Api;
|
||||||
|
|
||||||
|
public sealed record CrmUser(string Id, string? Email, string? Name, long Exp);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Проверка токена CRM (Supabase access_token) и доступа к workspace.
|
||||||
|
/// Токены не логируются и не хранятся: в кэше ключ — SHA-256 токена.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class SupabaseAuth(IHttpClientFactory http, IConfiguration cfg)
|
||||||
|
{
|
||||||
|
private readonly string _url = (cfg["Supabase:Url"] ?? "").TrimEnd('/');
|
||||||
|
private readonly string _key = cfg["Supabase:PublishableKey"] ?? "";
|
||||||
|
private readonly ConcurrentDictionary<string, (CrmUser User, DateTimeOffset Until)> _users = new();
|
||||||
|
private readonly ConcurrentDictionary<string, (bool Ok, DateTimeOffset Until)> _ws = new();
|
||||||
|
private static readonly TimeSpan CacheFor = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
public const string Shared = "shared";
|
||||||
|
|
||||||
|
// id пользовательского workspace CRM — текст: 'ws' + base36 (createWorkspace в CRM, напр. wsmv5l7d425re160);
|
||||||
|
// принимаем и любые похожие текстовые id (в т. ч. uuid). Только безопасные символы — значение идёт в фильтр PostgREST.
|
||||||
|
[GeneratedRegex("^[A-Za-z0-9_-]{1,64}$")]
|
||||||
|
private static partial Regex CustomWsRx();
|
||||||
|
|
||||||
|
public enum Fail { None, BadToken, Expired, Rejected, Unavailable }
|
||||||
|
|
||||||
|
public async Task<(CrmUser? User, Fail Fail)> VerifyAsync(string token, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var key = Hash(token);
|
||||||
|
if (_users.TryGetValue(key, out var hit) && hit.Until > now && hit.User.Exp > now.ToUnixTimeSeconds())
|
||||||
|
return (hit.User, Fail.None);
|
||||||
|
|
||||||
|
// 1. Разбор JWT: exp и aud.
|
||||||
|
JsonElement claims;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var parts = token.Split('.');
|
||||||
|
if (parts.Length != 3) return (null, Fail.BadToken);
|
||||||
|
claims = JsonDocument.Parse(Base64Url(parts[1])).RootElement;
|
||||||
|
}
|
||||||
|
catch { return (null, Fail.BadToken); }
|
||||||
|
if (!claims.TryGetProperty("exp", out var expEl) || !expEl.TryGetInt64(out var exp)) return (null, Fail.BadToken);
|
||||||
|
if (exp <= now.ToUnixTimeSeconds()) return (null, Fail.Expired);
|
||||||
|
if (!AudIsAuthenticated(claims)) return (null, Fail.Rejected);
|
||||||
|
|
||||||
|
// 2. Supabase: GET /auth/v1/user.
|
||||||
|
HttpResponseMessage r;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var req = new HttpRequestMessage(HttpMethod.Get, $"{_url}/auth/v1/user");
|
||||||
|
req.Headers.Add("apikey", _key);
|
||||||
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
r = await http.CreateClient("supabase").SendAsync(req, ct);
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is HttpRequestException or TaskCanceledException) { return (null, Fail.Unavailable); }
|
||||||
|
using (r)
|
||||||
|
{
|
||||||
|
if (!r.IsSuccessStatusCode) return (null, (int)r.StatusCode >= 500 ? Fail.Unavailable : Fail.Rejected);
|
||||||
|
using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct));
|
||||||
|
var u = doc.RootElement;
|
||||||
|
if (Str(u, "aud") != "authenticated" || Str(u, "id") is not { } id) return (null, Fail.Rejected);
|
||||||
|
string? name = null;
|
||||||
|
if (u.TryGetProperty("user_metadata", out var md) && md.ValueKind == JsonValueKind.Object)
|
||||||
|
name = Str(md, "name") ?? Str(md, "full_name") ?? Str(md, "display_name");
|
||||||
|
var user = new CrmUser(id, Str(u, "email"), string.IsNullOrWhiteSpace(name) ? null : name, exp);
|
||||||
|
_users[key] = (user, now + CacheFor);
|
||||||
|
if (_users.Count > 5000) Sweep(now);
|
||||||
|
return (user, Fail.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Нормализует и проверяет workspace. id из клиента не доверяем:
|
||||||
|
/// shared — для всех, personal:<uid> — только владельцу, остальные (custom, напр. wsmv5l7d425re160) —
|
||||||
|
/// запросом к таблице workspaces Supabase с токеном пользователя: RLS отдаёт строку, только если он владелец или участник.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> CanAccessAsync(CrmUser user, string token, string workspaceId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (workspaceId == Shared) return true;
|
||||||
|
if (workspaceId.StartsWith("personal:", StringComparison.Ordinal))
|
||||||
|
return workspaceId == $"personal:{user.Id}";
|
||||||
|
if (!CustomWsRx().IsMatch(workspaceId)) return false;
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var key = user.Id + "|" + workspaceId;
|
||||||
|
if (_ws.TryGetValue(key, out var hit) && hit.Until > now) return hit.Ok;
|
||||||
|
bool ok;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var req = new HttpRequestMessage(HttpMethod.Get,
|
||||||
|
$"{_url}/rest/v1/workspaces?id=eq.{Uri.EscapeDataString(workspaceId)}&select=id");
|
||||||
|
req.Headers.Add("apikey", _key);
|
||||||
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
using var r = await http.CreateClient("supabase").SendAsync(req, ct);
|
||||||
|
if (!r.IsSuccessStatusCode) return false; // ошибку не кэшируем
|
||||||
|
using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct));
|
||||||
|
ok = doc.RootElement.ValueKind == JsonValueKind.Array && doc.RootElement.GetArrayLength() > 0;
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException) { return false; }
|
||||||
|
_ws[key] = (ok, now + CacheFor);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record WorkspaceInfo(string Id, string Kind, string Title);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Workspace пользователя для выбора, когда CRM не прислала workspace в crm:auth.
|
||||||
|
/// Как в CRM (model.js workspaces()): «Мій» (personal:<uid>), «Загальний» (shared) и custom-строки
|
||||||
|
/// таблицы workspaces, где он владелец или участник (RLS отдаёт только их, фильтр — подстраховка).
|
||||||
|
/// null — Supabase недоступен.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<WorkspaceInfo>?> ListWorkspacesAsync(CrmUser user, string token, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var list = new List<WorkspaceInfo>
|
||||||
|
{
|
||||||
|
new($"personal:{user.Id}", "personal", "Мій"),
|
||||||
|
new(Shared, "shared", "Загальний"),
|
||||||
|
};
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var req = new HttpRequestMessage(HttpMethod.Get,
|
||||||
|
$"{_url}/rest/v1/workspaces?select=id,kind,title,owner_uid,members&kind=eq.custom");
|
||||||
|
req.Headers.Add("apikey", _key);
|
||||||
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
using var r = await http.CreateClient("supabase").SendAsync(req, ct);
|
||||||
|
// Таблицы может не быть (схему CRM не обновили) — тогда только «Мій» и «Загальний».
|
||||||
|
if ((int)r.StatusCode == 404) return list;
|
||||||
|
if (!r.IsSuccessStatusCode) return null;
|
||||||
|
using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct));
|
||||||
|
if (doc.RootElement.ValueKind != JsonValueKind.Array) return null;
|
||||||
|
var custom = new List<WorkspaceInfo>();
|
||||||
|
foreach (var w in doc.RootElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (Str(w, "id") is not { } id || !CustomWsRx().IsMatch(id)) continue;
|
||||||
|
var mine = Str(w, "owner_uid") == user.Id;
|
||||||
|
if (!mine && w.TryGetProperty("members", out var m) && m.ValueKind == JsonValueKind.Array)
|
||||||
|
mine = m.EnumerateArray().Any(x => x.ValueKind == JsonValueKind.String && x.GetString() == user.Id);
|
||||||
|
if (!mine) continue;
|
||||||
|
var title = Str(w, "title");
|
||||||
|
custom.Add(new(id, "custom", string.IsNullOrWhiteSpace(title) ? "Без назви" : title.Trim()));
|
||||||
|
_ws[user.Id + "|" + id] = (true, DateTimeOffset.UtcNow + CacheFor);
|
||||||
|
}
|
||||||
|
list.AddRange(custom.OrderBy(w => w.Title, StringComparer.Create(new System.Globalization.CultureInfo("uk-UA"), true)));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Sweep(DateTimeOffset now)
|
||||||
|
{
|
||||||
|
foreach (var (k, v) in _users) if (v.Until <= now) _users.TryRemove(k, out _);
|
||||||
|
foreach (var (k, v) in _ws) if (v.Until <= now) _ws.TryRemove(k, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool AudIsAuthenticated(JsonElement claims)
|
||||||
|
{
|
||||||
|
if (!claims.TryGetProperty("aud", out var aud)) return false;
|
||||||
|
if (aud.ValueKind == JsonValueKind.String) return aud.GetString() == "authenticated";
|
||||||
|
if (aud.ValueKind == JsonValueKind.Array)
|
||||||
|
foreach (var a in aud.EnumerateArray()) if (a.ValueKind == JsonValueKind.String && a.GetString() == "authenticated") return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Str(JsonElement e, string p) =>
|
||||||
|
e.TryGetProperty(p, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||||
|
|
||||||
|
private static string Hash(string s) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(s)));
|
||||||
|
|
||||||
|
private static byte[] Base64Url(string s)
|
||||||
|
{
|
||||||
|
s = s.Replace('-', '+').Replace('_', '/');
|
||||||
|
return Convert.FromBase64String(s.PadRight(s.Length + (4 - s.Length % 4) % 4, '='));
|
||||||
|
}
|
||||||
|
}
|
||||||
14
calls/backend/Calls.Api/Calls.Api.csproj
Normal file
14
calls/backend/Calls.Api/Calls.Api.csproj
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<InvariantGlobalization>false</InvariantGlobalization>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.12" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
100
calls/backend/Calls.Api/Db.cs
Normal file
100
calls/backend/Calls.Api/Db.cs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace Calls.Api;
|
||||||
|
|
||||||
|
/// <summary>Подключения к SQLite и схема. Файл базы лежит на постоянном томе (Storage:DataDir).</summary>
|
||||||
|
public sealed class Db
|
||||||
|
{
|
||||||
|
public string DataDir { get; }
|
||||||
|
private readonly string _cs;
|
||||||
|
|
||||||
|
public Db(IConfiguration cfg)
|
||||||
|
{
|
||||||
|
DataDir = Path.GetFullPath(cfg["Storage:DataDir"] ?? "data");
|
||||||
|
Directory.CreateDirectory(DataDir);
|
||||||
|
_cs = new SqliteConnectionStringBuilder
|
||||||
|
{
|
||||||
|
DataSource = Path.Combine(DataDir, "calls.db"),
|
||||||
|
Cache = SqliteCacheMode.Shared,
|
||||||
|
}.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public SqliteConnection Open()
|
||||||
|
{
|
||||||
|
var c = new SqliteConnection(_cs);
|
||||||
|
c.Open();
|
||||||
|
using var cmd = c.CreateCommand();
|
||||||
|
cmd.CommandText = "PRAGMA busy_timeout=5000;";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Init()
|
||||||
|
{
|
||||||
|
using var c = Open();
|
||||||
|
using var cmd = c.CreateCommand();
|
||||||
|
// Время — секунды Unix (UTC).
|
||||||
|
cmd.CommandText = """
|
||||||
|
PRAGMA journal_mode=WAL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS meta(
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
INSERT OR IGNORE INTO meta(key, value) VALUES ('created_at', strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||||
|
INSERT OR IGNORE INTO meta(key, value) VALUES ('schema', '1');
|
||||||
|
|
||||||
|
-- Одна постоянная комната Jitsi на workspace. Имя случайное, id workspace в нём нет.
|
||||||
|
CREATE TABLE IF NOT EXISTS rooms(
|
||||||
|
workspace_id TEXT PRIMARY KEY,
|
||||||
|
room TEXT NOT NULL UNIQUE,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
created_by TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Постоянная гостевая ссылка workspace. Токен ссылки = HMAC(Calls:LinkSecret, link_id);
|
||||||
|
-- в базе только link_id и SHA-256 токена, сам токен без секрета не восстановить.
|
||||||
|
CREATE TABLE IF NOT EXISTS guest_links(
|
||||||
|
workspace_id TEXT PRIMARY KEY,
|
||||||
|
link_id TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
updated_by TEXT NOT NULL,
|
||||||
|
updated_by_name TEXT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Одноразовые коды входа (окно звонка), живут Calls:CodeSeconds. Хранится хеш кода.
|
||||||
|
CREATE TABLE IF NOT EXISTS join_codes(
|
||||||
|
code_hash TEXT PRIMARY KEY,
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT NULL,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Сессия окна звонка: по ней окно берёт новые пропуски Jitsi без токена CRM. Хранится хеш.
|
||||||
|
CREATE TABLE IF NOT EXISTS call_sessions(
|
||||||
|
session_hash TEXT PRIMARY KEY,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('member','guest')),
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT NULL,
|
||||||
|
link_id TEXT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS call_sessions_exp ON call_sessions(expires_at);
|
||||||
|
""";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CreatedAt()
|
||||||
|
{
|
||||||
|
using var c = Open();
|
||||||
|
using var cmd = c.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT value FROM meta WHERE key='created_at'";
|
||||||
|
return (string)cmd.ExecuteScalar()!;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
calls/backend/Calls.Api/JitsiToken.cs
Normal file
61
calls/backend/Calls.Api/JitsiToken.cs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Calls.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Пропуск в Jitsi: HS256 JWT на одну комнату (prosody token auth, AUTH_TYPE=jwt).
|
||||||
|
/// Права модератора — context.user.moderator (модуль prosody token_affiliation).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class JitsiToken
|
||||||
|
{
|
||||||
|
public string Domain { get; }
|
||||||
|
public string AppId { get; }
|
||||||
|
public string Audience { get; }
|
||||||
|
public TimeSpan Ttl { get; }
|
||||||
|
private readonly byte[] _secret;
|
||||||
|
|
||||||
|
public JitsiToken(IConfiguration cfg)
|
||||||
|
{
|
||||||
|
Domain = (cfg["Jitsi:Domain"] ?? "meet.volcanic.tes.gd").Trim();
|
||||||
|
AppId = (cfg["Jitsi:AppId"] ?? "tvorymo-crm").Trim();
|
||||||
|
Audience = (cfg["Jitsi:Audience"] ?? "jitsi").Trim();
|
||||||
|
Ttl = TimeSpan.FromMinutes(double.TryParse(cfg["Jitsi:TokenTtlMinutes"], out var m) && m > 0 ? m : 15);
|
||||||
|
var secret = cfg["Jitsi:AppSecret"] ?? "";
|
||||||
|
if (secret.Length < 32) throw new InvalidOperationException("Jitsi:AppSecret (JWT_APP_SECRET) не задан или короче 32 символов");
|
||||||
|
_secret = Encoding.UTF8.GetBytes(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record Pass(string Jwt, long Exp);
|
||||||
|
|
||||||
|
public Pass Issue(string room, string userId, string name, string? email, bool moderator, DateTimeOffset now)
|
||||||
|
{
|
||||||
|
var exp = (now + Ttl).ToUnixTimeSeconds();
|
||||||
|
var user = new Dictionary<string, object?> { ["id"] = userId, ["name"] = name, ["moderator"] = moderator };
|
||||||
|
if (!string.IsNullOrEmpty(email)) user["email"] = email;
|
||||||
|
var payload = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["iss"] = AppId,
|
||||||
|
["aud"] = Audience,
|
||||||
|
["sub"] = Domain,
|
||||||
|
["room"] = room,
|
||||||
|
["iat"] = now.ToUnixTimeSeconds(),
|
||||||
|
["nbf"] = now.ToUnixTimeSeconds() - 30,
|
||||||
|
["exp"] = exp,
|
||||||
|
["context"] = new Dictionary<string, object?> { ["user"] = user },
|
||||||
|
["moderator"] = moderator,
|
||||||
|
};
|
||||||
|
return new Pass(Sign(payload), exp);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Sign(object payload)
|
||||||
|
{
|
||||||
|
var header = B64(JsonSerializer.SerializeToUtf8Bytes(new { alg = "HS256", typ = "JWT" }));
|
||||||
|
var body = B64(JsonSerializer.SerializeToUtf8Bytes(payload));
|
||||||
|
var sig = B64(HMACSHA256.HashData(_secret, Encoding.ASCII.GetBytes(header + "." + body)));
|
||||||
|
return header + "." + body + "." + sig;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string B64(byte[] b) => Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||||
|
}
|
||||||
227
calls/backend/Calls.Api/Program.cs
Normal file
227
calls/backend/Calls.Api/Program.cs
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Calls.Api;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<Db>();
|
||||||
|
builder.Services.AddSingleton<JitsiToken>();
|
||||||
|
builder.Services.AddSingleton<Store>();
|
||||||
|
builder.Services.AddSingleton<SupabaseAuth>();
|
||||||
|
builder.Services.AddHttpClient("supabase", c => c.Timeout = TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
var cfg = app.Configuration;
|
||||||
|
// Origin CRM, из которых принимаем crm:auth и в которые разрешено встраивание (через пробел или запятую).
|
||||||
|
var crmOrigins = (cfg["Crm:Origins"] ?? "https://test.units.tvorymo.com")
|
||||||
|
.Split([' ', ',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Select(o => o.TrimEnd('/')).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||||
|
// Откуда грузятся стили CRM (ссылки в index.html).
|
||||||
|
var crmAssets = (cfg["Crm:AssetsOrigin"] ?? "https://test.units.tvorymo.com").TrimEnd('/');
|
||||||
|
var publicUrl = (cfg["Calls:PublicUrl"] ?? "https://crm-calls.volcanic.tes.gd").TrimEnd('/');
|
||||||
|
var appStoreUrl = cfg["Calls:AppStoreUrl"] ?? "https://apps.apple.com/app/jitsi-meet/id1165103905";
|
||||||
|
var playStoreUrl = cfg["Calls:PlayStoreUrl"] ?? "https://play.google.com/store/apps/details?id=org.jitsi.meet";
|
||||||
|
|
||||||
|
app.Services.GetRequiredService<Db>().Init();
|
||||||
|
var jitsi = app.Services.GetRequiredService<JitsiToken>(); // проверка секрета при старте
|
||||||
|
app.Services.GetRequiredService<Store>(); // проверка Calls:LinkSecret при старте
|
||||||
|
var meetOrigin = "https://" + jitsi.Domain;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- заголовки
|
||||||
|
// Встраивание только в CRM; X-Frame-Options не ставим (иначе iframe пустой).
|
||||||
|
var csp = string.Join("; ",
|
||||||
|
$"frame-ancestors {string.Join(' ', crmOrigins)}",
|
||||||
|
"default-src 'self'",
|
||||||
|
$"script-src 'self' {meetOrigin}",
|
||||||
|
$"frame-src {meetOrigin}",
|
||||||
|
$"style-src 'self' 'unsafe-inline' {crmAssets} https://fonts.googleapis.com",
|
||||||
|
$"font-src 'self' data: {crmAssets} https://fonts.gstatic.com",
|
||||||
|
"img-src 'self' data: blob: https:",
|
||||||
|
"connect-src 'self'",
|
||||||
|
"object-src 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'");
|
||||||
|
app.Use(async (ctx, next) =>
|
||||||
|
{
|
||||||
|
var h = ctx.Response.Headers;
|
||||||
|
h["Content-Security-Policy"] = csp;
|
||||||
|
h["X-Content-Type-Options"] = "nosniff";
|
||||||
|
// Без Referer: в адресе /g/<токен> и /call?code=… секреты.
|
||||||
|
h["Referrer-Policy"] = "no-referrer";
|
||||||
|
// Камера и микрофон нужны окну звонка и iframe Jitsi в нём.
|
||||||
|
h["Permissions-Policy"] = $"camera=(self \"{meetOrigin}\"), microphone=(self \"{meetOrigin}\"), display-capture=(self \"{meetOrigin}\"), fullscreen=(self \"{meetOrigin}\")";
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- ошибки
|
||||||
|
app.Use(async (ctx, next) =>
|
||||||
|
{
|
||||||
|
try { await next(); }
|
||||||
|
catch (CallsError e) when (!ctx.Response.HasStarted)
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = e.Status;
|
||||||
|
await ctx.Response.WriteAsJsonAsync(new { error = e.Message });
|
||||||
|
}
|
||||||
|
catch (JsonException) when (!ctx.Response.HasStarted)
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 400;
|
||||||
|
await ctx.Response.WriteAsJsonAsync(new { error = "некоректний JSON" });
|
||||||
|
}
|
||||||
|
catch (BadHttpRequestException e) when (!ctx.Response.HasStarted)
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = e.StatusCode;
|
||||||
|
await ctx.Response.WriteAsJsonAsync(new { error = "некоректний запит" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- авторизация CRM для /api/*
|
||||||
|
// Bearer = access_token Supabase из CRM. Workspace — заголовок X-Calls-Workspace (обязателен там, где нужен).
|
||||||
|
var open = new[] { "/api/verify", "/api/config", "/api/call/", "/api/guest/" };
|
||||||
|
app.Use(async (ctx, next) =>
|
||||||
|
{
|
||||||
|
var path = ctx.Request.Path.Value ?? "";
|
||||||
|
if (!path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase)) { await next(); return; }
|
||||||
|
ctx.Response.Headers.CacheControl = "no-store";
|
||||||
|
if (open.Any(o => o.EndsWith('/') ? path.StartsWith(o, StringComparison.OrdinalIgnoreCase) : path.Equals(o, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{ await next(); return; }
|
||||||
|
|
||||||
|
var auth = ctx.Request.Headers.Authorization.ToString();
|
||||||
|
var token = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? auth[7..].Trim() : "";
|
||||||
|
if (token.Length == 0) { await Deny(ctx, 401, "потрібен заголовок Authorization: Bearer <токен CRM>"); return; }
|
||||||
|
var sa = ctx.RequestServices.GetRequiredService<SupabaseAuth>();
|
||||||
|
var (user, fail) = await sa.VerifyAsync(token, ctx.RequestAborted);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
await Deny(ctx, fail == SupabaseAuth.Fail.Unavailable ? 502 : 401, fail switch
|
||||||
|
{
|
||||||
|
SupabaseAuth.Fail.Expired => "термін дії токена минув",
|
||||||
|
SupabaseAuth.Fail.Unavailable => "Supabase недоступний",
|
||||||
|
_ => "токен не прийнято",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var ws = ctx.Request.Headers["X-Calls-Workspace"].ToString().Trim();
|
||||||
|
if (ws.Length > 0 && !await sa.CanAccessAsync(user, token, ws, ctx.RequestAborted))
|
||||||
|
{ await Deny(ctx, 403, "немає доступу до воркспейсу"); return; }
|
||||||
|
ctx.Items["user"] = user;
|
||||||
|
ctx.Items["token"] = token;
|
||||||
|
ctx.Items["ws"] = ws;
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
static Task Deny(HttpContext ctx, int status, string error)
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = status;
|
||||||
|
return ctx.Response.WriteAsJsonAsync(new { error });
|
||||||
|
}
|
||||||
|
static CrmUser U(HttpContext c) => (CrmUser)c.Items["user"]!;
|
||||||
|
static string Ws(HttpContext c) =>
|
||||||
|
(string)c.Items["ws"]! is { Length: > 0 } ws ? ws : throw new CallsError(400, "потрібен заголовок X-Calls-Workspace");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- без авторизации
|
||||||
|
app.MapGet("/health", (Db db) => Results.Json(new { status = "ok", dbCreatedAt = db.CreatedAt() }));
|
||||||
|
|
||||||
|
app.MapGet("/api/config", () => Results.Json(new
|
||||||
|
{
|
||||||
|
crmOrigins,
|
||||||
|
meetDomain = jitsi.Domain,
|
||||||
|
publicUrl,
|
||||||
|
appStoreUrl,
|
||||||
|
playStoreUrl,
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.MapPost("/api/verify", async (VerifyReq req, SupabaseAuth sa, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(req.access_token)) return Results.Json(new { ok = false, error = "немає access_token" }, statusCode: 400);
|
||||||
|
var (user, fail) = await sa.VerifyAsync(req.access_token, ct);
|
||||||
|
if (user is null)
|
||||||
|
return Results.Json(new { ok = false, error = fail == SupabaseAuth.Fail.Expired ? "термін дії токена минув" : "токен не прийнято" },
|
||||||
|
statusCode: fail == SupabaseAuth.Fail.Unavailable ? 502 : 401);
|
||||||
|
return Results.Json(new { ok = true, id = user.Id, email = user.Email, name = user.Name, exp = user.Exp });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- участник workspace (токен CRM)
|
||||||
|
app.MapGet("/api/workspaces", async (HttpContext ctx, SupabaseAuth sa, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var list = await sa.ListWorkspacesAsync(U(ctx), (string)ctx.Items["token"]!, ct)
|
||||||
|
?? throw new CallsError(502, "не вдалося отримати воркспейси з CRM");
|
||||||
|
return Results.Json(new { workspaces = list.Select(w => new { id = w.Id, kind = w.Kind, title = w.Title }) });
|
||||||
|
});
|
||||||
|
|
||||||
|
object LinkJson(Store.GuestLink l) => new
|
||||||
|
{
|
||||||
|
url = $"{publicUrl}/g/{l.Token}",
|
||||||
|
updatedAt = DateTimeOffset.FromUnixTimeSeconds(l.UpdatedAt).ToString("O"),
|
||||||
|
updatedByName = l.UpdatedByName,
|
||||||
|
};
|
||||||
|
|
||||||
|
app.MapGet("/api/room", (HttpContext ctx, Store store) =>
|
||||||
|
{
|
||||||
|
var ws = Ws(ctx);
|
||||||
|
store.Room(ws, U(ctx).Id);
|
||||||
|
return Results.Json(new { workspace = ws, guestLink = LinkJson(store.GetGuestLink(ws, U(ctx))) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPost("/api/room/guest-link", (HttpContext ctx, Store store) =>
|
||||||
|
{
|
||||||
|
var ws = Ws(ctx);
|
||||||
|
store.Room(ws, U(ctx).Id);
|
||||||
|
return Results.Json(new { workspace = ws, guestLink = LinkJson(store.RegenerateGuestLink(ws, U(ctx))) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPost("/api/join", (HttpContext ctx, Store store) =>
|
||||||
|
{
|
||||||
|
var ws = Ws(ctx);
|
||||||
|
store.Room(ws, U(ctx).Id);
|
||||||
|
var (code, expiresIn) = store.CreateJoinCode(ws, U(ctx));
|
||||||
|
return Results.Json(new { code, expiresIn, url = $"{publicUrl}/call?code={code}" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- окно звонка (без токена CRM)
|
||||||
|
app.MapPost("/api/call/exchange", (CodeReq req, Store store) =>
|
||||||
|
{
|
||||||
|
var s = store.ExchangeCode(req.code ?? "") ?? throw new CallsError(410, "посилання для входу застаріло, натисніть «Приєднатися» ще раз");
|
||||||
|
return Results.Json(new { session = s.Token, kind = s.Kind, name = s.Name });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPost("/api/call/pass", (SessionReq req, Store store) =>
|
||||||
|
{
|
||||||
|
var p = store.IssuePass(req.session ?? "") ?? throw new CallsError(401, "сесія дзвінка завершилась, відкрийте дзвінок знову");
|
||||||
|
return Results.Json(new { jwt = p.Jwt, exp = p.Exp, room = p.Room, domain = p.Domain, name = p.Name, kind = p.Kind });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- гость
|
||||||
|
app.MapGet("/api/guest/{token}", (string token, Store store) =>
|
||||||
|
store.FindGuestLink(token) != null ? Results.Json(new { ok = true }) : throw new CallsError(404, "посилання недійсне"));
|
||||||
|
|
||||||
|
app.MapPost("/api/guest/{token}/join", (string token, GuestReq req, Store store) =>
|
||||||
|
{
|
||||||
|
var name = Store.CleanGuestName(req.name) ?? throw new CallsError(400, "вкажіть ім'я");
|
||||||
|
var s = store.JoinAsGuest(token, name) ?? throw new CallsError(404, "посилання недійсне");
|
||||||
|
return Results.Json(new { session = s.Token, kind = s.Kind, name = s.Name });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapFallback("/api/{**rest}", () => Results.Json(new { error = "не знайдено" }, statusCode: 404));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- фронтенд (SPA: /, /call, /g/<токен>)
|
||||||
|
app.UseDefaultFiles();
|
||||||
|
app.UseStaticFiles(new StaticFileOptions
|
||||||
|
{
|
||||||
|
OnPrepareResponse = c =>
|
||||||
|
{
|
||||||
|
// index.html не кэшируем, ассеты с хешем — надолго.
|
||||||
|
c.Context.Response.Headers.CacheControl = c.File.Name == "index.html" ? "no-cache" : "public, max-age=31536000, immutable";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
app.MapFallbackToFile("index.html", new StaticFileOptions
|
||||||
|
{
|
||||||
|
OnPrepareResponse = c => c.Context.Response.Headers.CacheControl = "no-cache",
|
||||||
|
});
|
||||||
|
|
||||||
|
app.Run();
|
||||||
|
|
||||||
|
record VerifyReq(string? access_token);
|
||||||
|
record CodeReq(string? code);
|
||||||
|
record SessionReq(string? session);
|
||||||
|
record GuestReq(string? name);
|
||||||
|
|
||||||
|
public partial class Program;
|
||||||
230
calls/backend/Calls.Api/Store.cs
Normal file
230
calls/backend/Calls.Api/Store.cs
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace Calls.Api;
|
||||||
|
|
||||||
|
public sealed class CallsError(int status, string message) : Exception(message)
|
||||||
|
{
|
||||||
|
public int Status { get; } = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Комнаты, гостевые ссылки, одноразовые коды и сессии окна звонка.
|
||||||
|
/// Секреты (код, сессия, токен ссылки) в базе только хешами SHA-256.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Store
|
||||||
|
{
|
||||||
|
private readonly Db _db;
|
||||||
|
private readonly JitsiToken _jitsi;
|
||||||
|
private readonly byte[] _linkSecret;
|
||||||
|
private readonly TimeSpan _codeTtl;
|
||||||
|
private readonly TimeSpan _sessionTtl;
|
||||||
|
private readonly TimeProvider _time;
|
||||||
|
|
||||||
|
public Store(Db db, JitsiToken jitsi, IConfiguration cfg, TimeProvider? time = null)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_jitsi = jitsi;
|
||||||
|
_time = time ?? TimeProvider.System;
|
||||||
|
var ls = cfg["Calls:LinkSecret"] ?? "";
|
||||||
|
if (ls.Length < 32) throw new InvalidOperationException("Calls:LinkSecret (GUEST_LINK_SECRET) не задан или короче 32 символов");
|
||||||
|
_linkSecret = Encoding.UTF8.GetBytes(ls);
|
||||||
|
_codeTtl = TimeSpan.FromSeconds(int.TryParse(cfg["Calls:CodeSeconds"], out var s) && s > 0 ? s : 60);
|
||||||
|
_sessionTtl = TimeSpan.FromHours(double.TryParse(cfg["Calls:SessionHours"], out var h) && h > 0 ? h : 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long Now => _time.GetUtcNow().ToUnixTimeSeconds();
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ комната
|
||||||
|
|
||||||
|
/// <summary>Имя комнаты workspace; создаётся при первом обращении. Только [a-z0-9]: Jitsi приводит имя к нижнему регистру.</summary>
|
||||||
|
public string Room(string workspaceId, string userId)
|
||||||
|
{
|
||||||
|
using var c = _db.Open();
|
||||||
|
var room = Scalar(c, "SELECT room FROM rooms WHERE workspace_id=$w", ("$w", workspaceId));
|
||||||
|
if (room != null) return room;
|
||||||
|
Exec(c, "INSERT OR IGNORE INTO rooms(workspace_id, room, created_at, created_by) VALUES ($w,$r,$t,$u)",
|
||||||
|
("$w", workspaceId), ("$r", "ws-" + RandomLower(20)), ("$t", Now), ("$u", userId));
|
||||||
|
return Scalar(c, "SELECT room FROM rooms WHERE workspace_id=$w", ("$w", workspaceId))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ гостевая ссылка
|
||||||
|
|
||||||
|
public sealed record GuestLink(string Token, long UpdatedAt, string UpdatedBy, string? UpdatedByName);
|
||||||
|
|
||||||
|
public GuestLink GetGuestLink(string workspaceId, CrmUser user)
|
||||||
|
{
|
||||||
|
using var c = _db.Open();
|
||||||
|
var link = ReadLink(c, workspaceId);
|
||||||
|
if (link != null) return link;
|
||||||
|
WriteLink(c, workspaceId, user, insertOnly: true);
|
||||||
|
return ReadLink(c, workspaceId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Новая ссылка: старая сразу перестаёт пускать новых гостей.</summary>
|
||||||
|
public GuestLink RegenerateGuestLink(string workspaceId, CrmUser user)
|
||||||
|
{
|
||||||
|
using var c = _db.Open();
|
||||||
|
WriteLink(c, workspaceId, user, insertOnly: false);
|
||||||
|
return ReadLink(c, workspaceId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private GuestLink? ReadLink(SqliteConnection c, string workspaceId)
|
||||||
|
{
|
||||||
|
using var cmd = c.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT link_id, updated_at, updated_by, updated_by_name FROM guest_links WHERE workspace_id=$w";
|
||||||
|
cmd.Parameters.AddWithValue("$w", workspaceId);
|
||||||
|
using var r = cmd.ExecuteReader();
|
||||||
|
if (!r.Read()) return null;
|
||||||
|
return new GuestLink(LinkToken(r.GetString(0)), r.GetInt64(1), r.GetString(2), r.IsDBNull(3) ? null : r.GetString(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteLink(SqliteConnection c, string workspaceId, CrmUser user, bool insertOnly)
|
||||||
|
{
|
||||||
|
var linkId = RandomLower(24);
|
||||||
|
var sql = insertOnly
|
||||||
|
? "INSERT OR IGNORE INTO guest_links(workspace_id, link_id, token_hash, updated_at, updated_by, updated_by_name) VALUES ($w,$l,$h,$t,$u,$n)"
|
||||||
|
: """
|
||||||
|
INSERT INTO guest_links(workspace_id, link_id, token_hash, updated_at, updated_by, updated_by_name) VALUES ($w,$l,$h,$t,$u,$n)
|
||||||
|
ON CONFLICT(workspace_id) DO UPDATE SET link_id=$l, token_hash=$h, updated_at=$t, updated_by=$u, updated_by_name=$n
|
||||||
|
""";
|
||||||
|
Exec(c, sql, ("$w", workspaceId), ("$l", linkId), ("$h", Hash(LinkToken(linkId))), ("$t", Now),
|
||||||
|
("$u", user.Id), ("$n", (object?)(user.Name ?? user.Email) ?? DBNull.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private string LinkToken(string linkId) =>
|
||||||
|
JitsiToken.B64(HMACSHA256.HashData(_linkSecret, Encoding.UTF8.GetBytes("guest-link:" + linkId)));
|
||||||
|
|
||||||
|
/// <summary>Действующая ссылка → (workspace, link_id); старая или чужая — null.</summary>
|
||||||
|
public (string WorkspaceId, string LinkId)? FindGuestLink(string token)
|
||||||
|
{
|
||||||
|
if (!IsToken(token)) return null;
|
||||||
|
using var c = _db.Open();
|
||||||
|
using var cmd = c.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT workspace_id, link_id FROM guest_links WHERE token_hash=$h";
|
||||||
|
cmd.Parameters.AddWithValue("$h", Hash(token));
|
||||||
|
using var r = cmd.ExecuteReader();
|
||||||
|
return r.Read() ? (r.GetString(0), r.GetString(1)) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ код входа и сессия окна звонка
|
||||||
|
|
||||||
|
public (string Code, int ExpiresIn) CreateJoinCode(string workspaceId, CrmUser user)
|
||||||
|
{
|
||||||
|
var code = RandomToken();
|
||||||
|
using var c = _db.Open();
|
||||||
|
Exec(c, "DELETE FROM join_codes WHERE expires_at < $t", ("$t", Now));
|
||||||
|
Exec(c, "INSERT INTO join_codes(code_hash, workspace_id, user_id, name, email, expires_at) VALUES ($h,$w,$u,$n,$e,$x)",
|
||||||
|
("$h", Hash(code)), ("$w", workspaceId), ("$u", user.Id), ("$n", DisplayName(user)),
|
||||||
|
("$e", (object?)user.Email ?? DBNull.Value), ("$x", Now + (long)_codeTtl.TotalSeconds));
|
||||||
|
return (code, (int)_codeTtl.TotalSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record Session(string Token, string Kind, string Name);
|
||||||
|
|
||||||
|
/// <summary>Код одноразовый: удаляется при обмене, даже если уже истёк.</summary>
|
||||||
|
public Session? ExchangeCode(string code)
|
||||||
|
{
|
||||||
|
if (!IsToken(code)) return null;
|
||||||
|
using var c = _db.Open();
|
||||||
|
using var cmd = c.CreateCommand();
|
||||||
|
cmd.CommandText = "DELETE FROM join_codes WHERE code_hash=$h RETURNING workspace_id, user_id, name, email, expires_at";
|
||||||
|
cmd.Parameters.AddWithValue("$h", Hash(code));
|
||||||
|
string ws, uid, name; string? email; long exp;
|
||||||
|
using (var r = cmd.ExecuteReader())
|
||||||
|
{
|
||||||
|
if (!r.Read()) return null;
|
||||||
|
ws = r.GetString(0); uid = r.GetString(1); name = r.GetString(2);
|
||||||
|
email = r.IsDBNull(3) ? null : r.GetString(3); exp = r.GetInt64(4);
|
||||||
|
}
|
||||||
|
if (exp < Now) return null;
|
||||||
|
return NewSession(c, "member", ws, uid, name, email, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Session? JoinAsGuest(string linkToken, string name)
|
||||||
|
{
|
||||||
|
var link = FindGuestLink(linkToken);
|
||||||
|
if (link == null) return null;
|
||||||
|
using var c = _db.Open();
|
||||||
|
return NewSession(c, "guest", link.Value.WorkspaceId, "guest-" + RandomLower(12), name, null, link.Value.LinkId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Session NewSession(SqliteConnection c, string kind, string ws, string uid, string name, string? email, string? linkId)
|
||||||
|
{
|
||||||
|
var token = RandomToken();
|
||||||
|
Exec(c, "DELETE FROM call_sessions WHERE expires_at < $t", ("$t", Now));
|
||||||
|
Exec(c, """
|
||||||
|
INSERT INTO call_sessions(session_hash, kind, workspace_id, user_id, name, email, link_id, created_at, expires_at)
|
||||||
|
VALUES ($h,$k,$w,$u,$n,$e,$l,$t,$x)
|
||||||
|
""",
|
||||||
|
("$h", Hash(token)), ("$k", kind), ("$w", ws), ("$u", uid), ("$n", name),
|
||||||
|
("$e", (object?)email ?? DBNull.Value), ("$l", (object?)linkId ?? DBNull.Value),
|
||||||
|
("$t", Now), ("$x", Now + (long)_sessionTtl.TotalSeconds));
|
||||||
|
return new Session(token, kind, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PassResult(string Jwt, long Exp, string Room, string Domain, string Name, string Kind);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Новый пропуск Jitsi по сессии окна звонка. Участник workspace — модератор, гость — нет.
|
||||||
|
/// Гостевая сессия живёт, пока жива сессия: перегенерация ссылки не выкидывает уже вошедших гостей.
|
||||||
|
/// </summary>
|
||||||
|
public PassResult? IssuePass(string session)
|
||||||
|
{
|
||||||
|
if (!IsToken(session)) return null;
|
||||||
|
using var c = _db.Open();
|
||||||
|
using var cmd = c.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT kind, workspace_id, user_id, name, email, expires_at FROM call_sessions WHERE session_hash=$h";
|
||||||
|
cmd.Parameters.AddWithValue("$h", Hash(session));
|
||||||
|
string kind, ws, uid, name; string? email;
|
||||||
|
using (var r = cmd.ExecuteReader())
|
||||||
|
{
|
||||||
|
if (!r.Read() || r.GetInt64(5) < Now) return null;
|
||||||
|
kind = r.GetString(0); ws = r.GetString(1); uid = r.GetString(2); name = r.GetString(3);
|
||||||
|
email = r.IsDBNull(4) ? null : r.GetString(4);
|
||||||
|
}
|
||||||
|
var room = Room(ws, uid);
|
||||||
|
var pass = _jitsi.Issue(room, uid, name, email, moderator: kind == "member", _time.GetUtcNow());
|
||||||
|
return new PassResult(pass.Jwt, pass.Exp, room, _jitsi.Domain, name, kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ helpers
|
||||||
|
|
||||||
|
public static string DisplayName(CrmUser u) =>
|
||||||
|
!string.IsNullOrWhiteSpace(u.Name) ? u.Name.Trim() : !string.IsNullOrWhiteSpace(u.Email) ? u.Email.Trim() : "Користувач CRM";
|
||||||
|
|
||||||
|
/// <summary>Имя гостя: 1–60 символов без управляющих.</summary>
|
||||||
|
public static string? CleanGuestName(string? name)
|
||||||
|
{
|
||||||
|
if (name == null) return null;
|
||||||
|
var s = new string(name.Where(ch => !char.IsControl(ch)).ToArray()).Trim();
|
||||||
|
while (s.Contains(" ")) s = s.Replace(" ", " ");
|
||||||
|
if (s.Length == 0) return null;
|
||||||
|
return s.Length > 60 ? s[..60].Trim() : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsToken(string? s) => s is { Length: >= 20 and <= 100 } && s.All(ch => char.IsAsciiLetterOrDigit(ch) || ch is '-' or '_');
|
||||||
|
|
||||||
|
private static string RandomToken() => JitsiToken.B64(RandomNumberGenerator.GetBytes(32));
|
||||||
|
|
||||||
|
private static string RandomLower(int n) => RandomNumberGenerator.GetString("abcdefghijklmnopqrstuvwxyz0123456789", n);
|
||||||
|
|
||||||
|
private static string Hash(string s) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(s)));
|
||||||
|
|
||||||
|
private static string? Scalar(SqliteConnection c, string sql, params (string, object)[] ps)
|
||||||
|
{
|
||||||
|
using var cmd = c.CreateCommand();
|
||||||
|
cmd.CommandText = sql;
|
||||||
|
foreach (var (k, v) in ps) cmd.Parameters.AddWithValue(k, v);
|
||||||
|
return cmd.ExecuteScalar() as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Exec(SqliteConnection c, string sql, params (string, object)[] ps)
|
||||||
|
{
|
||||||
|
using var cmd = c.CreateCommand();
|
||||||
|
cmd.CommandText = sql;
|
||||||
|
foreach (var (k, v) in ps) cmd.Parameters.AddWithValue(k, v);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
8
calls/frontend/.oxlintrc.json
Normal file
8
calls/frontend/.oxlintrc.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
18
calls/frontend/index.html
Normal file
18
calls/frontend/index.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="uk">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="referrer" content="no-referrer">
|
||||||
|
<title>Дзвінки · CRM Tvorymo</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<!-- Стили CRM ссылками: сначала дизайн-система, затем crm.css (порядок важен). -->
|
||||||
|
<link rel="stylesheet" href="https://test.units.tvorymo.com/tvorymo-crm/css/tvorymo-ui.css">
|
||||||
|
<link rel="stylesheet" href="https://test.units.tvorymo.com/tvorymo-crm/css/crm.css">
|
||||||
|
</head>
|
||||||
|
<body class="side-off">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1295
calls/frontend/package-lock.json
generated
Normal file
1295
calls/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
calls/frontend/package.json
Normal file
24
calls/frontend/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "crm-calls-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "oxlint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.13.3",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.7",
|
||||||
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
|
"oxlint": "^1.81.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
169
calls/frontend/src/App.tsx
Normal file
169
calls/frontend/src/App.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
// Модуль «Дзвінки» в iframe боковой панели CRM: workspace, кнопка «Приєднатися», гостевая ссылка.
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { api, type GuestLink, type Workspace } from "./api";
|
||||||
|
import { CopyButton } from "./CopyButton";
|
||||||
|
import { retryReady, useAuth } from "./crm";
|
||||||
|
import { ConfirmDialog } from "./Dialogs";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
|
const WS_KEY = "calls_ws_v1:";
|
||||||
|
|
||||||
|
function Waiting() {
|
||||||
|
const a = useAuth();
|
||||||
|
const d = a.diag;
|
||||||
|
const [late, setLate] = useState(false);
|
||||||
|
useEffect(() => { const t = window.setTimeout(() => setLate(true), 10000); return () => window.clearTimeout(t); }, []);
|
||||||
|
const text = {
|
||||||
|
standalone: <>Ця сторінка працює всередині CRM Tvorymo. Відкрийте модуль «Дзвінки» з бічної панелі CRM.</>,
|
||||||
|
waiting: <>Чекаю авторизацію від CRM…</>,
|
||||||
|
checking: <>Перевіряю користувача…</>,
|
||||||
|
error: <>Помилка авторизації: {a.error}</>,
|
||||||
|
logout: <>Ви вийшли з CRM. Увійдіть у CRM знову.</>,
|
||||||
|
ok: null,
|
||||||
|
}[a.status];
|
||||||
|
return (
|
||||||
|
<main id="main"><div className="page">
|
||||||
|
<div className="head"><div><h1>Дзвінки</h1><div className="sub">Відеодзвінки <b>CRM Tvorymo</b></div></div></div>
|
||||||
|
<div className="pcard"><div className="t">{text}</div>
|
||||||
|
{a.status === "waiting" && late && <div className="small mt-6">
|
||||||
|
CRM не надіслала авторизацію. Перевірте, що для цього посилання в бічній панелі CRM увімкнено галочку
|
||||||
|
«Передавати авторизацію».
|
||||||
|
<div className="row wrap mt-10"><button className="btn sm" onClick={retryReady}>Надіслати запит ще раз</button></div>
|
||||||
|
<div className="mute mt-6">Батьківська сторінка: {d.parentOrigin ?? "невідомо"} · дозволені: {d.allowed.join(", ") || "—"} ·
|
||||||
|
повідомлень отримано: {d.received.length}</div>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
</div></main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Выбор workspace, если CRM его не прислала. */
|
||||||
|
function Picker(props: { onPick: (w: Workspace) => void; current?: string }) {
|
||||||
|
const [list, setList] = useState<Workspace[] | null>(null);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
useEffect(() => { api.workspaces().then((r) => setList(r.workspaces)).catch((e) => setErr((e as Error).message)); }, []);
|
||||||
|
if (err) return <div className="err">{err}</div>;
|
||||||
|
if (!list) return <div className="empty">Завантаження воркспейсів…</div>;
|
||||||
|
return (
|
||||||
|
<div className="pcard">
|
||||||
|
<div className="t">Оберіть воркспейс<small>Кожен воркспейс має свою кімнату для дзвінків.</small></div>
|
||||||
|
<div className="mt-10">
|
||||||
|
{list.map((w) => (
|
||||||
|
<button key={w.id} className={"mrow" + (w.id === props.current ? " on" : "")} onClick={() => props.onPick(w)}>
|
||||||
|
<div className="t">{w.title}<small>{w.kind === "personal" ? "Бачите лише ви" : w.kind === "shared" ? "Усі користувачі CRM" : "Воркспейс на вибраних людей"}</small></div>
|
||||||
|
<Icon name="chev" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Room(props: { ws: string }) {
|
||||||
|
const { ws } = props;
|
||||||
|
const [link, setLink] = useState<GuestLink | null>(null);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
const [joinErr, setJoinErr] = useState("");
|
||||||
|
const [fallback, setFallback] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [confirm, setConfirm] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLink(null); setErr("");
|
||||||
|
api.room(ws).then((r) => setLink(r.guestLink)).catch((e) => setErr((e as Error).message));
|
||||||
|
}, [ws]);
|
||||||
|
|
||||||
|
const join = async () => {
|
||||||
|
setJoinErr(""); setFallback(null);
|
||||||
|
// Окно открываем сразу по клику (пока жива активация пользователя), адрес задаём после запроса кода.
|
||||||
|
// В iframe CRM нет доступа к камере — звонок идёт в отдельном окне.
|
||||||
|
const w = window.open("", "_blank");
|
||||||
|
try { if (w) { w.document.title = "Дзвінок"; w.document.body.textContent = "Підключення…"; } } catch { /* не страшно */ }
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const r = await api.join(ws);
|
||||||
|
if (w && !w.closed) w.location.href = r.url;
|
||||||
|
else setFallback(r.url); // браузер заблокировал окно — ссылка вручную (код живёт 60 секунд)
|
||||||
|
} catch (e) {
|
||||||
|
try { w?.close(); } catch { /* уже закрыто */ }
|
||||||
|
setJoinErr((e as Error).message);
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const regenerate = async () => {
|
||||||
|
const r = await api.regenerateLink(ws);
|
||||||
|
setLink(r.guestLink);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (err) return <div className="err">{err}</div>;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="pcard">
|
||||||
|
<div className="t">Кімната воркспейсу<small>Постійна кімната: заходьте будь-коли, учасники воркспейсу — модератори.</small></div>
|
||||||
|
<div className="row wrap mt-14">
|
||||||
|
<button className="btn gold" disabled={busy} onClick={() => void join()}><Icon name="video" />Приєднатися</button>
|
||||||
|
</div>
|
||||||
|
{joinErr && <div className="err mt-6">{joinErr}</div>}
|
||||||
|
{fallback && <div className="small mt-10">
|
||||||
|
Браузер не відкрив нове вікно. <a href={fallback} target="_blank" rel="noopener">Відкрити дзвінок</a> (посилання діє хвилину).
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sec"><h2>Гостьове посилання</h2></div>
|
||||||
|
<div className="pcard">
|
||||||
|
<div className="t">Для людей без акаунта CRM<small>За посиланням можна увійти в кімнату цього воркспейсу без прав модератора.</small></div>
|
||||||
|
{!link ? <div className="empty">Завантаження…</div> : <>
|
||||||
|
<div className="in mt-10"><input readOnly value={link.url} onFocus={(e) => e.target.select()} aria-label="Гостьове посилання" /></div>
|
||||||
|
<div className="row wrap mt-10 gap-14">
|
||||||
|
<CopyButton text={link.url} label="Скопіювати" className="btn sm" />
|
||||||
|
<button className="btn sm ghost" onClick={() => setConfirm(true)}><Icon name="refresh" />Нове посилання</button>
|
||||||
|
</div>
|
||||||
|
<div className="small mute mt-6">
|
||||||
|
Оновлено {new Date(link.updatedAt).toLocaleString("uk-UA")}{link.updatedByName ? " · " + link.updatedByName : ""}
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
{confirm && <ConfirmDialog title="Нове гостьове посилання"
|
||||||
|
text={<>Старе посилання одразу перестане пускати нових гостей. Тим, хто вже в дзвінку, це не завадить.</>}
|
||||||
|
ok="Створити нове" onOk={regenerate} onClose={() => setConfirm(false)} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const a = useAuth();
|
||||||
|
const uid = a.user?.id;
|
||||||
|
const [picked, setPicked] = useState<Workspace | null>(null);
|
||||||
|
const [picking, setPicking] = useState(false);
|
||||||
|
|
||||||
|
// Выбор workspace запоминаем (если CRM не присылает его сама).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!uid || a.crmWorkspace) return;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(WS_KEY + uid);
|
||||||
|
if (raw) setPicked(JSON.parse(raw) as Workspace);
|
||||||
|
} catch { /* без сховища */ }
|
||||||
|
}, [uid, a.crmWorkspace]);
|
||||||
|
|
||||||
|
const pick = useCallback((w: Workspace) => {
|
||||||
|
setPicked(w); setPicking(false);
|
||||||
|
try { if (uid) localStorage.setItem(WS_KEY + uid, JSON.stringify(w)); } catch { /* без сховища */ }
|
||||||
|
}, [uid]);
|
||||||
|
|
||||||
|
if (a.status !== "ok") return <Waiting />;
|
||||||
|
const ws = a.crmWorkspace ? { id: a.crmWorkspace.id, title: a.crmWorkspace.title ?? a.crmWorkspace.id } : picked;
|
||||||
|
const fromCrm = !!a.crmWorkspace;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main id="main"><div className="page">
|
||||||
|
<div className="head">
|
||||||
|
<div><h1>Дзвінки</h1>
|
||||||
|
<div className="sub">{ws ? <>Воркспейс: <b>{ws.title}</b></> : "Оберіть воркспейс"}
|
||||||
|
{a.user && <> · {a.user.name ?? a.user.email}</>}</div>
|
||||||
|
</div>
|
||||||
|
{!fromCrm && ws && !picking && <button className="btn sm ghost" onClick={() => setPicking(true)}>Змінити воркспейс</button>}
|
||||||
|
</div>
|
||||||
|
{(!ws || picking) ? <Picker onPick={pick} current={ws?.id} /> : <Room key={ws.id} ws={ws.id} />}
|
||||||
|
</div></main>
|
||||||
|
);
|
||||||
|
}
|
||||||
229
calls/frontend/src/Call.tsx
Normal file
229
calls/frontend/src/Call.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
// Окно звонка: /call (участник workspace) и /g/<токен> (гость) после входа.
|
||||||
|
// Пропуск Jitsi берётся у сервера модуля по сессии окна и в адрес не попадает
|
||||||
|
// (исключение — кнопка «Відкрити в додатку»: приложению Jitsi Meet пропуск передаётся только в ссылке).
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { api, type Config, type Pass } from "./api";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
|
type JitsiApi = {
|
||||||
|
addListener(ev: string, fn: (e: unknown) => void): void;
|
||||||
|
dispose(): void;
|
||||||
|
};
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
JitsiMeetExternalAPI?: new (domain: string, opts: Record<string, unknown>) => JitsiApi;
|
||||||
|
/** Состояние для автотестов и диагностики (без пропусков и токенов). */
|
||||||
|
__calls?: { phase: string; starts: number; joined: number; errors: string[] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMobile(): boolean {
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
return /Android|iPhone|iPad|iPod|Mobile/i.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isIos = () => /iPhone|iPad|iPod/i.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||||
|
|
||||||
|
let scriptFor: string | null = null;
|
||||||
|
let scriptPromise: Promise<void> | null = null;
|
||||||
|
function loadExternalApi(domain: string): Promise<void> {
|
||||||
|
if (window.JitsiMeetExternalAPI) return Promise.resolve();
|
||||||
|
if (scriptPromise && scriptFor === domain) return scriptPromise;
|
||||||
|
scriptFor = domain;
|
||||||
|
scriptPromise = new Promise((resolve, reject) => {
|
||||||
|
const s = document.createElement("script");
|
||||||
|
s.src = `https://${domain}/external_api.js`;
|
||||||
|
s.async = true;
|
||||||
|
s.onload = () => resolve();
|
||||||
|
s.onerror = () => { scriptPromise = null; reject(new Error("не вдалося завантажити Jitsi")); };
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
return scriptPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diag = (window.__calls = { phase: "init", starts: 0, joined: 0, errors: [] as string[] });
|
||||||
|
|
||||||
|
/** Звонок в браузере. Начатый звонок живёт и после истечения пропуска; если Jitsi не смог переподключиться — берём новый пропуск и входим заново. */
|
||||||
|
export function BrowserCall(props: { session: string; onSessionLost: (msg: string) => void }) {
|
||||||
|
const { session, onSessionLost } = props;
|
||||||
|
const box = useRef<HTMLDivElement>(null);
|
||||||
|
const restart = useRef<() => void>(() => {});
|
||||||
|
const [phase, setPhaseState] = useState<"loading" | "in" | "ended" | "retry" | "error">("loading");
|
||||||
|
const [msg, setMsg] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let jitsi: JitsiApi | null = null;
|
||||||
|
let timer: number | undefined;
|
||||||
|
let fails = 0;
|
||||||
|
let alive = true;
|
||||||
|
const setPhase = (p: "loading" | "in" | "ended" | "retry" | "error") => { diag.phase = p; if (alive) setPhaseState(p); };
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
try { jitsi?.dispose(); } catch { /* уже закрыт */ }
|
||||||
|
jitsi = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryLater(why: string) {
|
||||||
|
stop();
|
||||||
|
fails++;
|
||||||
|
setMsg(why);
|
||||||
|
if (fails > 6) { setPhase("error"); return; }
|
||||||
|
setPhase("retry");
|
||||||
|
timer = window.setTimeout(() => void start(), Math.min(30, 2 ** (fails - 1)) * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
stop();
|
||||||
|
setPhase("loading");
|
||||||
|
let pass: Pass;
|
||||||
|
try { pass = await api.pass(session); }
|
||||||
|
catch (e) {
|
||||||
|
if ((e as { status?: number }).status === 401) { onSessionLost((e as Error).message); return; }
|
||||||
|
retryLater((e as Error).message); return;
|
||||||
|
}
|
||||||
|
try { await loadExternalApi(pass.domain); }
|
||||||
|
catch (e) { retryLater((e as Error).message); return; }
|
||||||
|
if (!alive || !box.current || !window.JitsiMeetExternalAPI) return;
|
||||||
|
diag.starts++;
|
||||||
|
let hungUp = false;
|
||||||
|
const j = new window.JitsiMeetExternalAPI(pass.domain, {
|
||||||
|
roomName: pass.room,
|
||||||
|
jwt: pass.jwt,
|
||||||
|
parentNode: box.current,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
lang: "uk",
|
||||||
|
configOverwrite: {
|
||||||
|
disableDeepLinking: true, // страницу «скачайте приложение» заменяет модуль
|
||||||
|
prejoinConfig: { enabled: diag.starts === 1 }, // при автоматическом повторном входе — без экрана подготовки
|
||||||
|
disableInviteFunctions: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
jitsi = j;
|
||||||
|
setPhase("in");
|
||||||
|
j.addListener("videoConferenceJoined", () => { fails = 0; diag.joined++; setMsg(""); });
|
||||||
|
j.addListener("readyToClose", () => { hungUp = true; stop(); setPhase("ended"); });
|
||||||
|
j.addListener("errorOccurred", (e) => {
|
||||||
|
const err = (e as { error?: { name?: string; isFatal?: boolean; type?: string } }).error ?? {};
|
||||||
|
diag.errors.push(`${err.type ?? ""}:${err.name ?? ""}${err.isFatal ? ":fatal" : ""}`);
|
||||||
|
if (diag.errors.length > 30) diag.errors.shift();
|
||||||
|
// Фатальная ошибка соединения или конференции (в т. ч. отказ по истёкшему пропуску при переподключении):
|
||||||
|
// Jitsi сам уже не восстановится — входим заново с новым пропуском.
|
||||||
|
if (err.isFatal && !hungUp && jitsi === j) retryLater("з'єднання перервалося");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
restart.current = () => { fails = 0; void start(); };
|
||||||
|
void start();
|
||||||
|
return () => { alive = false; stop(); };
|
||||||
|
}, [session, onSessionLost]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="calls-root">
|
||||||
|
<div ref={box} className={phase === "in" ? "calls-frame" : "calls-frame hidden"} />
|
||||||
|
{phase !== "in" && (
|
||||||
|
<main id="main"><div className="page">
|
||||||
|
<div className="head"><div><h1>Дзвінок</h1></div></div>
|
||||||
|
{phase === "loading" && <div className="empty">Підключення…</div>}
|
||||||
|
{phase === "retry" && <div className="empty">Перепідключення… <span className="mute">({msg})</span></div>}
|
||||||
|
{phase === "ended" && <div className="pcard">
|
||||||
|
<div className="t">Ви вийшли з дзвінка.</div>
|
||||||
|
<div className="row wrap mt-14 gap-14">
|
||||||
|
<button className="btn gold" onClick={() => restart.current()}><Icon name="video" />Повернутися до дзвінка</button>
|
||||||
|
<button className="btn ghost" onClick={() => window.close()}>Закрити вікно</button>
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
{phase === "error" && <div className="pcard">
|
||||||
|
<div className="t">Не вдалося підключитися: {msg}</div>
|
||||||
|
<div className="row wrap mt-14"><button className="btn gold" onClick={() => restart.current()}><Icon name="refresh" />Спробувати ще раз</button></div>
|
||||||
|
</div>}
|
||||||
|
</div></main>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Телефон: «Відкрити в додатку» (Jitsi Meet) или «Продовжити в браузері». */
|
||||||
|
export function MobileChoice(props: { session: string; onBrowser: () => void; onSessionLost: (msg: string) => void }) {
|
||||||
|
const [cfg, setCfg] = useState<Config | null>(null);
|
||||||
|
const [link, setLink] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
useEffect(() => { api.config().then(setCfg).catch(() => { /* ссылки на магазины по умолчанию */ }); }, []);
|
||||||
|
|
||||||
|
const openApp = async () => {
|
||||||
|
setBusy(true); setErr("");
|
||||||
|
try {
|
||||||
|
// Пропуск выдаётся только по нажатию: на одну комнату и на 15 минут.
|
||||||
|
const p = await api.pass(props.session);
|
||||||
|
const url = `org.jitsi.meet://${p.domain}/${encodeURIComponent(p.room)}?jwt=${encodeURIComponent(p.jwt)}`;
|
||||||
|
setLink(url);
|
||||||
|
window.location.href = url;
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as { status?: number }).status === 401) props.onSessionLost((e as Error).message);
|
||||||
|
else setErr((e as Error).message);
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const appStore = cfg?.appStoreUrl ?? "https://apps.apple.com/app/jitsi-meet/id1165103905";
|
||||||
|
const playStore = cfg?.playStoreUrl ?? "https://play.google.com/store/apps/details?id=org.jitsi.meet";
|
||||||
|
const stores = isIos() ? [["App Store", appStore], ["Google Play", playStore]] : [["Google Play", playStore], ["App Store", appStore]];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main id="main"><div className="page">
|
||||||
|
<div className="head"><div><h1>Дзвінок</h1><div className="sub">Як приєднатися з телефона?</div></div></div>
|
||||||
|
<div className="pcard">
|
||||||
|
<div className="row wrap gap-14">
|
||||||
|
<button className="btn gold" disabled={busy} onClick={() => void openApp()}><Icon name="phone" />Відкрити в додатку</button>
|
||||||
|
<button className="btn" onClick={props.onBrowser}><Icon name="globe" />Продовжити в браузері</button>
|
||||||
|
</div>
|
||||||
|
{err && <div className="err mt-6">{err}</div>}
|
||||||
|
{link && <div className="small mt-14">
|
||||||
|
Якщо додаток не відкрився: <a href={link}>відкрити Jitsi Meet</a> (посилання діє 15 хвилин).
|
||||||
|
</div>}
|
||||||
|
<div className="small mute mt-14">
|
||||||
|
Немає додатка Jitsi Meet? Встановіть його з{" "}
|
||||||
|
<a href={stores[0][1]} target="_blank" rel="noopener noreferrer">{stores[0][0]}</a> або{" "}
|
||||||
|
<a href={stores[1][1]} target="_blank" rel="noopener noreferrer">{stores[1][0]}</a> і натисніть «Відкрити в додатку» ще раз.
|
||||||
|
</div>
|
||||||
|
<div className="small mute mt-6">Якщо зв'язок пропав надовго, додаток сам нового пропуску не візьме: поверніться сюди й натисніть кнопку ще раз.</div>
|
||||||
|
</div>
|
||||||
|
</div></main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сессия окна → телефон: выбор; компьютер: сразу звонок в браузере. */
|
||||||
|
export function CallWithSession(props: { session: string; onSessionLost: (msg: string) => void }) {
|
||||||
|
const [browser, setBrowser] = useState(!isMobile());
|
||||||
|
return browser
|
||||||
|
? <BrowserCall session={props.session} onSessionLost={props.onSessionLost} />
|
||||||
|
: <MobileChoice session={props.session} onBrowser={() => setBrowser(true)} onSessionLost={props.onSessionLost} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SESSION_KEY = "calls_session_v1";
|
||||||
|
|
||||||
|
/** /call?code=… — обмен одноразового кода на сессию окна. Код сразу убираем из адреса. */
|
||||||
|
export function CallPage() {
|
||||||
|
const [session, setSession] = useState<string | null>(null);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
useEffect(() => {
|
||||||
|
const code = new URLSearchParams(location.search).get("code");
|
||||||
|
if (code) history.replaceState(null, "", "/call");
|
||||||
|
const saved = sessionStorage.getItem(SESSION_KEY);
|
||||||
|
if (code) {
|
||||||
|
api.exchange(code)
|
||||||
|
.then((r) => { sessionStorage.setItem(SESSION_KEY, r.session); setSession(r.session); })
|
||||||
|
.catch((e) => { if (saved) setSession(saved); else setErr((e as Error).message); });
|
||||||
|
} else if (saved) setSession(saved);
|
||||||
|
else setErr("Відкрийте дзвінок кнопкою «Приєднатися» в модулі «Дзвінки» CRM.");
|
||||||
|
}, []);
|
||||||
|
const lost = useCallback((m: string) => { sessionStorage.removeItem(SESSION_KEY); setSession(null); setErr(m); }, []);
|
||||||
|
if (session) return <CallWithSession session={session} onSessionLost={lost} />;
|
||||||
|
return (
|
||||||
|
<main id="main"><div className="page">
|
||||||
|
<div className="head"><div><h1>Дзвінок</h1></div></div>
|
||||||
|
{err ? <div className="pcard"><div className="t">{err}</div></div> : <div className="empty">Підключення…</div>}
|
||||||
|
</div></main>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
calls/frontend/src/CopyButton.tsx
Normal file
27
calls/frontend/src/CopyButton.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { copyText } from "./clipboard";
|
||||||
|
import { ManualCopyDialog } from "./Dialogs";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
|
/** Кнопка «скопировать»: после успеха пару секунд пишет «Скопійовано», при неудаче — окно для ручного копирования. */
|
||||||
|
export function CopyButton(props: { text: string; label: string; title?: string; className?: string }) {
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const [manual, setManual] = useState(false);
|
||||||
|
const timer = useRef<number | undefined>(undefined);
|
||||||
|
useEffect(() => () => window.clearTimeout(timer.current), []);
|
||||||
|
const click = async () => {
|
||||||
|
if (await copyText(props.text)) {
|
||||||
|
setDone(true);
|
||||||
|
window.clearTimeout(timer.current);
|
||||||
|
timer.current = window.setTimeout(() => setDone(false), 2000);
|
||||||
|
} else setManual(true);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button className={props.className ?? "btn sm ghost"} title={props.title} onClick={() => void click()} aria-live="polite">
|
||||||
|
<Icon name={done ? "check" : "copy"} />{done ? "Скопійовано" : props.label}
|
||||||
|
</button>
|
||||||
|
{manual && <ManualCopyDialog text={props.text} onClose={() => setManual(false)} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
calls/frontend/src/Dialogs.tsx
Normal file
69
calls/frontend/src/Dialogs.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
|
/** Аркуш CRM (#sheet-root > .backdrop + .sheet). confirm() в iframe CRM не работает — подтверждение своим окном. */
|
||||||
|
export function Sheet(props: { title: string; onClose: () => void; children: ReactNode; footer?: ReactNode }) {
|
||||||
|
const { onClose } = props;
|
||||||
|
useEffect(() => {
|
||||||
|
const k = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||||
|
window.addEventListener("keydown", k);
|
||||||
|
document.body.classList.add("lock");
|
||||||
|
return () => { window.removeEventListener("keydown", k); document.body.classList.remove("lock"); };
|
||||||
|
}, [onClose]);
|
||||||
|
return (
|
||||||
|
<div id="sheet-root" className="open">
|
||||||
|
<div className="backdrop" onClick={onClose} />
|
||||||
|
<div className="sheet" role="dialog" aria-modal="true" aria-label={props.title}>
|
||||||
|
<div className="grab" />
|
||||||
|
<div className="top">
|
||||||
|
<span className="tt">{props.title}</span>
|
||||||
|
<button className="icon-btn x" onClick={onClose} title="Закрити"><Icon name="x" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="sc">{props.children}</div>
|
||||||
|
{props.footer && <div className="bot">{props.footer}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmDialog(props: {
|
||||||
|
title: string; text: ReactNode; ok: string; danger?: boolean;
|
||||||
|
onOk: () => Promise<void> | void; onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const go = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try { await props.onOk(); props.onClose(); }
|
||||||
|
catch (e) { setErr((e as Error).message); setBusy(false); }
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Sheet title={props.title} onClose={props.onClose} footer={<>
|
||||||
|
<span className="sp" />
|
||||||
|
<button className="btn ghost" onClick={props.onClose}>Скасувати</button>
|
||||||
|
<button className={props.danger ? "btn dng" : "btn gold"} disabled={busy} onClick={go}>{props.ok}</button>
|
||||||
|
</>}>
|
||||||
|
<div className="block mt-10">{props.text}</div>
|
||||||
|
{err && <div className="err">{err}</div>}
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ManualCopyDialog(props: { text: string; onClose: () => void }) {
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
useEffect(() => { ref.current?.focus(); ref.current?.select(); }, []);
|
||||||
|
return (
|
||||||
|
<Sheet title="Скопіюйте посилання" onClose={props.onClose} footer={<>
|
||||||
|
<span className="sp" />
|
||||||
|
<button className="btn gold" onClick={props.onClose}>Готово</button>
|
||||||
|
</>}>
|
||||||
|
<div className="f">
|
||||||
|
<label>Скопіюйте вручну (Ctrl+C)</label>
|
||||||
|
<div className="in">
|
||||||
|
<input ref={ref} readOnly value={props.text} onFocus={(e) => e.target.select()} onClick={(e) => e.currentTarget.select()} />
|
||||||
|
</div>
|
||||||
|
<div className="small mute mt-6">Автоматично скопіювати не вдалося: браузер не дав доступу до буфера обміну.</div>
|
||||||
|
</div>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
calls/frontend/src/Guest.tsx
Normal file
60
calls/frontend/src/Guest.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// Гостевая страница /g/<токен>: имя → сессия гостя (без прав модератора) → звонок.
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { api } from "./api";
|
||||||
|
import { CallWithSession } from "./Call";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
|
const NAME_KEY = "calls_guest_name_v1";
|
||||||
|
|
||||||
|
export function GuestPage({ token }: { token: string }) {
|
||||||
|
const sessionKey = "calls_guest_session_v1:" + token.slice(0, 12);
|
||||||
|
const [state, setState] = useState<"checking" | "ok" | "bad">("checking");
|
||||||
|
const [name, setName] = useState(() => { try { return localStorage.getItem(NAME_KEY) ?? ""; } catch { return ""; } });
|
||||||
|
const [session, setSession] = useState<string | null>(() => sessionStorage.getItem(sessionKey));
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (session) return;
|
||||||
|
api.guestCheck(token).then(() => setState("ok")).catch(() => setState("bad"));
|
||||||
|
}, [token, session]);
|
||||||
|
|
||||||
|
const join = async () => {
|
||||||
|
const n = name.trim();
|
||||||
|
if (!n) { setErr("Вкажіть ім'я"); return; }
|
||||||
|
setBusy(true); setErr("");
|
||||||
|
try {
|
||||||
|
const r = await api.guestJoin(token, n);
|
||||||
|
try { localStorage.setItem(NAME_KEY, n); } catch { /* без сховища */ }
|
||||||
|
sessionStorage.setItem(sessionKey, r.session);
|
||||||
|
setSession(r.session);
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as { status?: number }).status === 404) setState("bad");
|
||||||
|
else setErr((e as Error).message);
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const lost = useCallback((m: string) => { sessionStorage.removeItem(sessionKey); setSession(null); setErr(m); }, [sessionKey]);
|
||||||
|
|
||||||
|
if (session) return <CallWithSession session={session} onSessionLost={lost} />;
|
||||||
|
return (
|
||||||
|
<main id="main"><div className="page">
|
||||||
|
<div className="head"><div><h1>Дзвінок</h1><div className="sub">Запрошення від <b>CRM Tvorymo</b></div></div></div>
|
||||||
|
{state === "checking" && <div className="empty">Перевірка посилання…</div>}
|
||||||
|
{state === "bad" && <div className="pcard"><div className="t">Посилання недійсне або застаріло.<small>Попросіть того, хто вас запросив, надіслати нове посилання.</small></div></div>}
|
||||||
|
{state === "ok" && <div className="pcard">
|
||||||
|
<div className="f">
|
||||||
|
<label htmlFor="gname">Ваше ім'я</label>
|
||||||
|
<div className="in big">
|
||||||
|
<input id="gname" value={name} maxLength={60} autoComplete="name" autoFocus
|
||||||
|
onChange={(e) => setName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") void join(); }} />
|
||||||
|
</div>
|
||||||
|
{err && <div className="err">{err}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="row wrap mt-14">
|
||||||
|
<button className="btn gold" disabled={busy} onClick={() => void join()}><Icon name="video" />Приєднатися</button>
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
</div></main>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
calls/frontend/src/Icon.tsx
Normal file
20
calls/frontend/src/Icon.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// Иконки по правилам tvorymo-ui.css: <svg class="ic" viewBox="0 0 24 24">, обводка currentColor.
|
||||||
|
const P: Record<string, string> = {
|
||||||
|
x: "M6 6l12 12M18 6 6 18",
|
||||||
|
video: "M3 7a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM16 10l5-3v10l-5-3",
|
||||||
|
phone: "M7 3h10v18H7zM11 18h2",
|
||||||
|
globe: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18",
|
||||||
|
link: "M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1",
|
||||||
|
refresh: "M20 11a8 8 0 0 0-14.9-3M4 5v4h4M4 13a8 8 0 0 0 14.9 3M20 19v-4h-4",
|
||||||
|
copy: "M9 9h11v11H9zM5 15V4h11",
|
||||||
|
check: "M5 12l5 5 9-10",
|
||||||
|
chev: "M9 6l6 6-6 6",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Icon({ name }: { name: string }) {
|
||||||
|
return (
|
||||||
|
<svg className="ic" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d={P[name] ?? ""} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
calls/frontend/src/api.ts
Normal file
47
calls/frontend/src/api.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// Запросы к серверу модуля. Участник workspace — с токеном CRM; окно звонка и гость — по своей сессии.
|
||||||
|
import { getToken } from "./crm";
|
||||||
|
|
||||||
|
export type Workspace = { id: string; kind: string; title: string };
|
||||||
|
export type GuestLink = { url: string; updatedAt: string; updatedByName: string | null };
|
||||||
|
export type Pass = { jwt: string; exp: number; room: string; domain: string; name: string; kind: "member" | "guest" };
|
||||||
|
export type Config = { crmOrigins: string[]; meetDomain: string; publicUrl: string; appStoreUrl: string; playStoreUrl: string };
|
||||||
|
|
||||||
|
async function body<T>(r: Response): Promise<T> {
|
||||||
|
let data: unknown = null;
|
||||||
|
try { data = await r.json(); } catch { /* не JSON */ }
|
||||||
|
if (!r.ok) {
|
||||||
|
const e = new Error((data as { error?: string } | null)?.error || `HTTP ${r.status}`) as Error & { status?: number };
|
||||||
|
e.status = r.status;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Запрос с токеном CRM; на 401 — один раз просим у CRM свежий токен и повторяем. */
|
||||||
|
async function crm<T>(method: string, path: string, ws?: string): Promise<T> {
|
||||||
|
const go = async (force: boolean) => {
|
||||||
|
const headers: Record<string, string> = { Authorization: `Bearer ${await getToken(force)}` };
|
||||||
|
if (ws) headers["X-Calls-Workspace"] = ws;
|
||||||
|
return fetch(path, { method, headers });
|
||||||
|
};
|
||||||
|
let r = await go(false);
|
||||||
|
if (r.status === 401) r = await go(true);
|
||||||
|
return body<T>(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post<T>(path: string, data: unknown): Promise<T> {
|
||||||
|
return body<T>(await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
config: async () => body<Config>(await fetch("/api/config")),
|
||||||
|
workspaces: () => crm<{ workspaces: Workspace[] }>("GET", "/api/workspaces"),
|
||||||
|
room: (ws: string) => crm<{ workspace: string; guestLink: GuestLink }>("GET", "/api/room", ws),
|
||||||
|
regenerateLink: (ws: string) => crm<{ workspace: string; guestLink: GuestLink }>("POST", "/api/room/guest-link", ws),
|
||||||
|
join: (ws: string) => crm<{ code: string; expiresIn: number; url: string }>("POST", "/api/join", ws),
|
||||||
|
exchange: (code: string) => post<{ session: string; kind: string; name: string }>("/api/call/exchange", { code }),
|
||||||
|
pass: (session: string) => post<Pass>("/api/call/pass", { session }),
|
||||||
|
guestCheck: async (token: string) => body<{ ok: boolean }>(await fetch(`/api/guest/${encodeURIComponent(token)}`)),
|
||||||
|
guestJoin: (token: string, name: string) =>
|
||||||
|
post<{ session: string; kind: string; name: string }>(`/api/guest/${encodeURIComponent(token)}/join`, { name }),
|
||||||
|
};
|
||||||
7
calls/frontend/src/calls.css
Normal file
7
calls/frontend/src/calls.css
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/* Дзвінки: только то, чего нет в tvorymo-ui.css / crm.css — окно звонка на весь экран и кнопка-строка выбора. */
|
||||||
|
.calls-root{min-height:100dvh}
|
||||||
|
.calls-frame{position:fixed;inset:0;background:#000}
|
||||||
|
.calls-frame.hidden{display:none}
|
||||||
|
.calls-frame iframe{display:block;border:0}
|
||||||
|
button.mrow{width:100%;text-align:left;background:none;border:0;font:inherit;color:inherit;cursor:pointer}
|
||||||
|
button.mrow.on{background:var(--gold-dim)}
|
||||||
34
calls/frontend/src/clipboard.ts
Normal file
34
calls/frontend/src/clipboard.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// Копирование в буфер обмена внутри iframe CRM.
|
||||||
|
// CRM создаёт iframe без allow="clipboard-write", поэтому navigator.clipboard.writeText там отклоняется.
|
||||||
|
// Запасной путь — временный textarea + document.execCommand("copy"): он работает от клика пользователя.
|
||||||
|
|
||||||
|
function copyViaTextarea(text: string): boolean {
|
||||||
|
const prev = document.activeElement as HTMLElement | null;
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.value = text;
|
||||||
|
ta.setAttribute("readonly", "");
|
||||||
|
// Вне экрана, но в документе: иначе выделение не сработает.
|
||||||
|
ta.style.position = "fixed";
|
||||||
|
ta.style.top = "-1000px";
|
||||||
|
ta.style.opacity = "0";
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
ta.setSelectionRange(0, text.length);
|
||||||
|
let ok = false;
|
||||||
|
try { ok = document.execCommand("copy"); } catch { ok = false; }
|
||||||
|
ta.remove();
|
||||||
|
prev?.focus?.({ preventScroll: true });
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** true — скопировано; false — оба способа не сработали (показать окно для ручного копирования). */
|
||||||
|
export async function copyText(text: string): Promise<boolean> {
|
||||||
|
// Chrome сообщает заранее, что clipboard-write запрещён: тогда сразу синхронный путь, пока жива активация от клика.
|
||||||
|
const fp = (document as Document & { featurePolicy?: { allowsFeature(f: string): boolean } }).featurePolicy;
|
||||||
|
const blocked = fp ? !fp.allowsFeature("clipboard-write") : false;
|
||||||
|
if (!blocked && navigator.clipboard?.writeText && window.isSecureContext) {
|
||||||
|
try { await navigator.clipboard.writeText(text); return true; }
|
||||||
|
catch { /* нет разрешения clipboard-write — пробуем запасной путь */ }
|
||||||
|
}
|
||||||
|
return copyViaTextarea(text);
|
||||||
|
}
|
||||||
183
calls/frontend/src/crm.ts
Normal file
183
calls/frontend/src/crm.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
// Мост с CRM: postMessage (crm:ready → crm:auth, crm:workspace, crm:refresh, crm:logout).
|
||||||
|
// Токен живёт только в памяти, никуда не логируется и не выводится.
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
export type User = { id: string; email: string | null; name: string | null };
|
||||||
|
export type CrmWorkspace = { id?: string; kind?: string; title?: string };
|
||||||
|
|
||||||
|
/** Диагностика для экрана ожидания (без токенов). */
|
||||||
|
export type Diag = {
|
||||||
|
allowed: string[];
|
||||||
|
parentOrigin: string | null;
|
||||||
|
readySentTo: string[];
|
||||||
|
received: { origin: string; type: string; accepted: boolean }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthState = {
|
||||||
|
status: "standalone" | "waiting" | "checking" | "ok" | "error" | "logout";
|
||||||
|
error?: string;
|
||||||
|
user?: User;
|
||||||
|
/** Workspace из CRM (id для API: shared | personal:<uid> | id custom). null — CRM не прислала, нужен выбор. */
|
||||||
|
crmWorkspace: { id: string; title?: string } | null;
|
||||||
|
diag: Diag;
|
||||||
|
};
|
||||||
|
|
||||||
|
let allowed: string[] = [];
|
||||||
|
let crmOrigin = "";
|
||||||
|
let token: string | null = null;
|
||||||
|
let expiresAt: number | null = null; // мс
|
||||||
|
let lastWs: CrmWorkspace | null = null;
|
||||||
|
let state: AuthState = {
|
||||||
|
status: "waiting", crmWorkspace: null,
|
||||||
|
diag: { allowed: [], parentOrigin: null, readySentTo: [], received: [] },
|
||||||
|
};
|
||||||
|
const subs = new Set<() => void>();
|
||||||
|
let waiters: Array<() => void> = [];
|
||||||
|
|
||||||
|
function set(p: Partial<AuthState>) {
|
||||||
|
state = { ...state, ...p };
|
||||||
|
subs.forEach((f) => f());
|
||||||
|
}
|
||||||
|
function setDiag(p: Partial<Diag>) {
|
||||||
|
set({ diag: { ...state.diag, ...p } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return useSyncExternalStore((f) => { subs.add(f); return () => subs.delete(f); }, () => state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMs(v: unknown): number | null {
|
||||||
|
if (v == null) return null;
|
||||||
|
const n = Number(v);
|
||||||
|
if (Number.isFinite(n)) return n < 1e12 ? n * 1000 : n;
|
||||||
|
const d = Date.parse(String(v));
|
||||||
|
return Number.isNaN(d) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Workspace из сообщения CRM → id для API. Сервер всё равно проверяет доступ сам. */
|
||||||
|
function wsKey(w: CrmWorkspace | null | undefined, uid: string | undefined): { id: string; title?: string } | null {
|
||||||
|
if (!w || typeof w !== "object") return null;
|
||||||
|
const id = String(w.id ?? "");
|
||||||
|
const kind = String(w.kind ?? "");
|
||||||
|
const title = typeof w.title === "string" ? w.title : undefined;
|
||||||
|
if (kind === "shared" || id === "shared") return { id: "shared", title };
|
||||||
|
if (kind === "personal" || id.startsWith("personal:")) return uid ? { id: `personal:${uid}`, title } : null;
|
||||||
|
return id ? { id, title } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onAuth(d: Record<string, unknown>) {
|
||||||
|
const t = d.access_token;
|
||||||
|
if (typeof t !== "string" || !t) { set({ status: "error", error: "crm:auth без access_token" }); return; }
|
||||||
|
const changedUser = !state.user || (d.user as { id?: string } | undefined)?.id !== state.user.id;
|
||||||
|
token = t;
|
||||||
|
expiresAt = toMs(d.expires_at);
|
||||||
|
if ("workspace" in d) lastWs = (d.workspace as CrmWorkspace) ?? null;
|
||||||
|
if (!changedUser && state.status === "ok") {
|
||||||
|
set({ crmWorkspace: wsKey(lastWs, state.user?.id) });
|
||||||
|
flushWaiters();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.status !== "ok") set({ status: "checking" });
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/verify", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ access_token: t }),
|
||||||
|
});
|
||||||
|
const res = await r.json();
|
||||||
|
if (!r.ok || !res.ok) { set({ status: "error", error: res.error || `HTTP ${r.status}` }); return; }
|
||||||
|
if (!expiresAt && res.exp) expiresAt = res.exp * 1000;
|
||||||
|
const user: User = { id: res.id, email: res.email, name: res.name };
|
||||||
|
set({ status: "ok", user, error: undefined, crmWorkspace: wsKey(lastWs, user.id) });
|
||||||
|
flushWaiters();
|
||||||
|
} catch {
|
||||||
|
set({ status: "error", error: "не вдалося перевірити токен" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushWaiters() {
|
||||||
|
const w = waiters;
|
||||||
|
waiters = [];
|
||||||
|
w.forEach((f) => f());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Origin родителя: location.ancestorOrigins (Chrome/Safari) или document.referrer (iframe с referrerpolicy=strict-origin). */
|
||||||
|
function detectParent(): string | null {
|
||||||
|
try {
|
||||||
|
const ao = (location as Location & { ancestorOrigins?: DOMStringList }).ancestorOrigins;
|
||||||
|
if (ao && ao.length > 0 && ao[0] && ao[0] !== "null") return ao[0];
|
||||||
|
} catch { /* недоступно */ }
|
||||||
|
try { if (document.referrer) return new URL(document.referrer).origin; } catch { /* битый referrer */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendReady() {
|
||||||
|
const origin = detectParent();
|
||||||
|
// Известен родитель из списка — шлём только ему, иначе каждому разрешённому origin (браузер доставит совпавшему).
|
||||||
|
const targets = origin && allowed.includes(origin) ? [origin] : allowed;
|
||||||
|
for (const o of targets) window.parent.postMessage({ type: "crm:ready", version: 1 }, o);
|
||||||
|
setDiag({ readySentTo: targets });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initCrm() {
|
||||||
|
if (window.parent === window) { set({ status: "standalone" }); return; }
|
||||||
|
setDiag({ parentOrigin: detectParent() });
|
||||||
|
let pending: MessageEvent[] | null = [];
|
||||||
|
window.addEventListener("message", (ev) => {
|
||||||
|
if (ev.source !== window.parent) return;
|
||||||
|
if (pending) pending.push(ev); else handle(ev);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const cfg = await (await fetch("/api/config")).json();
|
||||||
|
allowed = Array.isArray(cfg.crmOrigins) ? cfg.crmOrigins : [];
|
||||||
|
} catch {
|
||||||
|
set({ status: "error", error: "немає зв'язку з сервером модуля" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDiag({ allowed });
|
||||||
|
const early = pending ?? [];
|
||||||
|
pending = null;
|
||||||
|
early.forEach(handle);
|
||||||
|
sendReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle(ev: MessageEvent) {
|
||||||
|
const d = ev.data;
|
||||||
|
const type = d && typeof d === "object" && typeof d.type === "string" ? d.type : typeof d;
|
||||||
|
const ok = allowed.includes(ev.origin);
|
||||||
|
const rec = state.diag.received;
|
||||||
|
if (rec.length < 20) setDiag({ received: [...rec, { origin: ev.origin, type, accepted: ok }] });
|
||||||
|
if (!ok || !d || typeof d !== "object") return;
|
||||||
|
if (d.type === "crm:auth") { crmOrigin = ev.origin; void onAuth(d); }
|
||||||
|
else if (d.type === "crm:workspace") {
|
||||||
|
lastWs = d.workspace ?? null;
|
||||||
|
set({ crmWorkspace: wsKey(lastWs, state.user?.id) });
|
||||||
|
} else if (d.type === "crm:logout") {
|
||||||
|
token = null; expiresAt = null;
|
||||||
|
set({ status: "logout", user: undefined });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retryReady() {
|
||||||
|
if (window.parent !== window && allowed.length) sendReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestRefresh(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
waiters = waiters.filter((w) => w !== done);
|
||||||
|
reject(new Error("CRM не надіслала новий токен"));
|
||||||
|
}, 15000);
|
||||||
|
const done = () => { clearTimeout(timer); resolve(); };
|
||||||
|
waiters.push(done);
|
||||||
|
if (crmOrigin) window.parent.postMessage({ type: "crm:refresh" }, crmOrigin);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Токен для запроса: если истекает меньше чем через минуту — просим у CRM свежий. */
|
||||||
|
export async function getToken(force = false): Promise<string> {
|
||||||
|
const stale = !token || force || (expiresAt != null && expiresAt - Date.now() < 60_000);
|
||||||
|
if (stale) {
|
||||||
|
try { await requestRefresh(); } catch (e) { if (!token) throw e; }
|
||||||
|
}
|
||||||
|
if (!token) throw new Error("немає авторизації CRM");
|
||||||
|
return token;
|
||||||
|
}
|
||||||
17
calls/frontend/src/main.tsx
Normal file
17
calls/frontend/src/main.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import { CallPage } from "./Call";
|
||||||
|
import { initCrm } from "./crm";
|
||||||
|
import { GuestPage } from "./Guest";
|
||||||
|
import "./calls.css";
|
||||||
|
|
||||||
|
// Три страницы: / — модуль в iframe CRM, /call — окно звонка, /g/<токен> — гость.
|
||||||
|
const path = location.pathname;
|
||||||
|
const guest = /^\/g\/([A-Za-z0-9_-]{20,100})\/?$/.exec(path);
|
||||||
|
let page;
|
||||||
|
if (path === "/call" || path === "/call/") page = <CallPage />;
|
||||||
|
else if (guest) page = <GuestPage token={guest[1]} />;
|
||||||
|
else { void initCrm(); page = <App />; }
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(<StrictMode>{page}</StrictMode>);
|
||||||
26
calls/frontend/tsconfig.app.json
Normal file
26
calls/frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"allowArbitraryExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
calls/frontend/tsconfig.json
Normal file
7
calls/frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
23
calls/frontend/tsconfig.node.json
Normal file
23
calls/frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"module": "nodenext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
7
calls/frontend/vite.config.ts
Normal file
7
calls/frontend/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: { proxy: { '/api': 'http://127.0.0.1:5090', '/health': 'http://127.0.0.1:5090' } },
|
||||||
|
})
|
||||||
38
docker-compose.calls.yml
Normal file
38
docker-compose.calls.yml
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Модуль «Дзвінки» для CRM: https://crm-calls.volcanic.tes.gd (отдельное приложение Coolify).
|
||||||
|
# Jitsi — docker-compose.yml (другое приложение, https://meet.volcanic.tes.gd).
|
||||||
|
# Секреты JWT_APP_SECRET (тот же, что у Jitsi) и GUEST_LINK_SECRET — только в env Coolify.
|
||||||
|
services:
|
||||||
|
calls:
|
||||||
|
build:
|
||||||
|
context: ./calls
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
expose:
|
||||||
|
- "8080"
|
||||||
|
environment:
|
||||||
|
ASPNETCORE_ENVIRONMENT: Production
|
||||||
|
ASPNETCORE_URLS: "http://+:8080"
|
||||||
|
Storage__DataDir: /data
|
||||||
|
Calls__PublicUrl: '${CALLS_PUBLIC_URL:-https://crm-calls.volcanic.tes.gd}'
|
||||||
|
Calls__LinkSecret: '${GUEST_LINK_SECRET}'
|
||||||
|
Calls__SessionHours: '${CALLS_SESSION_HOURS:-12}'
|
||||||
|
# origin CRM через пробел: из них принимается crm:auth, они же в frame-ancestors
|
||||||
|
Crm__Origins: '${CRM_ORIGINS:-https://test.units.tvorymo.com}'
|
||||||
|
Crm__AssetsOrigin: '${CRM_ASSETS_ORIGIN:-https://test.units.tvorymo.com}'
|
||||||
|
Supabase__Url: '${SUPABASE_URL:-https://yjxbkcqsquwbsyoonfkn.supabase.co}'
|
||||||
|
Supabase__PublishableKey: '${SUPABASE_PUBLISHABLE_KEY}'
|
||||||
|
Jitsi__Domain: '${JITSI_DOMAIN:-meet.volcanic.tes.gd}'
|
||||||
|
Jitsi__AppId: '${JWT_APP_ID:-tvorymo-crm}'
|
||||||
|
Jitsi__Audience: '${JWT_AUDIENCE:-jitsi}'
|
||||||
|
Jitsi__AppSecret: '${JWT_APP_SECRET}'
|
||||||
|
Jitsi__TokenTtlMinutes: '${JITSI_TOKEN_TTL_MINUTES:-15}'
|
||||||
|
volumes:
|
||||||
|
- calls-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
calls-data:
|
||||||
@@ -2,12 +2,16 @@
|
|||||||
# Deployed by Coolify (build_pack: dockercompose) on the Volcanic VPS (164.90.233.156).
|
# Deployed by Coolify (build_pack: dockercompose) on the Volcanic VPS (164.90.233.156).
|
||||||
#
|
#
|
||||||
# Routing:
|
# Routing:
|
||||||
# https://crm-calls.volcanic.tes.gd (443, Coolify Traefik) -> web:8000 (nginx)
|
# https://meet.volcanic.tes.gd (443, Coolify Traefik) -> web:8000 (nginx)
|
||||||
# / -> Jitsi web app
|
# / -> Jitsi web app
|
||||||
# /xmpp-websocket -> prosody:5280 (proxied by web's nginx)
|
# /xmpp-websocket -> prosody:5280 (proxied by web's nginx)
|
||||||
# UDP JVB_PORT (10000) -> jvb (media + SCTP bridge channel), published on the host, bypasses Traefik.
|
# UDP JVB_PORT (10000) -> jvb (media + SCTP bridge channel), published on the host, bypasses Traefik.
|
||||||
#
|
#
|
||||||
# Secrets (JICOFO_AUTH_PASSWORD, JVB_AUTH_PASSWORD) live only in Coolify env vars.
|
# Auth: JWT only (AUTH_TYPE=jwt, ENABLE_GUESTS=0). Passes are issued by the CRM calls module
|
||||||
|
# (docker-compose.calls.yml, https://crm-calls.volcanic.tes.gd): HS256, iss tvorymo-crm, aud jitsi,
|
||||||
|
# one room, context.user.moderator -> owner via prosody token_affiliation.
|
||||||
|
#
|
||||||
|
# Secrets (JICOFO_AUTH_PASSWORD, JVB_AUTH_PASSWORD, JWT_APP_SECRET) live only in Coolify env vars.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
@@ -24,7 +28,7 @@ services:
|
|||||||
- jitsi-web-storage:/storage
|
- jitsi-web-storage:/storage
|
||||||
environment:
|
environment:
|
||||||
TZ: '${TZ:-Europe/Kyiv}'
|
TZ: '${TZ:-Europe/Kyiv}'
|
||||||
PUBLIC_URL: '${PUBLIC_URL:-https://crm-calls.volcanic.tes.gd}'
|
PUBLIC_URL: '${PUBLIC_URL:-https://meet.volcanic.tes.gd}'
|
||||||
DISABLE_HTTPS: '1'
|
DISABLE_HTTPS: '1'
|
||||||
ENABLE_HTTP_REDIRECT: '0'
|
ENABLE_HTTP_REDIRECT: '0'
|
||||||
ENABLE_LETSENCRYPT: '0'
|
ENABLE_LETSENCRYPT: '0'
|
||||||
@@ -37,9 +41,13 @@ services:
|
|||||||
XMPP_MUC_DOMAIN: muc.meet.jitsi
|
XMPP_MUC_DOMAIN: muc.meet.jitsi
|
||||||
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
|
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
|
||||||
DEFAULT_LANGUAGE: '${DEFAULT_LANGUAGE:-uk}'
|
DEFAULT_LANGUAGE: '${DEFAULT_LANGUAGE:-uk}'
|
||||||
# Embedding into wiki/CRM via iframe: allow *.tes.gd and test.units.tvorymo.com.
|
ENABLE_AUTH: '1'
|
||||||
# Jitsi's nginx sets no X-Frame-Options; this CSP is the only framing policy.
|
AUTH_TYPE: jwt
|
||||||
CSP_HEADER: "frame-ancestors 'self' https://*.tes.gd https://test.units.tvorymo.com"
|
ENABLE_GUESTS: '0'
|
||||||
|
# The calls module shows its own "open in app / continue in browser" page.
|
||||||
|
DISABLE_DEEP_LINKING: 'true'
|
||||||
|
# Only the calls module may embed Jitsi (call window). Jitsi's nginx sets no X-Frame-Options.
|
||||||
|
CSP_HEADER: "frame-ancestors ${CALLS_ORIGIN:-https://crm-calls.volcanic.tes.gd}"
|
||||||
depends_on:
|
depends_on:
|
||||||
- jvb
|
- jvb
|
||||||
|
|
||||||
@@ -60,7 +68,7 @@ services:
|
|||||||
- jitsi-prosody-data:/var/lib/prosody
|
- jitsi-prosody-data:/var/lib/prosody
|
||||||
environment:
|
environment:
|
||||||
TZ: '${TZ:-Europe/Kyiv}'
|
TZ: '${TZ:-Europe/Kyiv}'
|
||||||
PUBLIC_URL: '${PUBLIC_URL:-https://crm-calls.volcanic.tes.gd}'
|
PUBLIC_URL: '${PUBLIC_URL:-https://meet.volcanic.tes.gd}'
|
||||||
XMPP_SERVER: prosody
|
XMPP_SERVER: prosody
|
||||||
XMPP_DOMAIN: meet.jitsi
|
XMPP_DOMAIN: meet.jitsi
|
||||||
XMPP_AUTH_DOMAIN: auth.meet.jitsi
|
XMPP_AUTH_DOMAIN: auth.meet.jitsi
|
||||||
@@ -70,6 +78,16 @@ services:
|
|||||||
ENABLE_XMPP_WEBSOCKET: '1'
|
ENABLE_XMPP_WEBSOCKET: '1'
|
||||||
JICOFO_AUTH_PASSWORD: '${JICOFO_AUTH_PASSWORD}'
|
JICOFO_AUTH_PASSWORD: '${JICOFO_AUTH_PASSWORD}'
|
||||||
JVB_AUTH_PASSWORD: '${JVB_AUTH_PASSWORD}'
|
JVB_AUTH_PASSWORD: '${JVB_AUTH_PASSWORD}'
|
||||||
|
ENABLE_AUTH: '1'
|
||||||
|
AUTH_TYPE: jwt
|
||||||
|
ENABLE_GUESTS: '0'
|
||||||
|
JWT_ALLOW_EMPTY: '0'
|
||||||
|
JWT_APP_ID: '${JWT_APP_ID:-tvorymo-crm}'
|
||||||
|
JWT_APP_SECRET: '${JWT_APP_SECRET}'
|
||||||
|
JWT_ACCEPTED_ISSUERS: '${JWT_APP_ID:-tvorymo-crm}'
|
||||||
|
JWT_ACCEPTED_AUDIENCES: '${JWT_AUDIENCE:-jitsi}'
|
||||||
|
# context.user.moderator=true in the pass -> room owner (moderator); guests -> member.
|
||||||
|
XMPP_MUC_MODULES: token_affiliation
|
||||||
|
|
||||||
jicofo:
|
jicofo:
|
||||||
image: ghcr.io/jitsi/jicofo:${JITSI_IMAGE_VERSION:-stable-11248}
|
image: ghcr.io/jitsi/jicofo:${JITSI_IMAGE_VERSION:-stable-11248}
|
||||||
@@ -88,6 +106,10 @@ services:
|
|||||||
XMPP_MUC_DOMAIN: muc.meet.jitsi
|
XMPP_MUC_DOMAIN: muc.meet.jitsi
|
||||||
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
|
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
|
||||||
JICOFO_AUTH_PASSWORD: '${JICOFO_AUTH_PASSWORD}'
|
JICOFO_AUTH_PASSWORD: '${JICOFO_AUTH_PASSWORD}'
|
||||||
|
ENABLE_AUTH: '1'
|
||||||
|
AUTH_TYPE: jwt
|
||||||
|
# Moderators come only from the pass (token_affiliation); a guest joining first must not become owner.
|
||||||
|
ENABLE_AUTO_OWNER: 'false'
|
||||||
depends_on:
|
depends_on:
|
||||||
- prosody
|
- prosody
|
||||||
|
|
||||||
@@ -106,7 +128,7 @@ services:
|
|||||||
- jitsi-jvb-config:/config
|
- jitsi-jvb-config:/config
|
||||||
environment:
|
environment:
|
||||||
TZ: '${TZ:-Europe/Kyiv}'
|
TZ: '${TZ:-Europe/Kyiv}'
|
||||||
PUBLIC_URL: '${PUBLIC_URL:-https://crm-calls.volcanic.tes.gd}'
|
PUBLIC_URL: '${PUBLIC_URL:-https://meet.volcanic.tes.gd}'
|
||||||
XMPP_SERVER: prosody
|
XMPP_SERVER: prosody
|
||||||
XMPP_DOMAIN: meet.jitsi
|
XMPP_DOMAIN: meet.jitsi
|
||||||
XMPP_AUTH_DOMAIN: auth.meet.jitsi
|
XMPP_AUTH_DOMAIN: auth.meet.jitsi
|
||||||
|
|||||||
Reference in New Issue
Block a user