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

253 lines
12 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.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;
var crmOrigin = (cfg["Crm:Origin"] ?? "https://test.units.tvorymo.com").TrimEnd('/');
// Откуда грузятся стили CRM (ссылки в index.html).
var crmAssets = (cfg["Crm:AssetsOrigin"] ?? "https://test.units.tvorymo.com").TrimEnd('/');
app.Services.GetRequiredService<Db>().Init();
// ---------------------------------------------------------------- заголовки
// Встраивание только в CRM; X-Frame-Options не ставим (иначе iframe пустой).
var csp = string.Join("; ",
$"frame-ancestors {crmOrigin}",
"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, "нет доступа к workspace"); 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 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 { crmOrigin }));
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;
}
}