358 lines
17 KiB
C#
358 lines
17 KiB
C#
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;
|
||
}
|