Вики для CRM: ASP.NET Core + SQLite/FTS5 + React/TipTap, авторизация через CRM

This commit is contained in:
crm-wiki agent
2026-10-13 13:29:05 +00:00
parent 59d4e8c686
commit bf1c8632a9
32 changed files with 4449 additions and 0 deletions

133
backend/Wiki.Api/Auth.cs Normal file
View File

@@ -0,0 +1,133 @@
using System.Collections.Concurrent;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Wiki.Api;
public sealed record WikiUser(string Id, string? Email, string? Name, long Exp);
/// <summary>
/// Проверка токена CRM (Supabase access_token) и доступа к workspace.
/// Токены не логируются и не хранятся: в кэше ключ — SHA-256 токена.
/// </summary>
public sealed partial class SupabaseAuth(IHttpClientFactory http, IConfiguration cfg)
{
private readonly string _url = (cfg["Supabase:Url"] ?? "").TrimEnd('/');
private readonly string _key = cfg["Supabase:PublishableKey"] ?? "";
private readonly ConcurrentDictionary<string, (WikiUser User, DateTimeOffset Until)> _users = new();
private readonly ConcurrentDictionary<string, (bool Ok, DateTimeOffset Until)> _ws = new();
private static readonly TimeSpan CacheFor = TimeSpan.FromMinutes(5);
public const string Shared = "shared";
[GeneratedRegex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")]
private static partial Regex UuidRx();
public enum Fail { None, BadToken, Expired, Rejected, Unavailable }
public async Task<(WikiUser? User, Fail Fail)> VerifyAsync(string token, CancellationToken ct)
{
var now = DateTimeOffset.UtcNow;
var key = Hash(token);
if (_users.TryGetValue(key, out var hit) && hit.Until > now && hit.User.Exp > now.ToUnixTimeSeconds())
return (hit.User, Fail.None);
// 1. Разбор JWT: exp и aud.
JsonElement claims;
try
{
var parts = token.Split('.');
if (parts.Length != 3) return (null, Fail.BadToken);
claims = JsonDocument.Parse(Base64Url(parts[1])).RootElement;
}
catch { return (null, Fail.BadToken); }
if (!claims.TryGetProperty("exp", out var expEl) || !expEl.TryGetInt64(out var exp)) return (null, Fail.BadToken);
if (exp <= now.ToUnixTimeSeconds()) return (null, Fail.Expired);
if (!AudIsAuthenticated(claims)) return (null, Fail.Rejected);
// 2. Supabase: GET /auth/v1/user.
HttpResponseMessage r;
try
{
using var req = new HttpRequestMessage(HttpMethod.Get, $"{_url}/auth/v1/user");
req.Headers.Add("apikey", _key);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
r = await http.CreateClient("supabase").SendAsync(req, ct);
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException) { return (null, Fail.Unavailable); }
using (r)
{
if (!r.IsSuccessStatusCode) return (null, (int)r.StatusCode >= 500 ? Fail.Unavailable : Fail.Rejected);
using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct));
var u = doc.RootElement;
if (Str(u, "aud") != "authenticated" || Str(u, "id") is not { } id) return (null, Fail.Rejected);
string? name = null;
if (u.TryGetProperty("user_metadata", out var md) && md.ValueKind == JsonValueKind.Object)
name = Str(md, "name") ?? Str(md, "full_name") ?? Str(md, "display_name");
var user = new WikiUser(id, Str(u, "email"), string.IsNullOrWhiteSpace(name) ? null : name, exp);
_users[key] = (user, now + CacheFor);
if (_users.Count > 5000) Sweep(now);
return (user, Fail.None);
}
}
/// <summary>
/// Нормализует и проверяет workspace. id из клиента не доверяем:
/// shared — для всех, personal:&lt;uid&gt; — только владельцу, &lt;uuid&gt; (custom) — по таблице workspaces с RLS.
/// </summary>
public async Task<bool> CanAccessAsync(WikiUser user, string token, string workspaceId, CancellationToken ct)
{
if (workspaceId == Shared) return true;
if (workspaceId.StartsWith("personal:", StringComparison.Ordinal))
return workspaceId == $"personal:{user.Id}";
if (!UuidRx().IsMatch(workspaceId)) return false;
var now = DateTimeOffset.UtcNow;
var key = user.Id + "|" + workspaceId.ToLowerInvariant();
if (_ws.TryGetValue(key, out var hit) && hit.Until > now) return hit.Ok;
bool ok;
try
{
using var req = new HttpRequestMessage(HttpMethod.Get,
$"{_url}/rest/v1/workspaces?id=eq.{workspaceId.ToLowerInvariant()}&select=id");
req.Headers.Add("apikey", _key);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var r = await http.CreateClient("supabase").SendAsync(req, ct);
if (!r.IsSuccessStatusCode) return false; // ошибку не кэшируем
using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct));
ok = doc.RootElement.ValueKind == JsonValueKind.Array && doc.RootElement.GetArrayLength() > 0;
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException) { return false; }
_ws[key] = (ok, now + CacheFor);
return ok;
}
private void Sweep(DateTimeOffset now)
{
foreach (var (k, v) in _users) if (v.Until <= now) _users.TryRemove(k, out _);
foreach (var (k, v) in _ws) if (v.Until <= now) _ws.TryRemove(k, out _);
}
private static bool AudIsAuthenticated(JsonElement claims)
{
if (!claims.TryGetProperty("aud", out var aud)) return false;
if (aud.ValueKind == JsonValueKind.String) return aud.GetString() == "authenticated";
if (aud.ValueKind == JsonValueKind.Array)
foreach (var a in aud.EnumerateArray()) if (a.ValueKind == JsonValueKind.String && a.GetString() == "authenticated") return true;
return false;
}
private static string? Str(JsonElement e, string p) =>
e.TryGetProperty(p, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
private static string Hash(string s) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(s)));
private static byte[] Base64Url(string s)
{
s = s.Replace('-', '+').Replace('_', '/');
return Convert.FromBase64String(s.PadRight(s.Length + (4 - s.Length % 4) % 4, '='));
}
}

