using System.Text.Json; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.StaticFiles; using Wiki.Api; var builder = WebApplication.CreateBuilder(args); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHttpClient("supabase", c => c.Timeout = TimeSpan.FromSeconds(10)); builder.Services.Configure(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:///#/module/?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().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(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(); 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().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; } }