using System.Text.Json; using Calls.Api; using Calls.Api.Recording; var builder = WebApplication.CreateBuilder(args); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHttpClient("supabase", c => c.Timeout = TimeSpan.FromSeconds(10)); // Запись и транскрипция (см. README «Запис і транскрипція»). builder.Services.AddSingleton(sp => RecordingOptions.From(sp.GetRequiredService())); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHttpClient("jitsi", c => c.Timeout = TimeSpan.FromSeconds(5)); // startService/stopService ждут, пока Jibri запустит или закроет Chrome и ffmpeg — дольше обычного таймаута. builder.Services.AddHttpClient("jibri", c => c.Timeout = JibriClient.ServiceTimeout); builder.Services.AddHttpClient("openai", c => c.Timeout = TimeSpan.FromMinutes(10)); builder.Services.AddHttpClient("storage", c => c.Timeout = TimeSpan.FromMinutes(5)); builder.Services.AddHttpClient("wiki", c => c.Timeout = TimeSpan.FromSeconds(30)); if (!string.Equals(builder.Configuration["CALLS_BACKGROUND"], "false", StringComparison.OrdinalIgnoreCase)) { builder.Services.AddHostedService(); builder.Services.AddHostedService(); } var app = builder.Build(); var cfg = app.Configuration; // Origin CRM, из которых принимаем crm:auth и в которые разрешено встраивание (через пробел или запятую). var crmOrigins = (cfg["Crm:Origins"] ?? "https://shotam.tvorymo.com,https://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://shotam.tvorymo.com").TrimEnd('/'); var publicUrl = (cfg["Calls:PublicUrl"] ?? "https://crm-calls.volcanic.tes.gd").TrimEnd('/'); var appStoreUrl = cfg["Calls:AppStoreUrl"] ?? "https://apps.apple.com/app/jitsi-meet/id1165103905"; var playStoreUrl = cfg["Calls:PlayStoreUrl"] ?? "https://play.google.com/store/apps/details?id=org.jitsi.meet"; app.Services.GetRequiredService().Init(); // Общий каталог записей (bind с хоста) создаётся Docker под root, а Jibri пишет как s6 (uid 1000): // отдаём каталог ему; модуль работает от root и читает/удаляет файлы. { var rec = app.Services.GetRequiredService(); if (rec.Enabled && Directory.Exists(rec.RecordingsDir) && OperatingSystem.IsLinux()) { try { var uid = cfg["RECORDINGS_UID"] ?? "1000"; using var p = System.Diagnostics.Process.Start("chown", [$"{uid}:{uid}", rec.RecordingsDir]); p.WaitForExit(5000); File.SetUnixFileMode(rec.RecordingsDir, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute); app.Logger.LogInformation("Каталог записів {D}: власник {U}, код виходу chown {C}", rec.RecordingsDir, uid, p.ExitCode); } catch (Exception e) { app.Logger.LogWarning("Каталог записів {D}: не вдалося змінити власника: {E}", rec.RecordingsDir, e.Message); } } } var jitsi = app.Services.GetRequiredService(); // проверка секрета при старте app.Services.GetRequiredService(); // проверка Calls:LinkSecret при старте var meetOrigin = "https://" + jitsi.Domain; // ---------------------------------------------------------------- заголовки // Встраивание только в CRM; X-Frame-Options не ставим (иначе iframe пустой). var csp = string.Join("; ", $"frame-ancestors {string.Join(' ', crmOrigins)}", "default-src 'self'", $"script-src 'self' {meetOrigin}", $"frame-src {meetOrigin}", $"style-src 'self' 'unsafe-inline' {crmAssets} https://fonts.googleapis.com", $"font-src 'self' data: {crmAssets} https://fonts.gstatic.com", "img-src 'self' data: blob: https:", "connect-src 'self'", "object-src 'none'", "base-uri 'self'", "form-action 'self'"); app.Use(async (ctx, next) => { var h = ctx.Response.Headers; h["Content-Security-Policy"] = csp; h["X-Content-Type-Options"] = "nosniff"; // Без Referer: в адресе /g/<токен> и /call?code=… секреты. h["Referrer-Policy"] = "no-referrer"; // Камера и микрофон нужны окну звонка и iframe Jitsi в нём. h["Permissions-Policy"] = $"camera=(self \"{meetOrigin}\"), microphone=(self \"{meetOrigin}\"), display-capture=(self \"{meetOrigin}\"), fullscreen=(self \"{meetOrigin}\")"; await next(); }); // ---------------------------------------------------------------- ошибки app.Use(async (ctx, next) => { try { await next(); } catch (CallsError e) when (!ctx.Response.HasStarted) { ctx.Response.StatusCode = e.Status; await ctx.Response.WriteAsJsonAsync(new { error = e.Message }); } catch (JsonException) when (!ctx.Response.HasStarted) { ctx.Response.StatusCode = 400; await ctx.Response.WriteAsJsonAsync(new { error = "некоректний JSON" }); } catch (BadHttpRequestException e) when (!ctx.Response.HasStarted) { ctx.Response.StatusCode = e.StatusCode; await ctx.Response.WriteAsJsonAsync(new { error = "некоректний запит" }); } }); // ---------------------------------------------------------------- авторизация CRM для /api/* // Bearer = access_token Supabase из CRM. Workspace — заголовок X-Calls-Workspace (обязателен там, где нужен). var open = new[] { "/api/verify", "/api/config", "/api/call/", "/api/guest/", "/api/admin/" }; app.Use(async (ctx, next) => { var path = ctx.Request.Path.Value ?? ""; if (!path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase)) { await next(); return; } ctx.Response.Headers.CacheControl = "no-store"; if (open.Any(o => o.EndsWith('/') ? path.StartsWith(o, StringComparison.OrdinalIgnoreCase) : path.Equals(o, StringComparison.OrdinalIgnoreCase))) { await next(); return; } var auth = ctx.Request.Headers.Authorization.ToString(); var token = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? auth[7..].Trim() : ""; if (token.Length == 0) { await Deny(ctx, 401, "потрібен заголовок Authorization: Bearer <токен CRM>"); return; } var sa = ctx.RequestServices.GetRequiredService(); var (user, fail) = await sa.VerifyAsync(token, ctx.RequestAborted); if (user is null) { await Deny(ctx, fail == SupabaseAuth.Fail.Unavailable ? 502 : 401, fail switch { SupabaseAuth.Fail.Expired => "термін дії токена минув", SupabaseAuth.Fail.Unavailable => "Supabase недоступний", _ => "токен не прийнято", }); return; } var ws = ctx.Request.Headers["X-Calls-Workspace"].ToString().Trim(); if (ws.Length > 0 && !await sa.CanAccessAsync(user, token, ws, ctx.RequestAborted)) { await Deny(ctx, 403, "немає доступу до воркспейсу"); return; } ctx.Items["user"] = user; ctx.Items["token"] = token; ctx.Items["ws"] = ws; await next(); }); static Task Deny(HttpContext ctx, int status, string error) { ctx.Response.StatusCode = status; return ctx.Response.WriteAsJsonAsync(new { error }); } static CrmUser U(HttpContext c) => (CrmUser)c.Items["user"]!; static string Ws(HttpContext c) => (string)c.Items["ws"]! is { Length: > 0 } ws ? ws : throw new CallsError(400, "потрібен заголовок X-Calls-Workspace"); // ---------------------------------------------------------------- без авторизации app.MapGet("/health", (Db db) => Results.Json(new { status = "ok", dbCreatedAt = db.CreatedAt() })); app.MapGet("/api/config", () => Results.Json(new { crmOrigins, meetDomain = jitsi.Domain, publicUrl, appStoreUrl, playStoreUrl, })); app.MapPost("/api/verify", async (VerifyReq req, SupabaseAuth sa, CancellationToken ct) => { if (string.IsNullOrWhiteSpace(req.access_token)) return Results.Json(new { ok = false, error = "немає access_token" }, statusCode: 400); var (user, fail) = await sa.VerifyAsync(req.access_token, ct); if (user is null) return Results.Json(new { ok = false, error = fail == SupabaseAuth.Fail.Expired ? "термін дії токена минув" : "токен не прийнято" }, statusCode: fail == SupabaseAuth.Fail.Unavailable ? 502 : 401); return Results.Json(new { ok = true, id = user.Id, email = user.Email, name = user.Name, exp = user.Exp }); }); // ---------------------------------------------------------------- участник workspace (токен CRM) app.MapGet("/api/workspaces", async (HttpContext ctx, SupabaseAuth sa, CancellationToken ct) => { var list = await sa.ListWorkspacesAsync(U(ctx), (string)ctx.Items["token"]!, ct) ?? throw new CallsError(502, "не вдалося отримати воркспейси з CRM"); return Results.Json(new { workspaces = list.Select(w => new { id = w.Id, kind = w.Kind, title = w.Title }) }); }); object LinkJson(Store.GuestLink l) => new { url = $"{publicUrl}/g/{l.Token}", updatedAt = DateTimeOffset.FromUnixTimeSeconds(l.UpdatedAt).ToString("O"), updatedByName = l.UpdatedByName, }; app.MapGet("/api/room", (HttpContext ctx, Store store) => { var ws = Ws(ctx); store.Room(ws, U(ctx).Id); return Results.Json(new { workspace = ws, guestLink = LinkJson(store.GetGuestLink(ws, U(ctx))) }); }); app.MapPost("/api/room/guest-link", (HttpContext ctx, Store store) => { var ws = Ws(ctx); store.Room(ws, U(ctx).Id); return Results.Json(new { workspace = ws, guestLink = LinkJson(store.RegenerateGuestLink(ws, U(ctx))) }); }); app.MapPost("/api/join", (HttpContext ctx, Store store) => { var ws = Ws(ctx); store.CheckGate(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 }); }); // ---------------------------------------------------------------- служебное (только с ADMIN_TOKEN из env) // Тестовые комнаты test-* (не workspace CRM) и состояние обработки записей — для проверок без аккаунта CRM. var adminToken = cfg["ADMIN_TOKEN"] ?? ""; bool Admin(HttpContext ctx) => adminToken.Length >= 24 && System.Security.Cryptography.CryptographicOperations.FixedTimeEquals( System.Text.Encoding.UTF8.GetBytes(ctx.Request.Headers["X-Admin-Token"].ToString()), System.Text.Encoding.UTF8.GetBytes(adminToken)); app.MapPost("/api/admin/test-link", (HttpContext ctx, AdminLinkReq req, Store store) => { if (!Admin(ctx)) return Results.Json(new { error = "не знайдено" }, statusCode: 404); var ws = req.workspace ?? ""; if (!System.Text.RegularExpressions.Regex.IsMatch(ws, "^test-[a-z0-9-]{1,40}$")) return Results.Json(new { error = "лише тестові воркспейси test-…" }, statusCode: 400); var bot = new CrmUser("admin-test", null, "Тест", 0); store.Room(ws, bot.Id); var link = req.regenerate == true ? store.RegenerateGuestLink(ws, bot) : store.GetGuestLink(ws, bot); return Results.Json(new { workspace = ws, guestLink = LinkJson(link) }); }); app.MapGet("/api/admin/calls", (HttpContext ctx, CallsRepo calls) => { if (!Admin(ctx)) return Results.Json(new { error = "не знайдено" }, statusCode: 404); return Results.Json(new { calls = calls.Recent(30).Select(c => new { c.Id, c.Room, c.WorkspaceId, c.Status, c.Error, c.Attempts, startedAt = DateTimeOffset.FromUnixTimeSeconds(c.StartedAt).ToString("O"), seconds = (c.EndedAt ?? c.LastSeenAt) - c.StartedAt, participants = c.Participants, hasAudioUrl = c.AudioUrl != null, c.PageId, }) }); }); 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); record AdminLinkReq(string? workspace, bool? regenerate); public partial class Program;