110
backend/Wiki.Api/Db.cs Normal file
View File

@@ -0,0 +1,110 @@
using Microsoft.Data.Sqlite;
namespace Wiki.Api;
/// <summary>Подключения к SQLite и схема. Файл базы лежит на постоянном томе (Storage:DataDir).</summary>
public sealed class Db
{
public string DataDir { get; }
public string ImagesDir { get; }
private readonly string _cs;
public Db(IConfiguration cfg)
{
DataDir = Path.GetFullPath(cfg["Storage:DataDir"] ?? "data");
ImagesDir = Path.Combine(DataDir, "images");
Directory.CreateDirectory(ImagesDir);
_cs = new SqliteConnectionStringBuilder
{
DataSource = Path.Combine(DataDir, "wiki.db"),
Cache = SqliteCacheMode.Shared,
ForeignKeys = true,
}.ToString();
}
public SqliteConnection Open()
{
var c = new SqliteConnection(_cs);
c.Open();
using var cmd = c.CreateCommand();
cmd.CommandText = "PRAGMA busy_timeout=5000;";
cmd.ExecuteNonQuery();
return c;
}
public void Init()
{
using var c = Open();
Exec(c, """
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS meta(
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
INSERT OR IGNORE INTO meta(key, value) VALUES ('created_at', strftime('%Y-%m-%dT%H:%M:%fZ','now'));
INSERT OR IGNORE INTO meta(key, value) VALUES ('schema', '1');
-- Папки и страницы одной таблицей: kind = 'folder' | 'page'.
CREATE TABLE IF NOT EXISTS nodes(
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
parent_id TEXT NULL REFERENCES nodes(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('folder','page')),
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
created_by TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_by TEXT NOT NULL,
updated_by_name TEXT NULL
);
CREATE INDEX IF NOT EXISTS ix_nodes_ws_parent ON nodes(workspace_id, parent_id);
CREATE TABLE IF NOT EXISTS images(
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
page_id TEXT NULL,
mime TEXT NOT NULL,
size INTEGER NOT NULL,
file_name TEXT NOT NULL,
created_at TEXT NOT NULL,
created_by TEXT NOT NULL
);
-- Полнотекстовый поиск по названию и тексту (external content на nodes).
CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
title, content,
content='nodes', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2'
);
CREATE TRIGGER IF NOT EXISTS nodes_ai AFTER INSERT ON nodes BEGIN
INSERT INTO nodes_fts(rowid, title, content) VALUES (new.rowid, new.title, new.content);
END;
CREATE TRIGGER IF NOT EXISTS nodes_ad AFTER DELETE ON nodes BEGIN
INSERT INTO nodes_fts(nodes_fts, rowid, title, content) VALUES ('delete', old.rowid, old.title, old.content);
END;
CREATE TRIGGER IF NOT EXISTS nodes_au AFTER UPDATE OF title, content ON nodes BEGIN
INSERT INTO nodes_fts(nodes_fts, rowid, title, content) VALUES ('delete', old.rowid, old.title, old.content);
INSERT INTO nodes_fts(rowid, title, content) VALUES (new.rowid, new.title, new.content);
END;
""");
}
public string? Meta(string key)
{
using var c = Open();
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT value FROM meta WHERE key=$k";
cmd.Parameters.AddWithValue("$k", key);
return cmd.ExecuteScalar() as string;
}
public static void Exec(SqliteConnection c, string sql)
{
using var cmd = c.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
}

252
backend/Wiki.Api/Program.cs Normal file
View File

@@ -0,0 +1,252 @@
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;
}
}

