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

228 lines
11 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;
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;