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

358 lines
17 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.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,
Person? Author, Person? LastEditor);
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);
/// <summary>Страница, где упомянута картинка. Title = null для страниц чужого workspace.</summary>
public sealed record ImageUsage(string Id, string Workspace, string? Title);
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, created_by 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);
var createdBy = r.IsDBNull(10) ? null : r.GetString(10);
r.Close();
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), Profiles.Load(c, createdBy), Profiles.Load(c, n.UpdatedBy));
}
/// <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;
}
/// <summary>
/// Удаляет картинку своего workspace: строку в images и файл на томе.
/// Если на неё ссылается хоть одна страница (любого workspace) — 409 и список этих страниц, ничего не удаляется.
/// </summary>
public (bool Deleted, List<ImageUsage> UsedIn) DeleteImage(string ws, string id)
{
using var c = db.Open();
using var tx = c.BeginTransaction();
string fileName;
using (var cmd = c.CreateCommand())
{
cmd.CommandText = "SELECT file_name FROM images WHERE id=$id AND workspace_id=$ws";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$ws", ws);
fileName = cmd.ExecuteScalar() as string ?? throw new WikiError(404, "зображення не знайдено");
}
var used = new List<ImageUsage>();
using (var cmd = c.CreateCommand())
{
// id — 16 hex-символов фиксированной длины, поэтому подстрока не совпадёт с другим id.
cmd.CommandText = "SELECT id, workspace_id, title FROM nodes WHERE instr(content, $ref) > 0 ORDER BY workspace_id, title";
cmd.Parameters.AddWithValue("$ref", "/api/images/" + id);
using var r = cmd.ExecuteReader();
while (r.Read())
{
var own = r.GetString(1) == ws;
// Названия страниц чужого workspace не раскрываем.
used.Add(new ImageUsage(r.GetString(0), r.GetString(1), own ? r.GetString(2) : null));
}
}
if (used.Count > 0) return (false, used);
using (var cmd = c.CreateCommand())
{
cmd.CommandText = "DELETE FROM images WHERE id=$id AND workspace_id=$ws";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$ws", ws);
cmd.ExecuteNonQuery();
}
tx.Commit();
File.Delete(System.IO.Path.Combine(db.ImagesDir, System.IO.Path.GetFileName(fileName)));
return (true, used);
}
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;
}