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

298 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text.Json;
using Calls.Api;
using Calls.Api.Recording;
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));
// Запись и транскрипция (см. README «Запис і транскрипція»).
builder.Services.AddSingleton(sp => RecordingOptions.From(sp.GetRequiredService<IConfiguration>()));
builder.Services.AddSingleton<CallGate>();
builder.Services.AddSingleton<CallsRepo>();
builder.Services.AddSingleton<ProsodyRooms>();
builder.Services.AddSingleton<JibriClient>();
builder.Services.AddSingleton<Transcriber>();
builder.Services.AddSingleton<CrmSession>();
builder.Services.AddSingleton<AudioStorage>();
builder.Services.AddSingleton<WikiPublisher>();
builder.Services.AddHttpClient("jitsi", c => c.Timeout = TimeSpan.FromSeconds(5));
builder.Services.AddHttpClient("jibri-stop", c => c.Timeout = TimeSpan.FromSeconds(60));
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<CallMonitor>();
builder.Services.AddHostedService<RecordingProcessor>();
}
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();
// Общий каталог записей (bind с хоста) создаётся Docker под root, а Jibri пишет как s6 (uid 1000):
// отдаём каталог ему; модуль работает от root и читает/удаляет файлы.
{
var rec = app.Services.GetRequiredService<RecordingOptions>();
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<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/", "/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<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.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;