308
backend/Wiki.Api/Store.cs Normal file
View File

@@ -0,0 +1,308 @@
using System.Security.Cryptography;
using System.Text.RegularExpressions;
using Microsoft.Data.Sqlite;
namespace Wiki.Api;
public sealed record NodeDto(
string Id, string? ParentId, string Kind, string Title, int Version,
string CreatedAt, string UpdatedAt, string UpdatedBy, string? UpdatedByName);
public sealed record Crumb(string Id, string Kind, string Title);
public sealed record PageDto(
string Id, string? ParentId, string Kind, string Title, int Version,
string CreatedAt, string UpdatedAt, string UpdatedBy, string? UpdatedByName,
string Markdown, IReadOnlyList<Crumb> Path);
public sealed record SearchHit(string Id, string Kind, string Title, string Snippet, IReadOnlyList<Crumb> Path);
public sealed record ImageRow(string Id, string WorkspaceId, string Mime, string FileName);
public sealed class WikiError(int status, string message) : Exception(message)
{
public int Status { get; } = status;
}
/// <summary>Доступ к данным вики. Каждый запрос ограничен workspace.</summary>
public sealed partial class Store(Db db)
{
public const int MaxTitle = 200;
public const int MaxContent = 2_000_000;
private const string Cols = "id, parent_id, kind, title, version, created_at, updated_at, updated_by, updated_by_name";
public static string NewId() => Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLowerInvariant();
private static string Now() => DateTime.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'");
public List<NodeDto> Tree(string ws)
{
using var c = db.Open();
using var cmd = c.CreateCommand();
cmd.CommandText = $"SELECT {Cols} FROM nodes WHERE workspace_id=$ws";
cmd.Parameters.AddWithValue("$ws", ws);
using var r = cmd.ExecuteReader();
var list = new List<NodeDto>();
while (r.Read()) list.Add(ReadNode(r));
return list;
}
public PageDto? Get(string ws, string id)
{
using var c = db.Open();
return Get(c, ws, id);
}
private static PageDto? Get(SqliteConnection c, string ws, string id)
{
using var cmd = c.CreateCommand();
cmd.CommandText = $"SELECT {Cols}, content FROM nodes WHERE workspace_id=$ws AND id=$id";
cmd.Parameters.AddWithValue("$ws", ws);
cmd.Parameters.AddWithValue("$id", id);
using var r = cmd.ExecuteReader();
if (!r.Read()) return null;
var n = ReadNode(r);
var content = r.GetString(9);
return new PageDto(n.Id, n.ParentId, n.Kind, n.Title, n.Version, n.CreatedAt, n.UpdatedAt, n.UpdatedBy,
n.UpdatedByName, content, Path(c, ws, n.ParentId));
}
/// <summary>Цепочка папок от корня до parentId включительно (хлебные крошки).</summary>
private static List<Crumb> Path(SqliteConnection c, string ws, string? parentId)
{
var list = new List<Crumb>();
if (parentId is null) return list;
using var cmd = c.CreateCommand();
cmd.CommandText = """
WITH RECURSIVE up(id, parent_id, kind, title, depth) AS (
SELECT id, parent_id, kind, title, 0 FROM nodes WHERE id=$id AND workspace_id=$ws
UNION ALL
SELECT n.id, n.parent_id, n.kind, n.title, up.depth+1 FROM nodes n JOIN up ON n.id = up.parent_id
WHERE n.workspace_id=$ws AND up.depth < 100
)
SELECT id, kind, title FROM up ORDER BY depth DESC
""";
cmd.Parameters.AddWithValue("$ws", ws);
cmd.Parameters.AddWithValue("$id", parentId);
using var r = cmd.ExecuteReader();
while (r.Read()) list.Add(new Crumb(r.GetString(0), r.GetString(1), r.GetString(2)));
return list;
}
public PageDto Create(string ws, string kind, string? title, string? parentId, string? markdown, WikiUser u)
{
var t = CleanTitle(title);
var content = kind == "page" ? CleanContent(markdown) : "";
using var c = db.Open();
using var tx = c.BeginTransaction();
parentId = NullIfEmpty(parentId);
if (parentId is not null) EnsureFolder(c, ws, parentId);
var id = NewId();
var now = Now();
using (var cmd = c.CreateCommand())
{
cmd.CommandText = """
INSERT INTO nodes(id, workspace_id, parent_id, kind, title, content, version, created_at, created_by, updated_at, updated_by, updated_by_name)
VALUES ($id, $ws, $p, $k, $t, $c, 1, $now, $u, $now, $u, $un)
""";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$ws", ws);
cmd.Parameters.AddWithValue("$p", (object?)parentId ?? DBNull.Value);
cmd.Parameters.AddWithValue("$k", kind);
cmd.Parameters.AddWithValue("$t", t);
cmd.Parameters.AddWithValue("$c", content);
cmd.Parameters.AddWithValue("$now", now);
cmd.Parameters.AddWithValue("$u", u.Id);
cmd.Parameters.AddWithValue("$un", (object?)(u.Name ?? u.Email) ?? DBNull.Value);
cmd.ExecuteNonQuery();
}
tx.Commit();
return Get(c, ws, id)!;
}
/// <summary>Сохранение страницы с проверкой версии. Конфликт — WikiError 409, current — актуальная версия.</summary>
public (PageDto? Saved, PageDto? Current) Save(string ws, string id, string? title, string? markdown, int version, WikiUser u)
{
using var c = db.Open();
var cur = Get(c, ws, id) ?? throw new WikiError(404, "страница не найдена");
if (cur.Kind != "page") throw new WikiError(400, "это папка, у неё нет текста");
var t = title is null ? cur.Title : CleanTitle(title);
var content = markdown is null ? cur.Markdown : CleanContent(markdown);
using var cmd = c.CreateCommand();
cmd.CommandText = """
UPDATE nodes SET title=$t, content=$c, version=version+1, updated_at=$now, updated_by=$u, updated_by_name=$un
WHERE id=$id AND workspace_id=$ws AND version=$v
""";
cmd.Parameters.AddWithValue("$t", t);
cmd.Parameters.AddWithValue("$c", content);
cmd.Parameters.AddWithValue("$now", Now());
cmd.Parameters.AddWithValue("$u", u.Id);
cmd.Parameters.AddWithValue("$un", (object?)(u.Name ?? u.Email) ?? DBNull.Value);
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$ws", ws);
cmd.Parameters.AddWithValue("$v", version);
if (cmd.ExecuteNonQuery() == 0) return (null, Get(c, ws, id));
return (Get(c, ws, id), null);
}
/// <summary>Переименование и/или перенос. Переименование страницы увеличивает версию.</summary>
public PageDto Patch(string ws, string id, string? title, bool moveSet, string? parentId, int? version, WikiUser u)
{
using var c = db.Open();
using var tx = c.BeginTransaction();
var cur = Get(c, ws, id) ?? throw new WikiError(404, "не найдено");
if (version is { } v && v != cur.Version) throw new WikiError(409, "страницу уже изменили");
var newTitle = title is null ? cur.Title : CleanTitle(title);
var newParent = moveSet ? NullIfEmpty(parentId) : cur.ParentId;
if (moveSet && newParent is not null)
{
EnsureFolder(c, ws, newParent);
if (newParent == id || Path(c, ws, newParent).Any(p => p.Id == id))
throw new WikiError(400, "нельзя перенести папку внутрь неё самой");
}
var renamed = newTitle != cur.Title;
using (var cmd = c.CreateCommand())
{
cmd.CommandText = """
UPDATE nodes SET title=$t, parent_id=$p,
version = version + $bump, updated_at=$now, updated_by=$u, updated_by_name=$un
WHERE id=$id AND workspace_id=$ws
""";
cmd.Parameters.AddWithValue("$t", newTitle);
cmd.Parameters.AddWithValue("$p", (object?)newParent ?? DBNull.Value);
cmd.Parameters.AddWithValue("$bump", renamed ? 1 : 0);
cmd.Parameters.AddWithValue("$now", Now());
cmd.Parameters.AddWithValue("$u", u.Id);
cmd.Parameters.AddWithValue("$un", (object?)(u.Name ?? u.Email) ?? DBNull.Value);
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$ws", ws);
cmd.ExecuteNonQuery();
}
tx.Commit();
return Get(c, ws, id)!;
}
/// <summary>Удаляет узел со всем содержимым. Возвращает число удалённых узлов.</summary>
public int Delete(string ws, string id)
{
using var c = db.Open();
using var tx = c.BeginTransaction();
int count;
using (var cmd = c.CreateCommand())
{
cmd.CommandText = """
WITH RECURSIVE sub(id) AS (
SELECT id FROM nodes WHERE id=$id AND workspace_id=$ws
UNION ALL
SELECT n.id FROM nodes n JOIN sub ON n.parent_id = sub.id
)
SELECT count(*) FROM sub
""";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$ws", ws);
count = Convert.ToInt32(cmd.ExecuteScalar());
}
if (count == 0) throw new WikiError(404, "не найдено");
using (var cmd = c.CreateCommand())
{
cmd.CommandText = "DELETE FROM nodes WHERE id=$id AND workspace_id=$ws";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$ws", ws);
cmd.ExecuteNonQuery();
}
tx.Commit();
return count;
}
[GeneratedRegex(@"[\p{L}\p{N}_]+")]
private static partial Regex WordRx();
public List<SearchHit> Search(string ws, string q, int limit)
{
var words = WordRx().Matches(q).Select(m => m.Value).Take(8).ToList();
var hits = new List<SearchHit>();
if (words.Count == 0) return hits;
var match = string.Join(" ", words.Select(w => $"\"{w}\"*"));
using var c = db.Open();
using var cmd = c.CreateCommand();
// \u0002 … \u0003 — границы совпадения в сниппете, клиент сам их подсвечивает.
cmd.CommandText = """
SELECT n.id, n.kind, n.title, n.parent_id,
snippet(nodes_fts, 1, char(2), char(3), '…', 14)
FROM nodes_fts JOIN nodes n ON n.rowid = nodes_fts.rowid
WHERE nodes_fts MATCH $q AND n.workspace_id = $ws
ORDER BY bm25(nodes_fts, 8.0, 1.0)
LIMIT $lim
""";
cmd.Parameters.AddWithValue("$q", match);
cmd.Parameters.AddWithValue("$ws", ws);
cmd.Parameters.AddWithValue("$lim", Math.Clamp(limit, 1, 100));
var rows = new List<(string Id, string Kind, string Title, string? Parent, string Snip)>();
using (var r = cmd.ExecuteReader())
while (r.Read())
rows.Add((r.GetString(0), r.GetString(1), r.GetString(2), r.IsDBNull(3) ? null : r.GetString(3),
r.IsDBNull(4) ? "" : r.GetString(4)));
foreach (var x in rows) hits.Add(new SearchHit(x.Id, x.Kind, x.Title, x.Snip, Path(c, ws, x.Parent)));
return hits;
}
public void AddImage(string id, string ws, string? pageId, string mime, long size, string fileName, WikiUser u)
{
using var c = db.Open();
using var cmd = c.CreateCommand();
cmd.CommandText = """
INSERT INTO images(id, workspace_id, page_id, mime, size, file_name, created_at, created_by)
VALUES ($id, $ws, $p, $m, $s, $f, $now, $u)
""";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$ws", ws);
cmd.Parameters.AddWithValue("$p", (object?)pageId ?? DBNull.Value);
cmd.Parameters.AddWithValue("$m", mime);
cmd.Parameters.AddWithValue("$s", size);
cmd.Parameters.AddWithValue("$f", fileName);
cmd.Parameters.AddWithValue("$now", Now());
cmd.Parameters.AddWithValue("$u", u.Id);
cmd.ExecuteNonQuery();
}
public ImageRow? GetImage(string id)
{
using var c = db.Open();
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT id, workspace_id, mime, file_name FROM images WHERE id=$id";
cmd.Parameters.AddWithValue("$id", id);
using var r = cmd.ExecuteReader();
return r.Read() ? new ImageRow(r.GetString(0), r.GetString(1), r.GetString(2), r.GetString(3)) : null;
}
private static void EnsureFolder(SqliteConnection c, string ws, string id)
{
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT kind FROM nodes WHERE id=$id AND workspace_id=$ws";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$ws", ws);
var kind = cmd.ExecuteScalar() as string ?? throw new WikiError(400, "папка назначения не найдена");
if (kind != "folder") throw new WikiError(400, "класть можно только в папку");
}
private static NodeDto ReadNode(SqliteDataReader r) => new(
r.GetString(0), r.IsDBNull(1) ? null : r.GetString(1), r.GetString(2), r.GetString(3), r.GetInt32(4),
r.GetString(5), r.GetString(6), r.GetString(7), r.IsDBNull(8) ? null : r.GetString(8));
private static string CleanTitle(string? t)
{
t = (t ?? "").Trim();
if (t.Length == 0) throw new WikiError(400, "название не может быть пустым");
if (t.Length > MaxTitle) throw new WikiError(400, $"название длиннее {MaxTitle} символов");
return t;
}
private static string CleanContent(string? md)
{
md ??= "";
if (md.Length > MaxContent) throw new WikiError(413, "текст страницы слишком большой");
return md;
}
private static string? NullIfEmpty(string? s) => string.IsNullOrWhiteSpace(s) ? null : s;
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.12" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,16 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"System.Net.Http.HttpClient": "Warning"
}
},
"AllowedHosts": "*",
"Storage": { "DataDir": "data" },
"Crm": { "Origin": "https://test.units.tvorymo.com" },
"Supabase": {
"Url": "https://yjxbkcqsquwbsyoonfkn.supabase.co",
"PublishableKey": "sb_publishable_TQaZAtnBpImL49A-o87bKg_q5sYvhPR"
}
}