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