Files
crm-wiki/backend/Wiki.Api/Program.cs

269 lines
14 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 Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.StaticFiles;
using Wiki.Api;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<Db>();
builder.Services.AddSingleton<Store>();
builder.Services.AddSingleton<SupabaseAuth>();
builder.Services.AddSingleton<Profiles>();
builder.Services.AddHttpClient("supabase", c => c.Timeout = TimeSpan.FromSeconds(10));
builder.Services.Configure<FormOptions>(o => o.MultipartBodyLengthLimit = Images.MaxBytes + 64 * 1024);
var app = builder.Build();
var cfg = app.Configuration;
// Origin CRM, из которых принимаем crm:auth и в которые разрешено встраивание (через пробел или запятую).
var crmOrigins = (cfg["CRM_ORIGINS"] ?? cfg["Crm:Origins"] ?? cfg["Crm:Origin"] ?? "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('/');
// Ссылка на страницу в CRM для вики, открытой вне iframe: https://<crm>/#/module/<id>?path={path}&ws={ws}.
// Без шаблона (или без {path}) кнопки «Відкрити в CRM» нет. Авторедирект — только CRM_AUTO_REDIRECT=true.
var crmLinkTemplate = (cfg["CRM_LINK_TEMPLATE"] ?? cfg["Crm:LinkTemplate"])?.Trim();
if (string.IsNullOrEmpty(crmLinkTemplate) || !crmLinkTemplate.Contains("{path}")
|| !Uri.TryCreate(crmLinkTemplate.Replace("{path}", "").Replace("{ws}", ""), UriKind.Absolute, out var tplUri)
|| (tplUri.Scheme != "https" && tplUri.Scheme != "http"))
crmLinkTemplate = null;
var crmAutoRedirect = crmLinkTemplate != null
&& string.Equals((cfg["CRM_AUTO_REDIRECT"] ?? cfg["Crm:AutoRedirect"])?.Trim(), "true", StringComparison.OrdinalIgnoreCase);
app.Services.GetRequiredService<Db>().Init();
// ---------------------------------------------------------------- заголовки
// Встраивание только в CRM; X-Frame-Options не ставим (иначе iframe пустой).
var csp = string.Join("; ",
$"frame-ancestors {string.Join(' ', crmOrigins)}",
"default-src 'self'",
"script-src 'self'",
$"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";
h["Referrer-Policy"] = "strict-origin";
await next();
});
// ---------------------------------------------------------------- ошибки
app.Use(async (ctx, next) =>
{
try { await next(); }
catch (WikiError 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 = "некоректний запит" });
}
});
// ---------------------------------------------------------------- авторизация /api/*
// Bearer = access_token Supabase из CRM. Workspace — заголовок X-Wiki-Workspace (по умолчанию shared).
var open = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "/api/verify", "/api/config" };
app.Use(async (ctx, next) =>
{
var path = ctx.Request.Path.Value ?? "";
if (!path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase) || open.Contains(path)) { await next(); return; }
ctx.Response.Headers.CacheControl = "no-store";
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-Wiki-Workspace"].ToString().Trim();
if (ws.Length == 0) ws = SupabaseAuth.Shared;
if (!await sa.CanAccessAsync(user, token, ws, ctx.RequestAborted)) { await Deny(ctx, 403, "немає доступу до воркспейсу"); return; }
// Профиль (имя, аватар из довідника CRM) — для поля «автор»; сбой не мешает запросу.
try { await ctx.RequestServices.GetRequiredService<Profiles>().EnsureAsync(user, token, ctx.RequestAborted); }
catch (Exception e) when (e is not OperationCanceledException) { /* профиль обновится в следующий раз */ }
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 WikiUser U(HttpContext c) => (WikiUser)c.Items["user"]!;
static string Ws(HttpContext c) => (string)c.Items["ws"]!;
// ---------------------------------------------------------------- статика SPA
var types = new FileExtensionContentTypeProvider();
app.UseDefaultFiles();
app.UseStaticFiles(new StaticFileOptions
{
ContentTypeProvider = types,
OnPrepareResponse = s => s.Context.Response.Headers.CacheControl =
s.File.Name.EndsWith(".html") ? "no-cache" : "public, max-age=31536000, immutable",
});
// ---------------------------------------------------------------- открытые
app.MapGet("/health", (Db db) => Results.Ok(new { status = "ok", dbCreatedAt = db.Meta("created_at") }));
app.MapGet("/api/config", () => Results.Ok(new { crmOrigins, crmLinkTemplate, crmAutoRedirect }));
app.MapPost("/api/verify", async (HttpContext ctx, SupabaseAuth sa) =>
{
ctx.Response.Headers.CacheControl = "no-store";
string? token;
try
{
using var doc = await JsonDocument.ParseAsync(ctx.Request.Body, cancellationToken: ctx.RequestAborted);
token = doc.RootElement.TryGetProperty("access_token", out var t) && t.ValueKind == JsonValueKind.String ? t.GetString() : null;
}
catch (JsonException) { return Results.Json(new { ok = false, error = "некоректний запит" }, statusCode: 400); }
if (string.IsNullOrEmpty(token)) return Results.Json(new { ok = false, error = "немає access_token" }, statusCode: 400);
var (u, fail) = await sa.VerifyAsync(token, ctx.RequestAborted);
if (u is null)
return Results.Json(new { ok = false, error = fail == SupabaseAuth.Fail.Expired ? "термін дії токена минув" : "токен не прийнято" },
statusCode: fail == SupabaseAuth.Fail.Unavailable ? 502 : 401);
return Results.Ok(new { ok = true, id = u.Id, email = u.Email, name = u.Name, exp = u.Exp });
});
// ---------------------------------------------------------------- вики
var api = app.MapGroup("/api");
api.MapGet("/me", (HttpContext c) => { var u = U(c); return Results.Ok(new { id = u.Id, email = u.Email, name = u.Name, exp = u.Exp, workspace = Ws(c) }); });
api.MapGet("/tree", (HttpContext c, Store s) => Results.Ok(new { workspace = Ws(c), nodes = s.Tree(Ws(c)) }));
api.MapGet("/nodes/{id}", (HttpContext c, Store s, string id) =>
s.Get(Ws(c), id) is { } p ? Results.Ok(p) : Results.NotFound(new { error = "не знайдено" }));
api.MapGet("/pages/{id}", (HttpContext c, Store s, string id) =>
s.Get(Ws(c), id) is { Kind: "page" } p ? Results.Ok(p) : Results.NotFound(new { error = "сторінку не знайдено" }));
api.MapGet("/pages/{id}/markdown", (HttpContext c, Store s, string id) =>
s.Get(Ws(c), id) is { Kind: "page" } p
? Results.Text(p.Markdown, "text/markdown; charset=utf-8")
: Results.NotFound(new { error = "сторінку не знайдено" }));
api.MapPost("/folders", (HttpContext c, Store s, CreateReq r) =>
{
var p = s.Create(Ws(c), "folder", r.Title, r.ParentId, null, U(c));
return Results.Created($"/api/nodes/{p.Id}", p);
});
api.MapPost("/pages", (HttpContext c, Store s, CreateReq r) =>
{
var p = s.Create(Ws(c), "page", r.Title, r.ParentId, r.Markdown, U(c));
return Results.Created($"/api/pages/{p.Id}", p);
});
api.MapPut("/pages/{id}", (HttpContext c, Store s, string id, SaveReq r) =>
{
if (r.Version is not { } v) return Results.BadRequest(new { error = "потрібне поле version" });
var (saved, current) = s.Save(Ws(c), id, r.Title, r.Markdown, v, U(c));
return saved is not null
? Results.Ok(saved)
: Results.Json(new { error = "сторінку вже змінили, версія застаріла", current }, statusCode: 409);
});
// Переименовать и/или переместить: { title?, parentId? (null — в корень), version? }.
api.MapMethods("/nodes/{id}", ["PATCH"], async (HttpContext c, Store s, string id) =>
{
using var doc = await JsonDocument.ParseAsync(c.Request.Body, cancellationToken: c.RequestAborted);
var b = doc.RootElement;
if (b.ValueKind != JsonValueKind.Object) return Results.BadRequest(new { error = "очікується об'єкт" });
string? title = b.TryGetProperty("title", out var t) && t.ValueKind == JsonValueKind.String ? t.GetString() : null;
var moveSet = b.TryGetProperty("parentId", out var pEl);
string? parent = moveSet && pEl.ValueKind == JsonValueKind.String ? pEl.GetString() : null;
if (moveSet && pEl.ValueKind is not (JsonValueKind.String or JsonValueKind.Null))
return Results.BadRequest(new { error = "parentId — рядок або null" });
int? version = b.TryGetProperty("version", out var vEl) && vEl.TryGetInt32(out var vv) ? vv : null;
return Results.Ok(s.Patch(Ws(c), id, title, moveSet, parent, version, U(c)));
});
api.MapDelete("/nodes/{id}", (HttpContext c, Store s, string id) => Results.Ok(new { deleted = s.Delete(Ws(c), id) }));
api.MapGet("/search", (HttpContext c, Store s, string? q, int? limit) =>
Results.Ok(new { results = s.Search(Ws(c), q ?? "", limit ?? 30) }));
api.MapPost("/images", async (HttpContext c, Store s, Db db) =>
{
if (!c.Request.HasFormContentType) return Results.BadRequest(new { error = "потрібен multipart/form-data з полем file" });
var form = await c.Request.ReadFormAsync(c.RequestAborted);
var file = form.Files["file"];
if (file is null || file.Length == 0) return Results.BadRequest(new { error = "немає файлу" });
if (file.Length > Images.MaxBytes) return Results.Json(new { error = "зображення більше 10 МБ" }, statusCode: 413);
var head = new byte[16];
await using (var st = file.OpenReadStream()) { _ = await st.ReadAtLeastAsync(head, head.Length, false, c.RequestAborted); }
if (Images.Sniff(head) is not { } kind) return Results.BadRequest(new { error = "підтримуються PNG, JPEG, GIF, WebP" });
var id = Store.NewId();
var fileName = id + kind.Ext;
var path = Path.Combine(db.ImagesDir, fileName);
await using (var outS = File.Create(path)) await file.CopyToAsync(outS, c.RequestAborted);
var pageId = form["pageId"].ToString();
s.AddImage(id, Ws(c), string.IsNullOrEmpty(pageId) ? null : pageId, kind.Mime, file.Length, fileName, U(c));
return Results.Created($"/api/images/{id}", new { id, url = $"/api/images/{id}" });
}).DisableAntiforgery();
// Картинку отдаём только после проверки токена и доступа к workspace самой картинки.
api.MapGet("/images/{id}", async (HttpContext c, Store s, Db db, SupabaseAuth sa, string id) =>
{
var img = s.GetImage(id);
if (img is null) return Results.NotFound(new { error = "зображення не знайдено" });
if (img.WorkspaceId != Ws(c) && !await sa.CanAccessAsync(U(c), (string)c.Items["token"]!, img.WorkspaceId, c.RequestAborted))
return Results.NotFound(new { error = "зображення не знайдено" });
var path = Path.Combine(db.ImagesDir, img.FileName);
if (!File.Exists(path)) return Results.NotFound(new { error = "файл зображення втрачено" });
c.Response.Headers.CacheControl = "private, max-age=86400";
return Results.File(path, img.Mime);
});
app.MapFallback("/api/{**rest}", () => Results.NotFound(new { error = "немає такого методу API" }));
app.MapFallbackToFile("index.html", new StaticFileOptions
{
OnPrepareResponse = s => s.Context.Response.Headers.CacheControl = "no-cache",
});
app.Run();
record CreateReq(string? Title, string? ParentId, string? Markdown);
record SaveReq(string? Title, string? Markdown, int? Version);
static class Images
{
public const long MaxBytes = 10 * 1024 * 1024;
public static (string Mime, string Ext)? Sniff(byte[] b)
{
if (b[0] == 0x89 && b[1] == 0x50 && b[2] == 0x4E && b[3] == 0x47) return ("image/png", ".png");
if (b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF) return ("image/jpeg", ".jpg");
if (b[0] == 'G' && b[1] == 'I' && b[2] == 'F' && b[3] == '8') return ("image/gif", ".gif");
if (b[0] == 'R' && b[1] == 'I' && b[2] == 'F' && b[3] == 'F' && b[8] == 'W' && b[9] == 'E' && b[10] == 'B' && b[11] == 'P')
return ("image/webp", ".webp");
return null;
}
}