253 lines
12 KiB
C#
253 lines
12 KiB
C#
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;
|
||
}
|
||
}
|