Вики для 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

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
**/bin/
**/obj/
**/node_modules/
**/dist/
**/wwwroot/
.git/
inbox/
data/
deploy.json

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
**/bin/
**/obj/
**/node_modules/
**/dist/
backend/Wiki.Api/wwwroot/
backend/Wiki.Api/data/
data/
inbox/
deploy.json
*.user

30
CLAUDE.md Normal file
View File

@@ -0,0 +1,30 @@
# crm-wiki
Вики (база знаний) для CRM Tvorymo, встраивается в CRM через iframe в боковой панели.
**Полная спецификация — `SPEC.md` в этой папке. Начинай с неё.**
## Окружение
- Код: Gitea `gitea.tes.gd`, репозиторий `admin/crm-wiki`, env `GIT_URL`, `GIT_USER`, `GIT_KEY`. Gitea создала репозиторий с начальным коммитом в `main`, поэтому сначала клонируй его, а не делай `git init`.
- Деплой: Coolify storage, env `COOLIFY_URL`, `COOLIFY_KEY` (профиль «Storage coolify»).
Деплоить через Coolify v4 REST API (скилл `deploying-to-coolify-via-api`), fire-and-forget.
Упаковка .NET-приложения для Coolify: скилл `preparing-dotnet-react-app-for-coolify`.
- **Постоянный том обязателен:** файл SQLite и картинки должны лежать на named volume в Coolify, иначе пропадут при пересборке.
- .NET SDK есть локально (`dotnet --list-sdks`: 8, 9, 10). Сборка для продакшена выполняется в Dockerfile.
- Домен: `https://wiki-crm.tes.gd` (HTTPS, домены *.tes.gd на этом Coolify работают).
- CRM: `https://test.units.tvorymo.com`. Supabase: `https://yjxbkcqsquwbsyoonfkn.supabase.co`.
## Образец встраивания
Тестовая страница `https://test-crm.tes.gd` уже работает: авторизация через postMessage, проверка токена в Supabase, стили CRM.
- Репозиторий: `https://gitea.tes.gd/admin/crm-embed-test.git` (те же `GIT_USER`/`GIT_KEY` дают доступ на чтение).
- Рабочая копия на этом сервере: `/app/data/home/crm_embed_test` (только читать, не менять).
## Supabase publishable key
`sb_publishable_TQaZAtnBpImL49A-o87bKg_q5sYvhPR` (получен от Ланы 2026-10-13; публичный по дизайну)
## Правила
- Токены CRM/Supabase не логировать (ни браузер, ни сервер).
- Никогда не печатать `GIT_KEY` / `COOLIFY_KEY`.

35
Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
# syntax=docker/dockerfile:1.7
# 1. Фронтенд
FROM node:22-alpine AS web
WORKDIR /src
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY frontend/ ./
RUN npm run build
# 2. Бэкенд
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS api
WORKDIR /src
COPY backend/Wiki.Api/Wiki.Api.csproj backend/Wiki.Api/
RUN dotnet restore backend/Wiki.Api/Wiki.Api.csproj
COPY backend/Wiki.Api/ backend/Wiki.Api/
RUN dotnet publish backend/Wiki.Api/Wiki.Api.csproj -c Release -o /out --no-restore /p:UseAppHost=false
# 3. Runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=api /out ./
COPY --from=web /src/dist ./wwwroot
# /data — постоянный том Coolify (SQLite + картинки)
RUN mkdir -p /data
ENV ASPNETCORE_ENVIRONMENT=Production \
ASPNETCORE_URLS=http://+:8080 \
Storage__DataDir=/data
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/health || exit 1
ENTRYPOINT ["dotnet", "Wiki.Api.dll"]

View File

@@ -1,2 +1,73 @@
# crm-wiki # crm-wiki
Вики (база знаний) для CRM Tvorymo. Открывается в iframe из боковой панели CRM, пользователя узнаёт по токену CRM (postMessage `crm:ready` → `crm:auth`).
- Сайт: https://wiki-crm.tes.gd
- Спецификация: [SPEC.md](SPEC.md)
- Стек: ASP.NET Core 10 (minimal API) + SQLite (FTS5), фронтенд Vite + React + TipTap.
- Данные: `/data/wiki.db` и `/data/images/` на постоянном томе `wiki-data` (Coolify).
## Авторизация
Каждый запрос к `/api/*` (кроме `/api/verify` и `/api/config`) требует:
| Заголовок | Значение |
|---|---|
| `Authorization` | `Bearer <access_token Supabase из CRM>` |
| `X-Wiki-Workspace` | `shared` (по умолчанию), `personal:<uid>` или `<uuid>` своего workspace |
Сервер проверяет токен: `exp`, `aud = "authenticated"` и `GET <SUPABASE_URL>/auth/v1/user` с publishable-ключом (результат кэшируется на 5 минут по SHA-256 токена). Workspace из запроса не доверяется: `shared` доступен всем, `personal:<uid>` только владельцу `uid`, произвольный `<uuid>` проверяется запросом `GET /rest/v1/workspaces?id=eq.<uuid>` с токеном пользователя (RLS Supabase). Ответы: `401` — токен не принят или истёк, `403` — нет доступа к workspace.
Пока CRM не передаёт workspace (задача Олега `mv6p5b92slw1an`), фронтенд работает в `shared`. Когда в `crm:auth` появится `workspace: { id, kind, title }`, он подхватится автоматически: `kind: "shared"` → `shared`, `kind: "personal"` → `personal:<uid>`, иначе `id`. Сообщение о смене workspace тоже обрабатывается (`{ type: "crm:workspace", workspace: {…} }`), если Олег выберет другое имя — поправить в `frontend/src/crm.ts`.
## REST API
Все ответы JSON (кроме `/markdown` и картинок). Ошибки: `{ "error": "…" }`.
| Метод | Путь | Что делает |
|---|---|---|
| `GET` | `/health` | `{ status, dbCreatedAt }` — без авторизации; `dbCreatedAt` не меняется, пока жив том с базой |
| `GET` | `/api/config` | `{ crmOrigin }` — без авторизации |
| `POST` | `/api/verify` | `{ access_token }` → `{ ok, id, email, name, exp }` — без авторизации |
| `GET` | `/api/me` | текущий пользователь и workspace |
| `GET` | `/api/tree` | `{ workspace, nodes: [Node] }` — все папки и страницы workspace без текста |
| `GET` | `/api/nodes/{id}` | папка или страница (Page) |
| `GET` | `/api/pages/{id}` | страница (Page), `404` для папки |
| `GET` | `/api/pages/{id}/markdown` | текст страницы, `text/markdown` |
| `POST` | `/api/pages` | `{ title, parentId?, markdown? }` → `201` Page |
| `POST` | `/api/folders` | `{ title, parentId? }` → `201` Page (kind = folder) |
| `PUT` | `/api/pages/{id}` | `{ title?, markdown?, version }` → Page; если `version` устарела — `409 { error, current }` |
| `PATCH` | `/api/nodes/{id}` | переименовать/переместить: `{ title?, parentId?, version? }`; `parentId: null` — в корень. Перенос в себя/потомка или в страницу — `400` |
| `DELETE` | `/api/nodes/{id}` | удалить узел со всем содержимым → `{ deleted: N }` |
| `GET` | `/api/search?q=…&limit=30` | `{ results: [{ id, kind, title, snippet, path }] }` — FTS5 по названию и тексту, префиксный поиск по словам; совпадения в `snippet` обрамлены `\u0002…\u0003` |
| `POST` | `/api/images` | `multipart/form-data`: `file` (PNG/JPEG/GIF/WebP, до 10 МБ), `pageId?` → `201 { id, url }` |
| `GET` | `/api/images/{id}` | картинка; нужен токен и доступ к workspace картинки |
```ts
type Node = { id, parentId: string|null, kind: "folder"|"page", title, version: number,
createdAt, updatedAt, updatedBy /* uid */, updatedByName };
type Page = Node & { markdown: string, path: { id, kind, title }[] /* папки от корня */ };
```
Формат текста — Markdown (GFM: таблицы, списки задач). Ссылка на другую страницу: `[текст](/p/<id>)`, картинка: `![alt](/api/images/<id>)`. Ссылки по id не ломаются при переименовании и переносе.
Пример (агент или скрипт):
```sh
curl -H "Authorization: Bearer $TOKEN" https://wiki-crm.tes.gd/api/tree
curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"Регламент","markdown":"# Привет"}' https://wiki-crm.tes.gd/api/pages
```
## Заголовки
`Content-Security-Policy: frame-ancestors https://test.units.tvorymo.com; …` (без `X-Frame-Options`). Origin CRM задаётся `Crm__Origin`, домен стилей CRM — `Crm__AssetsOrigin`.
## Разработка
```sh
cd backend/Wiki.Api && ASPNETCORE_URLS=http://127.0.0.1:5080 dotnet run # база в ./data
cd frontend && npm i && npm run dev # Vite, /api проксируется на 127.0.0.1:5080
```
Настройки (env): `Storage__DataDir`, `Crm__Origin`, `Crm__AssetsOrigin`, `Supabase__Url`, `Supabase__PublishableKey`.

46
SPEC.md Normal file
View File

@@ -0,0 +1,46 @@
---
добавлено: 2026-10-13
---
# Спецификация. Вики для CRM
База знаний для людей и агентов, отдельный сайт, встраивается в CRM через боковую панель. Задача владельца в CRM: «Додати в CRM вікі-сторінки зі структурою папок» (`mv2pby2qbv9vdj`, проект Tvorymo shotam). Как встраивать: [[Встраивание страниц в CRM]].
## Стек
- Бэкенд на C# (ASP.NET Core): REST API, чтобы к вики было легко обращаться из скиллов и других сервисов.
- Данные в SQLite, нагрузка небольшая. Файл базы на постоянном томе Coolify, иначе пропадёт при пересборке.
- Хостинг: наш сервер, Coolify storage, код в `gitea.tes.gd`. Агента (`crm-wiki`) создаёт админ.
- Домен: `https://wiki-crm.tes.gd`.
- Картинки хранятся на диске, на том же постоянном томе, что и SQLite. Отдаются только после проверки токена и доступа к workspace страницы.
## Авторизация
- Токен пользователя CRM приходит через `postMessage` (`crm:ready` → `crm:auth`), как на тестовой странице `https://test-crm.tes.gd`.
- Токен нужен только для входа: сервер вики проверяет его через Supabase (`GET /auth/v1/user` с publishable-ключом, `exp`, `aud = "authenticated"`). Данные вики в Supabase не хранятся.
- Агенты получают такой же доступ, как люди. Отдельный скилл для работы с вики сделаем позже, сейчас на агентах не фокусируемся.
## Workspace
- Сейчас CRM workspace во встроенную страницу не передаёт: в `crm:auth` только `access_token`, `expires_at`, `user { id, email, name }` (проверено по `js/pagebridge.js` 2026-10-13). Выбранный workspace CRM держит в `localStorage`.
- Нужна доработка протокола от Олега (задача в CRM): поле `workspace: { id, kind, title }` в `crm:auth` и новое сообщение при переключении workspace.
- Сервер вики id из сообщения не доверяет, а сверяет доступ: `personal:<uid>` только для владельца, `shared` для всех, `custom` проверяется запросом к таблице `workspaces` Supabase с токеном пользователя (RLS).
- У каждой папки и страницы поле `workspace_id`, дерево показывает только текущий workspace.
## Первая версия
1. Страницы: название и текст в визуальном редакторе как в Notion (заголовки, списки, жирный, ссылки, таблицы, картинки). Хранить текст в Markdown или в JSON редактора с экспортом в Markdown — решает агент, но API должен отдавать Markdown.
2. Папки с любой вложенностью, дерево сбоку (раскрыть, свернуть), хлебные крошки сверху.
3. Действия: создать, переименовать, переместить, удалить с подтверждением.
4. Ссылки между страницами по id: не ломаются при переименовании и переносе.
5. Поиск по названиям и тексту (SQLite FTS5).
6. Стили CRM: `tvorymo-ui.css` и `crm.css` ссылками, `body.side-off`.
7. Защита от одновременной правки: проверка версии страницы при сохранении.
## Потом
- История изменений и откат версий.
- Скилл для агентов.
## Зависимости
- Фильтр по workspace зависит от задачи Олега `mv6p5b92slw1an` (она блокирует задачу вики в CRM). До её выкатки делать всё остальное; workspace брать из сообщения, если поле есть, иначе временно работать в общем workspace (`shared`).

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"
}
}

28
docker-compose.yml Normal file
View File

@@ -0,0 +1,28 @@
services:
wiki:
build:
context: .
dockerfile: Dockerfile
expose:
- "8080"
environment:
SERVICE_FQDN_WIKI_8080: /
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: "http://+:8080"
Storage__DataDir: /data
Crm__Origin: "https://test.units.tvorymo.com"
Crm__AssetsOrigin: "https://test.units.tvorymo.com"
Supabase__Url: "https://yjxbkcqsquwbsyoonfkn.supabase.co"
# publishable-ключ публичный по дизайну
Supabase__PublishableKey: "sb_publishable_TQaZAtnBpImL49A-o87bKg_q5sYvhPR"
volumes:
- wiki-data:/data
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
volumes:
wiki-data:

8
frontend/.oxlintrc.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

17
frontend/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Вики · CRM Tvorymo</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- Стили CRM ссылками: сначала дизайн-система, затем crm.css (порядок важен). -->
<link rel="stylesheet" href="https://test.units.tvorymo.com/tvorymo-crm/css/tvorymo-ui.css">
<link rel="stylesheet" href="https://test.units.tvorymo.com/tvorymo-crm/css/crm.css">
</head>
<body class="side-off">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2001
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
frontend/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@tiptap/extension-image": "^3.32.0",
"@tiptap/extension-list": "^3.32.0",
"@tiptap/extension-table": "^3.32.0",
"@tiptap/extensions": "^3.32.0",
"@tiptap/markdown": "^3.32.0",
"@tiptap/pm": "^3.32.0",
"@tiptap/react": "^3.32.0",
"@tiptap/starter-kit": "^3.32.0",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"oxlint": "^1.81.0",
"typescript": "~6.0.2",
"vite": "^8.3.0"
}
}

280
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,280 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Api, ApiError, type Crumb, type Node, type Page, type SearchHit } from "./api";
import { useAuth } from "./crm";
import { ConfirmDialog, MoveDialog, PromptDialog } from "./Dialogs";
import { Icon } from "./Icon";
import { PageView } from "./PageView";
import { hrefOf, navigate, useRoute } from "./router";
import { childrenMap, Tree } from "./Tree";
type Dlg =
| { kind: "new"; what: "page" | "folder"; parentId: string | null }
| { kind: "rename"; node: Node }
| { kind: "move"; node: Node }
| { kind: "delete"; node: Node; count: number }
| null;
export default function App() {
const auth = useAuth();
if (auth.status === "ok") return <Wiki ws={auth.workspace} wsTitle={auth.workspaceTitle} userName={auth.user?.name || auth.user?.email || ""} />;
const text = {
standalone: <>Эта страница работает внутри CRM Tvorymo. Откройте её из боковой панели CRM: она пришлёт авторизацию (<span className="code">crm:auth</span>).</>,
waiting: <>Жду авторизацию от CRM…</>,
checking: <>Проверяю пользователя CRM…</>,
error: <>Не удалось войти: {auth.error}. Обновите страницу CRM.</>,
logout: <>Вы вышли из CRM. Войдите снова, чтобы открыть вики.</>,
}[auth.status];
return (
<main id="main"><div className="page">
<div className="head"><div><h1>Вики</h1><div className="sub">База знаний <b>CRM Tvorymo</b></div></div></div>
<div className="wsinfo">
<Icon name="page" />
<div className="grow">
<span className={"chip " + (auth.status === "error" ? "dng" : "gold")}>
{auth.status === "error" ? "ошибка авторизации" : auth.status === "logout" ? "нет сессии" : "жду авторизацию от CRM"}
</span>
<div className="small mt-6">{text}</div>
</div>
</div>
</div></main>
);
}
function Wiki(props: { ws: string; wsTitle?: string; userName: string }) {
const route = useRoute();
const [nodes, setNodes] = useState<Node[] | null>(null);
const [treeErr, setTreeErr] = useState("");
const [page, setPage] = useState<Page | null>(null);
const [pageErr, setPageErr] = useState("");
const [dlg, setDlg] = useState<Dlg>(null);
const [q, setQ] = useState("");
const [hits, setHits] = useState<SearchHit[] | null>(null);
const [sideOpen, setSideOpen] = useState(false);
const reloadTree = useCallback(async () => {
try { setNodes((await Api.tree()).nodes); setTreeErr(""); }
catch (e) { setTreeErr((e as Error).message); }
}, []);
useEffect(() => { setNodes(null); void reloadTree(); }, [props.ws, reloadTree]);
// Сменился workspace в CRM — открытая страница из другого workspace, уходим в корень.
const [wsSeen, setWsSeen] = useState(props.ws);
if (wsSeen !== props.ws) { setWsSeen(props.ws); void navigate("/", true); }
const currentId = route.kind === "root" ? null : route.id;
useEffect(() => {
setSideOpen(false);
if (!currentId) { setPage(null); setPageErr(""); return; }
let live = true;
setPageErr("");
Api.page(currentId)
.then((p) => { if (live) setPage(p); })
.catch((e) => { if (live) { setPage(null); setPageErr(e instanceof ApiError && e.status === 404 ? "Не найдено: страницу удалили или она в другом workspace." : (e as Error).message); } });
return () => { live = false; };
}, [currentId, props.ws]);
// Поиск с задержкой.
useEffect(() => {
const s = q.trim();
if (!s) { setHits(null); return; }
const t = window.setTimeout(() => {
Api.search(s).then((r) => setHits(r.results)).catch(() => setHits([]));
}, 250);
return () => window.clearTimeout(t);
}, [q, props.ws]);
const kids = useMemo(() => childrenMap(nodes ?? []), [nodes]);
const byId = useMemo(() => new Map((nodes ?? []).map((n) => [n.id, n])), [nodes]);
const create = async (what: "page" | "folder", title: string, parentId: string | null) => {
const p = what === "page" ? await Api.createPage(title, parentId) : await Api.createFolder(title, parentId);
await reloadTree();
void navigate(hrefOf(p.kind, p.id));
};
const askDelete = (n: Node) => {
let count = 0;
const walk = (id: string) => { count++; (kids.get(id) ?? []).forEach((c) => walk(c.id)); };
walk(n.id);
setDlg({ kind: "delete", node: n, count });
};
const current: Node | undefined = currentId ? byId.get(currentId) : undefined;
const crumbs: Crumb[] = page && page.id === currentId ? page.path : [];
const wsLabel = props.ws === "shared" ? "Общий" : props.ws.startsWith("personal:") ? "Мой" : props.wsTitle || "Workspace";
const listing = (parentId: string | null) => {
const list = kids.get(parentId) ?? [];
if (!list.length) return <div className="empty">Здесь пока пусто.</div>;
return list.map((n) => (
<a key={n.id} className="mrow" href={hrefOf(n.kind, n.id)} onClick={(e) => { e.preventDefault(); void navigate(hrefOf(n.kind, n.id)); }}>
<Icon name={n.kind === "folder" ? "folder" : "page"} />
<div className="t">{n.title}<small>{n.kind === "folder" ? `${kids.get(n.id)?.length ?? 0} внутри` : `изменено ${new Date(n.updatedAt).toLocaleDateString("uk-UA")}${n.updatedByName ? " · " + n.updatedByName : ""}`}</small></div>
</a>
));
};
const actions = (n: Node) => (
<div className="row wrap">
{n.kind === "folder" && <>
<button className="btn sm gold" onClick={() => setDlg({ kind: "new", what: "page", parentId: n.id })}><Icon name="plus" />Страница</button>
<button className="btn sm" onClick={() => setDlg({ kind: "new", what: "folder", parentId: n.id })}><Icon name="folder" />Папка</button>
<button className="btn sm ghost" onClick={() => setDlg({ kind: "rename", node: n })}><Icon name="edit" />Переименовать</button>
</>}
<button className="btn sm ghost" onClick={() => setDlg({ kind: "move", node: n })}><Icon name="move" />Переместить</button>
{n.kind === "page" && <button className="btn sm ghost" title="Ссылка вида /p/<id> не ломается при переименовании и переносе"
onClick={() => void navigator.clipboard?.writeText(`[${n.title}](/p/${n.id})`)}><Icon name="copy" />Markdown-ссылка</button>}
<button className="btn sm ghost danger" onClick={() => askDelete(n)}><Icon name="trash" />Удалить</button>
</div>
);
let content: React.ReactNode;
if (route.kind === "root") {
content = <>
<div className="head">
<div><h1>Вики</h1><div className="sub">Workspace: <b>{wsLabel}</b></div></div>
</div>
<div className="row wrap">
<button className="btn sm gold" onClick={() => setDlg({ kind: "new", what: "page", parentId: null })}><Icon name="plus" />Страница</button>
<button className="btn sm" onClick={() => setDlg({ kind: "new", what: "folder", parentId: null })}><Icon name="folder" />Папка</button>
</div>
<div className="sec"><h2>Содержимое <b>{kids.get(null)?.length ?? 0}</b></h2></div>
{nodes ? listing(null) : <div className="empty">Загрузка…</div>}
</>;
} else if (pageErr) {
content = <div className="empty">{pageErr} <a href="/" onClick={(e) => { e.preventDefault(); void navigate("/"); }}>На главную</a></div>;
} else if (!page || page.id !== currentId) {
content = <div className="empty">Загрузка…</div>;
} else if (page.kind === "folder") {
const n = current ?? page;
content = <>
<div className="head"><div className="min-w-0"><h1 className="trunc">{n.title}</h1><div className="sub">Папка</div></div></div>
{actions(n)}
<div className="sec"><h2>Содержимое <b>{kids.get(n.id)?.length ?? 0}</b></h2></div>
{listing(n.id)}
</>;
} else {
content = <>
<PageView key={page.id} page={page} nodes={nodes ?? []}
onSaved={(p, titleChanged) => {
setNodes((list) => list?.map((x) => (x.id === p.id ? { ...x, title: p.title, version: p.version, updatedAt: p.updatedAt, updatedByName: p.updatedByName } : x)) ?? list);
if (titleChanged) void reloadTree();
}} />
<div className="sep-top mt-24 pt-14">{actions(current ?? page)}</div>
</>;
}
return (
<main id="main" className="wide">
<div className={"wk" + (sideOpen ? " side-open" : "")}>
<aside className="wk-side" aria-label="Страницы вики">
<div className="qa">
<input placeholder="Поиск по вики…" value={q} onChange={(e) => setQ(e.target.value)} aria-label="Поиск" />
{q && <button className="go" onClick={() => setQ("")} title="Очистить"><Icon name="x" /></button>}
</div>
{hits ? (
<div className="mt-10">
<div className="sec"><h2>Найдено <b>{hits.length}</b></h2></div>
{hits.length === 0 && <div className="empty">Ничего не нашлось.</div>}
{hits.map((h) => (
<a key={h.id} className="mrow" href={hrefOf(h.kind, h.id)} onClick={(e) => { e.preventDefault(); void navigate(hrefOf(h.kind, h.id)); }}>
<Icon name={h.kind === "folder" ? "folder" : "page"} />
<div className="t">{h.title}
{h.path.length > 0 && <small>{h.path.map((c) => c.title).join(" / ")}</small>}
{h.snippet && <small><Snippet s={h.snippet} /></small>}
</div>
</a>
))}
</div>
) : (
<>
<div className="sec">
<h2>{wsLabel}</h2>
<div className="row">
<button className="icon-btn sm" title="Новая папка" aria-label="Новая папка" onClick={() => setDlg({ kind: "new", what: "folder", parentId: null })}><Icon name="folder" /></button>
<button className="icon-btn sm" title="Новая страница" aria-label="Новая страница" onClick={() => setDlg({ kind: "new", what: "page", parentId: null })}><Icon name="plus" /></button>
</div>
</div>
<a className={"wk-row wk-home" + (route.kind === "root" ? " on" : "")} href="/" onClick={(e) => { e.preventDefault(); void navigate("/"); }}>
<span className="wk-link"><Icon name="home" /><span>Главная</span></span>
</a>
{treeErr && <div className="err">{treeErr}</div>}
{nodes ? <Tree nodes={nodes} ws={props.ws} currentId={currentId} onAddPage={(pid) => setDlg({ kind: "new", what: "page", parentId: pid })} />
: !treeErr && <div className="empty">Загрузка…</div>}
</>
)}
<div className="small mute2 mt-20">{props.userName}</div>
</aside>
<section className="wk-main min-w-0">
<nav className="wk-crumbs" aria-label="Путь">
<button className="icon-btn sm wk-menu" onClick={() => setSideOpen((v) => !v)} title="Страницы" aria-label="Страницы"><Icon name="menu" /></button>
<a href="/" onClick={(e) => { e.preventDefault(); void navigate("/"); }}>Вики</a>
{crumbs.map((c) => (
<span key={c.id} className="row">
<span className="mute2">/</span>
<a href={hrefOf(c.kind, c.id)} onClick={(e) => { e.preventDefault(); void navigate(hrefOf(c.kind, c.id)); }}>{c.title}</a>
</span>
))}
{current && route.kind !== "root" && <span className="row"><span className="mute2">/</span><span className="trunc">{current.title}</span></span>}
</nav>
{content}
</section>
</div>
{dlg?.kind === "new" && (
<PromptDialog title={dlg.what === "page" ? "Новая страница" : "Новая папка"} label="Название" ok="Создать"
onClose={() => setDlg(null)} onOk={(t) => create(dlg.what, t, dlg.parentId)} />
)}
{dlg?.kind === "rename" && (
<PromptDialog title="Переименовать" label="Название" initial={dlg.node.title} ok="Сохранить"
onClose={() => setDlg(null)}
onOk={async (t) => { const p = await Api.patch(dlg.node.id, { title: t }); await reloadTree(); if (page?.id === p.id) setPage(p); }} />
)}
{dlg?.kind === "move" && nodes && (
<MoveDialog node={dlg.node} nodes={nodes} onClose={() => setDlg(null)}
onOk={async (pid) => {
const p = await Api.patch(dlg.node.id, { parentId: pid });
await reloadTree();
if (page?.id === p.id) setPage((old) => (old ? { ...old, parentId: p.parentId, path: p.path } : old));
}} />
)}
{dlg?.kind === "delete" && (
<ConfirmDialog title="Удалить" danger ok="Удалить"
text={dlg.node.kind === "folder"
? <>Удалить папку <b>«{dlg.node.title}»</b> и всё, что в ней ({dlg.count - 1} {plural(dlg.count - 1)})? Отменить нельзя.</>
: <>Удалить страницу <b>«{dlg.node.title}»</b>? Отменить нельзя.</>}
onClose={() => setDlg(null)}
onOk={async () => {
await Api.remove(dlg.node.id);
const parent = dlg.node.parentId;
await reloadTree();
if (currentId === dlg.node.id || (currentId && isInside(currentId, dlg.node.id, byId)))
void navigate(parent ? hrefOf("folder", parent) : "/", true);
}} />
)}
</main>
);
}
function isInside(id: string, ancestor: string, byId: Map<string, Node>) {
let cur = byId.get(id);
let guard = 0;
while (cur?.parentId && guard++ < 100) { if (cur.parentId === ancestor) return true; cur = byId.get(cur.parentId); }
return false;
}
function plural(n: number) {
const m10 = n % 10, m100 = n % 100;
if (m10 === 1 && m100 !== 11) return "элемент";
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return "элемента";
return "элементов";
}
/** Сниппет поиска: \u0002…\u0003 — совпадение. */
function Snippet({ s }: { s: string }) {
const parts = s.split(/(\u0002[^\u0003]*\u0003)/);
return <>{parts.map((p, i) => (p.startsWith("\u0002") ? <b key={i} className="gold">{p.slice(1, -1)}</b> : <span key={i}>{p}</span>))}</>;
}

158
frontend/src/Dialogs.tsx Normal file
View File

@@ -0,0 +1,158 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import type { Node } from "./api";
import { Icon } from "./Icon";
/** Аркуш CRM (#sheet-root > .backdrop + .sheet). */
export function Sheet(props: { title: string; onClose: () => void; children: ReactNode; footer?: ReactNode }) {
useEffect(() => {
const k = (e: KeyboardEvent) => { if (e.key === "Escape") props.onClose(); };
window.addEventListener("keydown", k);
document.body.classList.add("lock");
return () => { window.removeEventListener("keydown", k); document.body.classList.remove("lock"); };
}, [props.onClose]);
return (
<div id="sheet-root" className="open">
<div className="backdrop" onClick={props.onClose} />
<div className="sheet" role="dialog" aria-modal="true" aria-label={props.title}>
<div className="grab" />
<div className="top">
<span className="tt">{props.title}</span>
<button className="icon-btn x" onClick={props.onClose} title="Закрыть"><Icon name="x" /></button>
</div>
<div className="sc">{props.children}</div>
{props.footer && <div className="bot">{props.footer}</div>}
</div>
</div>
);
}
export function PromptDialog(props: {
title: string; label: string; initial?: string; ok: string;
onOk: (v: string) => Promise<void> | void; onClose: () => void;
}) {
const [v, setV] = useState(props.initial ?? "");
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
const ref = useRef<HTMLInputElement>(null);
useEffect(() => { ref.current?.focus(); ref.current?.select(); }, []);
const submit = async () => {
if (!v.trim()) { setErr("Введите название"); return; }
setBusy(true);
try { await props.onOk(v.trim()); props.onClose(); }
catch (e) { setErr((e as Error).message); setBusy(false); }
};
return (
<Sheet title={props.title} onClose={props.onClose} footer={<>
<span className="sp" />
<button className="btn ghost" onClick={props.onClose}>Отмена</button>
<button className="btn gold" disabled={busy} onClick={submit}>{props.ok}</button>
</>}>
<div className="f">
<label>{props.label}</label>
<div className="in big">
<input ref={ref} value={v} maxLength={200} onChange={(e) => setV(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void submit(); }} />
</div>
{err && <div className="err">{err}</div>}
</div>
</Sheet>
);
}
export function ConfirmDialog(props: {
title: string; text: ReactNode; ok: string; danger?: boolean;
onOk: () => Promise<void> | void; onClose: () => void;
}) {
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
const go = async () => {
setBusy(true);
try { await props.onOk(); props.onClose(); }
catch (e) { setErr((e as Error).message); setBusy(false); }
};
return (
<Sheet title={props.title} onClose={props.onClose} footer={<>
<span className="sp" />
<button className="btn ghost" onClick={props.onClose}>Отмена</button>
<button className={props.danger ? "btn dng" : "btn gold"} disabled={busy} onClick={go}>{props.ok}</button>
</>}>
<div className="block mt-10">{props.text}</div>
{err && <div className="err">{err}</div>}
</Sheet>
);
}
function pathTitle(n: Node, byId: Map<string, Node>) {
const parts: string[] = [];
let cur: Node | undefined = n;
let guard = 0;
while (cur && guard++ < 100) { parts.unshift(cur.title); cur = cur.parentId ? byId.get(cur.parentId) : undefined; }
return parts.join(" / ");
}
/** Выбор папки назначения для переноса. */
export function MoveDialog(props: {
node: Node; nodes: Node[]; onOk: (parentId: string | null) => Promise<void>; onClose: () => void;
}) {
const [err, setErr] = useState("");
const [q, setQ] = useState("");
const byId = useMemo(() => new Map(props.nodes.map((n) => [n.id, n])), [props.nodes]);
const options = useMemo(() => {
// Нельзя внутрь себя и своих потомков.
const banned = new Set<string>([props.node.id]);
let grew = true;
while (grew) {
grew = false;
for (const n of props.nodes) if (n.parentId && banned.has(n.parentId) && !banned.has(n.id)) { banned.add(n.id); grew = true; }
}
return props.nodes
.filter((n) => n.kind === "folder" && !banned.has(n.id))
.map((n) => ({ id: n.id, label: pathTitle(n, byId) }))
.sort((a, b) => a.label.localeCompare(b.label, "uk"));
}, [props.nodes, props.node.id, byId]);
const shown = options.filter((o) => o.label.toLowerCase().includes(q.toLowerCase()));
const pick = async (id: string | null) => {
try { await props.onOk(id); props.onClose(); } catch (e) { setErr((e as Error).message); }
};
return (
<Sheet title={`Переместить «${props.node.title}»`} onClose={props.onClose}>
<div className="f"><div className="in"><input placeholder="Найти папку…" value={q} onChange={(e) => setQ(e.target.value)} autoFocus /></div></div>
{err && <div className="err">{err}</div>}
<div className="mt-10">
<button className="mrow" disabled={props.node.parentId === null} onClick={() => pick(null)}>
<Icon name="home" /><div className="t">В корень{props.node.parentId === null && <small>уже здесь</small>}</div>
</button>
{shown.map((o) => (
<button key={o.id} className="mrow" disabled={props.node.parentId === o.id} onClick={() => pick(o.id)}>
<Icon name="folder" /><div className="t">{o.label}{props.node.parentId === o.id && <small>уже здесь</small>}</div>
</button>
))}
{shown.length === 0 && <div className="empty">Папок не найдено</div>}
</div>
</Sheet>
);
}
/** Выбор страницы для ссылки. */
export function PagePickDialog(props: { nodes: Node[]; exclude?: string; onPick: (n: Node) => void; onClose: () => void }) {
const [q, setQ] = useState("");
const byId = useMemo(() => new Map(props.nodes.map((n) => [n.id, n])), [props.nodes]);
const list = props.nodes
.filter((n) => n.kind === "page" && n.id !== props.exclude && n.title.toLowerCase().includes(q.toLowerCase()))
.map((n) => ({ n, label: pathTitle(n, byId) }))
.sort((a, b) => a.label.localeCompare(b.label, "uk"))
.slice(0, 100);
return (
<Sheet title="Ссылка на страницу" onClose={props.onClose}>
<div className="f"><div className="in"><input placeholder="Название страницы…" value={q} onChange={(e) => setQ(e.target.value)} autoFocus /></div></div>
<div className="mt-10">
{list.map(({ n, label }) => (
<button key={n.id} className="mrow" onClick={() => { props.onPick(n); props.onClose(); }}>
<Icon name="page" /><div className="t">{n.title}<small>{label}</small></div>
</button>
))}
{list.length === 0 && <div className="empty">Страниц не найдено</div>}
</div>
</Sheet>
);
}

194
frontend/src/Editor.tsx Normal file
View File

@@ -0,0 +1,194 @@
import { useState } from "react";
import { EditorContent, useEditor, useEditorState, type Editor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image";
import { TableKit } from "@tiptap/extension-table";
import { TaskItem, TaskList } from "@tiptap/extension-list";
import { Placeholder } from "@tiptap/extensions";
import { Markdown } from "@tiptap/markdown";
import { Api, imageUrl, type Node } from "./api";
import { Icon } from "./Icon";
import { PagePickDialog, PromptDialog } from "./Dialogs";
import { navigate } from "./router";
// Картинка: src в Markdown остаётся /api/images/<id>, а показываем blob-URL, загруженный с токеном.
const AuthImage = Image.extend({
addNodeView() {
return ({ node }) => {
const img = document.createElement("img");
let src = "";
const load = (s: string, alt: string) => {
img.alt = alt;
if (s === src) return;
src = s;
img.classList.add("op-40");
imageUrl(s).then((u) => { if (src === s) { img.src = u; img.classList.remove("op-40"); } })
.catch(() => { img.alt = "картинка недоступна"; });
};
load(node.attrs.src, node.attrs.alt ?? "");
return {
dom: img,
update: (n) => {
if (n.type.name !== "image") return false;
load(n.attrs.src, n.attrs.alt ?? "");
return true;
},
};
};
},
});
const isInternal = (href: string) => /^\/p\/[A-Za-z0-9_-]+$/.test(href);
export function WikiEditor(props: {
markdown: string; pageId: string; nodes: Node[];
onChange: (getMarkdown: () => string) => void;
}) {
const [dialog, setDialog] = useState<null | "link" | "page">(null);
const [upErr, setUpErr] = useState("");
const upload = async (ed: Editor, files: File[], pos?: number) => {
setUpErr("");
for (const f of files) {
try {
const r = await Api.upload(f, props.pageId);
const content = { type: "image", attrs: { src: r.url, alt: f.name.replace(/\.[^.]+$/, "") } };
if (pos != null) ed.chain().focus().insertContentAt(pos, content).run();
else ed.chain().focus().insertContent(content).run();
} catch (e) {
setUpErr(`Не удалось загрузить «${f.name}»: ${(e as Error).message}`);
}
}
};
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
link: { openOnClick: false, autolink: true, defaultProtocol: "https" },
}),
AuthImage,
TableKit.configure({ table: { resizable: false } }),
TaskList,
TaskItem.configure({ nested: true }),
Placeholder.configure({ placeholder: "Начните писать… Markdown работает: # заголовок, - список, **жирный**" }),
Markdown.configure({ markedOptions: { gfm: true } }),
],
content: props.markdown,
contentType: "markdown",
onUpdate: ({ editor }) => props.onChange(() => editor.getMarkdown()),
editorProps: {
attributes: { class: "wk-prose", spellcheck: "true" },
handleClick: (_view, _pos, event) => {
const a = (event.target as HTMLElement).closest("a");
const href = a?.getAttribute("href");
if (!href) return false;
event.preventDefault();
if (isInternal(href)) void navigate(href);
else window.open(href, "_blank", "noopener,noreferrer");
return true;
},
handlePaste: (_view, event) => {
const files = [...(event.clipboardData?.files ?? [])].filter((f) => f.type.startsWith("image/"));
if (!files.length || !editor) return false;
void upload(editor, files);
return true;
},
handleDrop: (view, event) => {
const files = [...(event.dataTransfer?.files ?? [])].filter((f) => f.type.startsWith("image/"));
if (!files.length || !editor) return false;
event.preventDefault();
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos;
void upload(editor, files, pos);
return true;
},
},
});
const s = useEditorState({
editor,
selector: ({ editor: e }) => ({
h1: e.isActive("heading", { level: 1 }), h2: e.isActive("heading", { level: 2 }), h3: e.isActive("heading", { level: 3 }),
bold: e.isActive("bold"), italic: e.isActive("italic"), strike: e.isActive("strike"), code: e.isActive("code"),
ul: e.isActive("bulletList"), ol: e.isActive("orderedList"), task: e.isActive("taskList"),
quote: e.isActive("blockquote"), codeBlock: e.isActive("codeBlock"), link: e.isActive("link"),
table: e.isActive("table"), undo: e.can().undo(), redo: e.can().redo(),
}),
});
if (!editor) return null;
const c = () => editor.chain().focus();
const B = (p: { on?: boolean; title: string; icon?: string; text?: string; run: () => void; disabled?: boolean }) => (
<button type="button" className={"wk-tb" + (p.on ? " on" : "")} title={p.title} aria-label={p.title} aria-pressed={p.on}
disabled={p.disabled} onMouseDown={(e) => e.preventDefault()} onClick={p.run}>
{p.icon ? <Icon name={p.icon} /> : <b>{p.text}</b>}
</button>
);
const pickImage = () => {
const inp = document.createElement("input");
inp.type = "file";
inp.accept = "image/png,image/jpeg,image/gif,image/webp";
inp.multiple = true;
inp.onchange = () => void upload(editor, [...(inp.files ?? [])]);
inp.click();
};
return (
<div className="wk-editor">
<div className="wk-toolbar" role="toolbar" aria-label="Форматирование">
<B title="Заголовок 1" text="H1" on={s?.h1} run={() => c().toggleHeading({ level: 1 }).run()} />
<B title="Заголовок 2" text="H2" on={s?.h2} run={() => c().toggleHeading({ level: 2 }).run()} />
<B title="Заголовок 3" text="H3" on={s?.h3} run={() => c().toggleHeading({ level: 3 }).run()} />
<span className="wk-sep" />
<B title="Жирный (Ctrl+B)" icon="bold" on={s?.bold} run={() => c().toggleBold().run()} />
<B title="Курсив (Ctrl+I)" icon="italic" on={s?.italic} run={() => c().toggleItalic().run()} />
<B title="Зачёркнутый" icon="strike" on={s?.strike} run={() => c().toggleStrike().run()} />
<B title="Код" icon="code" on={s?.code} run={() => c().toggleCode().run()} />
<span className="wk-sep" />
<B title="Маркированный список" icon="ul" on={s?.ul} run={() => c().toggleBulletList().run()} />
<B title="Нумерованный список" icon="ol" on={s?.ol} run={() => c().toggleOrderedList().run()} />
<B title="Список задач" icon="task" on={s?.task} run={() => c().toggleTaskList().run()} />
<B title="Цитата" icon="quote" on={s?.quote} run={() => c().toggleBlockquote().run()} />
<B title="Блок кода" text="{ }" on={s?.codeBlock} run={() => c().toggleCodeBlock().run()} />
<B title="Разделитель" icon="hr" run={() => c().setHorizontalRule().run()} />
<span className="wk-sep" />
<B title={s?.link ? "Убрать ссылку" : "Ссылка"} icon="link" on={s?.link}
run={() => (s?.link ? c().unsetLink().run() : setDialog("link"))} />
<B title="Ссылка на страницу вики" icon="pagelink" run={() => setDialog("page")} />
<B title="Картинка (можно вставить из буфера или перетащить)" icon="image" run={pickImage} />
<B title="Таблица" icon="table" on={s?.table} run={() => c().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()} disabled={s?.table} />
{s?.table && <>
<B title="Добавить строку" icon="rowadd" run={() => c().addRowAfter().run()} />
<B title="Добавить столбец" icon="coladd" run={() => c().addColumnAfter().run()} />
<B title="Удалить строку" icon="rowdel" run={() => c().deleteRow().run()} />
<B title="Удалить столбец" icon="coldel" run={() => c().deleteColumn().run()} />
<B title="Удалить таблицу" icon="trash" run={() => c().deleteTable().run()} />
</>}
<span className="wk-sep" />
<B title="Отменить (Ctrl+Z)" icon="undo" disabled={!s?.undo} run={() => c().undo().run()} />
<B title="Повторить (Ctrl+Shift+Z)" icon="redo" disabled={!s?.redo} run={() => c().redo().run()} />
</div>
{upErr && <div className="err">{upErr}</div>}
<EditorContent editor={editor} />
{dialog === "link" && (
<PromptDialog title="Ссылка" label="Адрес (https://…)" initial="https://" ok="Вставить"
onClose={() => setDialog(null)}
onOk={(url) => {
let href = url.trim();
if (!/^(https?:|mailto:|\/p\/)/i.test(href)) href = "https://" + href.replace(/^\/+/, "");
if (editor.state.selection.empty) c().insertContent({ type: "text", text: href, marks: [{ type: "link", attrs: { href } }] }).run();
else c().extendMarkRange("link").setLink({ href }).run();
}} />
)}
{dialog === "page" && (
<PagePickDialog nodes={props.nodes} exclude={props.pageId} onClose={() => setDialog(null)}
onPick={(n) => {
const href = `/p/${n.id}`;
if (editor.state.selection.empty) c().insertContent({ type: "text", text: n.title, marks: [{ type: "link", attrs: { href } }] }).insertContent(" ").run();
else c().extendMarkRange("link").setLink({ href }).run();
}} />
)}
</div>
);
}

43
frontend/src/Icon.tsx Normal file
View File

@@ -0,0 +1,43 @@
// Иконки по правилам tvorymo-ui.css: <svg class="ic" viewBox="0 0 24 24">, обводка currentColor.
const P: Record<string, string> = {
x: "M6 6l12 12M18 6 6 18",
folder: "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z",
page: "M7 3h7l5 5v11a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zM14 3v5h5M9 13h6M9 17h6",
home: "M4 11l8-7 8 7M6 10v10h12V10",
chev: "M9 6l6 6-6 6",
plus: "M12 5v14M5 12h14",
search: "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4",
edit: "M4 20h4L19 9l-4-4L4 16zM14 6l4 4",
move: "M5 12h14M13 6l6 6-6 6",
trash: "M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3",
menu: "M4 6h16M4 12h16M4 18h16",
link: "M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1",
pagelink: "M7 3h7l5 5v11a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zM10 15l4-4M11 11h3v3",
image: "M4 5h16v14H4zM4 16l5-5 4 4 3-3 4 4M15 9h.01",
table: "M4 5h16v14H4zM4 10h16M4 15h16M10 5v14",
bold: "M7 5h6a3.5 3.5 0 0 1 0 7H7zM7 12h7a3.5 3.5 0 0 1 0 7H7z",
italic: "M11 5h6M7 19h6M14 5l-4 14",
strike: "M5 12h14M16 7a4 3 0 0 0-8 0c0 4 8 2 8 6a4 3 0 0 1-8 0",
code: "M9 8l-4 4 4 4M15 8l4 4-4 4",
ul: "M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01",
ol: "M10 6h10M10 12h10M10 18h10M4 5l1-1v4M4 14h2l-2 3h2",
task: "M4 6l2 2 3-3M4 15h5v5H4zM12 7h8M12 17h8",
quote: "M6 17c-1-1-2-3-2-5 0-3 2-5 4-6M14 17c-1-1-2-3-2-5 0-3 2-5 4-6",
hr: "M4 12h16",
undo: "M9 14 4 9l5-5M4 9h10a6 6 0 0 1 0 12h-3",
redo: "M15 14l5-5-5-5M20 9H10a6 6 0 0 0 0 12h3",
rowadd: "M4 5h16v6H4zM12 15v6M9 18h6",
coladd: "M4 4h6v16H4zM15 12h6M18 9v6",
rowdel: "M4 5h16v6H4zM9 18h6",
coldel: "M4 4h6v16H4zM15 12h6",
copy: "M9 9h11v11H9zM5 15V4h11",
check: "M5 12l5 5 9-10",
};
export function Icon({ name }: { name: string }) {
return (
<svg className="ic" viewBox="0 0 24 24" aria-hidden="true">
<path d={P[name] ?? ""} />
</svg>
);
}

140
frontend/src/PageView.tsx Normal file
View File

@@ -0,0 +1,140 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Api, ApiError, type Node, type Page } from "./api";
import { WikiEditor } from "./Editor";
import { setLeaveGuard } from "./router";
type SaveState = "saved" | "dirty" | "saving" | "error" | "conflict";
const fmt = (iso: string) => new Date(iso).toLocaleString("uk-UA", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
/**
* Страница: заголовок + редактор, автосохранение через 1 с после правки.
* Сохраняем с версией: если страницу уже изменил кто-то другой, сервер отвечает 409 и мы показываем выбор.
*/
export function PageView(props: { page: Page; nodes: Node[]; onSaved: (p: Page, titleChanged: boolean) => void }) {
const [title, setTitle] = useState(props.page.title);
const [state, setState] = useState<SaveState>("saved");
const [err, setErr] = useState("");
const [meta, setMeta] = useState({ updatedAt: props.page.updatedAt, by: props.page.updatedByName });
const [conflict, setConflict] = useState<Page | null>(null);
const [editorKey, setEditorKey] = useState(0);
const [markdown, setMarkdown] = useState(props.page.markdown);
const version = useRef(props.page.version);
const savedTitle = useRef(props.page.title);
const getMd = useRef<() => string>(() => props.page.markdown);
const titleRef = useRef(title);
const dirty = useRef(false);
const timer = useRef<number | undefined>(undefined);
const inflight = useRef<Promise<void> | null>(null);
const save = useCallback(async (): Promise<void> => {
window.clearTimeout(timer.current);
if (inflight.current) { await inflight.current; }
if (!dirty.current) return;
const t = titleRef.current.trim() || "Без названия";
dirty.current = false;
setState("saving");
const run = (async () => {
try {
const p = await Api.save(props.page.id, t, getMd.current(), version.current);
version.current = p.version;
const titleChanged = p.title !== savedTitle.current;
savedTitle.current = p.title;
setMeta({ updatedAt: p.updatedAt, by: p.updatedByName });
setState(dirty.current ? "dirty" : "saved");
setErr("");
props.onSaved(p, titleChanged);
} catch (e) {
dirty.current = true;
if (e instanceof ApiError && e.status === 409) {
setConflict(e.body?.current ?? null);
setState("conflict");
} else {
setErr((e as Error).message);
setState("error");
}
}
})();
inflight.current = run;
await run;
inflight.current = null;
}, [props.page.id]);
const schedule = useCallback(() => {
dirty.current = true;
setState((s) => (s === "conflict" ? s : "dirty"));
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => { if (!conflictRef.current) void save(); }, 1000);
}, [save]);
const conflictRef = useRef(conflict);
conflictRef.current = conflict;
// Перед переходом на другую страницу — дописать изменения. При закрытии вкладки — предупредить.
useEffect(() => {
setLeaveGuard(async () => { if (dirty.current && !conflictRef.current) await save(); });
const before = (e: BeforeUnloadEvent) => { if (dirty.current) { e.preventDefault(); } };
window.addEventListener("beforeunload", before);
return () => { setLeaveGuard(null); window.removeEventListener("beforeunload", before); window.clearTimeout(timer.current); };
}, [save]);
const takeTheirs = () => {
if (!conflict) return;
version.current = conflict.version;
savedTitle.current = conflict.title;
titleRef.current = conflict.title;
setTitle(conflict.title);
setMarkdown(conflict.markdown);
getMd.current = () => conflict.markdown;
setMeta({ updatedAt: conflict.updatedAt, by: conflict.updatedByName });
dirty.current = false;
setConflict(null);
setState("saved");
setEditorKey((k) => k + 1);
};
const keepMine = () => {
if (!conflict) return;
version.current = conflict.version;
setConflict(null);
dirty.current = true;
void save();
};
const status = {
saved: <span className="chip"><span className="syncdot" />сохранено</span>,
dirty: <span className="chip"><span className="syncdot off" />есть изменения</span>,
saving: <span className="chip"><span className="syncdot off" />сохраняю…</span>,
error: <button className="chip dng" onClick={() => { dirty.current = true; void save(); }} title={err}>не сохранено · повторить</button>,
conflict: <span className="chip dng">конфликт версий</span>,
}[state];
return (
<div>
<div className="wk-titlebar">
<input className="wk-title" value={title} maxLength={200} placeholder="Без названия" aria-label="Название страницы"
onChange={(e) => { setTitle(e.target.value); titleRef.current = e.target.value; schedule(); }}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); (document.querySelector(".wk-prose") as HTMLElement | null)?.focus(); } }} />
</div>
<div className="row wrap small mute mt-6">
{status}
<span>изменено {fmt(meta.updatedAt)}{meta.by ? ` · ${meta.by}` : ""}</span>
</div>
{state === "error" && err && <div className="err">{err}</div>}
{conflict && (
<div className="blockbar">
<div className="grow">
<b>Страницу уже изменили</b>{conflict.updatedByName ? ` (${conflict.updatedByName}, ${fmt(conflict.updatedAt)})` : ""}.
Ваши правки ещё не сохранены. Что оставить?
<div className="row wrap mt-10">
<button className="btn sm" onClick={takeTheirs}>Загрузить их версию</button>
<button className="btn sm dng" onClick={keepMine}>Перезаписать своей</button>
</div>
</div>
</div>
)}
<WikiEditor key={editorKey} markdown={markdown} pageId={props.page.id} nodes={props.nodes}
onChange={(g) => { getMd.current = g; schedule(); }} />
</div>
);
}

87
frontend/src/Tree.tsx Normal file
View File

@@ -0,0 +1,87 @@
import { useEffect, useMemo, useState } from "react";
import type { Node } from "./api";
import { Icon } from "./Icon";
import { hrefOf, navigate } from "./router";
export function sortNodes(a: Node, b: Node) {
if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
return a.title.localeCompare(b.title, "uk", { sensitivity: "base", numeric: true });
}
export function childrenMap(nodes: Node[]) {
const m = new Map<string | null, Node[]>();
for (const n of nodes) {
const k = n.parentId ?? null;
if (!m.has(k)) m.set(k, []);
m.get(k)!.push(n);
}
for (const list of m.values()) list.sort(sortNodes);
return m;
}
const storeKey = (ws: string) => `wiki.open.${ws}`;
/** Дерево папок и страниц: раскрыть/свернуть, текущий узел подсвечен, предки раскрыты. */
export function Tree(props: { nodes: Node[]; ws: string; currentId: string | null; onAddPage: (parentId: string) => void }) {
const kids = useMemo(() => childrenMap(props.nodes), [props.nodes]);
const byId = useMemo(() => new Map(props.nodes.map((n) => [n.id, n])), [props.nodes]);
const [open, setOpen] = useState<Set<string>>(() => {
try { return new Set(JSON.parse(localStorage.getItem(storeKey(props.ws)) || "[]")); } catch { return new Set(); }
});
useEffect(() => {
try { localStorage.setItem(storeKey(props.ws), JSON.stringify([...open])); } catch { /* хранилище недоступно */ }
}, [open, props.ws]);
// Раскрыть предков текущего узла.
useEffect(() => {
if (!props.currentId) return;
const need: string[] = [];
let cur = byId.get(props.currentId);
const self = cur;
let guard = 0;
while (cur?.parentId && guard++ < 100) { need.push(cur.parentId); cur = byId.get(cur.parentId); }
if (self?.kind === "folder") need.push(self.id);
if (need.some((id) => !open.has(id))) setOpen((o) => new Set([...o, ...need]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.currentId, byId]);
const toggle = (id: string) => setOpen((o) => { const n = new Set(o); if (n.has(id)) n.delete(id); else n.add(id); return n; });
const render = (parent: string | null, depth: number): React.ReactNode =>
(kids.get(parent) ?? []).map((n) => {
const isOpen = open.has(n.id);
const hasKids = (kids.get(n.id)?.length ?? 0) > 0;
return (
<div key={n.id} role="treeitem" aria-expanded={n.kind === "folder" ? isOpen : undefined}>
<div className={"wk-row" + (n.id === props.currentId ? " on" : "")} style={{ paddingLeft: `calc(${depth} * var(--sp-14) + var(--sp-4))` }}>
{n.kind === "folder" ? (
<button className={"wk-chev" + (isOpen ? " open" : "") + (hasKids ? "" : " op-40")} onClick={() => toggle(n.id)}
title={isOpen ? "Свернуть" : "Развернуть"} aria-label={isOpen ? "Свернуть" : "Развернуть"}>
<Icon name="chev" />
</button>
) : <span className="wk-chev" />}
<a className="wk-link" href={hrefOf(n.kind, n.id)}
onClick={(e) => {
if (e.ctrlKey || e.metaKey) return;
e.preventDefault();
if (n.kind === "folder" && n.id === props.currentId) toggle(n.id);
void navigate(hrefOf(n.kind, n.id));
}}>
<Icon name={n.kind === "folder" ? "folder" : "page"} />
<span className="trunc">{n.title}</span>
</a>
{n.kind === "folder" && (
<button className="wk-add" title="Новая страница в папке" aria-label="Новая страница в папке" onClick={() => props.onAddPage(n.id)}>
<Icon name="plus" />
</button>
)}
</div>
{n.kind === "folder" && isOpen && <div role="group">{render(n.id, depth + 1)}</div>}
</div>
);
});
if (props.nodes.length === 0) return <div className="empty">Пока пусто. Создайте первую страницу или папку.</div>;
return <div role="tree" className="wk-tree">{render(null, 0)}</div>;
}

71
frontend/src/api.ts Normal file
View File

@@ -0,0 +1,71 @@
import { currentWorkspace, getToken } from "./crm";
export type Node = {
id: string; parentId: string | null; kind: "folder" | "page"; title: string; version: number;
createdAt: string; updatedAt: string; updatedBy: string; updatedByName: string | null;
};
export type Crumb = { id: string; kind: "folder" | "page"; title: string };
export type Page = Node & { markdown: string; path: Crumb[] };
export type SearchHit = { id: string; kind: "folder" | "page"; title: string; snippet: string; path: Crumb[] };
export class ApiError extends Error {
status: number;
body: any;
constructor(status: number, body: any) {
super(body?.error || `HTTP ${status}`);
this.status = status;
this.body = body;
}
}
/** Запрос к API вики: Bearer-токен CRM + текущий workspace. На 401 — свежий токен и один повтор. */
export async function api<T = any>(path: string, init: RequestInit = {}, ws = currentWorkspace()): Promise<T> {
const go = async (force: boolean) => {
const headers = new Headers(init.headers);
headers.set("Authorization", `Bearer ${await getToken(force)}`);
headers.set("X-Wiki-Workspace", ws);
if (init.body && typeof init.body === "string") headers.set("Content-Type", "application/json");
return fetch(path, { ...init, headers });
};
let r = await go(false);
if (r.status === 401) r = await go(true);
const type = r.headers.get("Content-Type") || "";
const body = type.includes("json") ? await r.json() : type.startsWith("image/") ? await r.blob() : await r.text();
if (!r.ok) throw new ApiError(r.status, body);
return body as T;
}
const json = (method: string, body: unknown): RequestInit => ({ method, body: JSON.stringify(body) });
export const Api = {
tree: () => api<{ workspace: string; nodes: Node[] }>("/api/tree"),
page: (id: string) => api<Page>(`/api/nodes/${encodeURIComponent(id)}`),
createPage: (title: string, parentId: string | null, markdown = "") =>
api<Page>("/api/pages", json("POST", { title, parentId, markdown })),
createFolder: (title: string, parentId: string | null) => api<Page>("/api/folders", json("POST", { title, parentId })),
save: (id: string, title: string, markdown: string, version: number) =>
api<Page>(`/api/pages/${encodeURIComponent(id)}`, json("PUT", { title, markdown, version })),
patch: (id: string, body: { title?: string; parentId?: string | null }) =>
api<Page>(`/api/nodes/${encodeURIComponent(id)}`, json("PATCH", body)),
remove: (id: string) => api<{ deleted: number }>(`/api/nodes/${encodeURIComponent(id)}`, { method: "DELETE" }),
search: (q: string) => api<{ results: SearchHit[] }>(`/api/search?q=${encodeURIComponent(q)}&limit=30`),
upload: (file: File, pageId: string) => {
const fd = new FormData();
fd.append("file", file);
fd.append("pageId", pageId);
return api<{ id: string; url: string }>("/api/images", { method: "POST", body: fd });
},
};
// Картинки отдаются только с токеном: грузим через fetch и подставляем blob-URL.
const imgCache = new Map<string, Promise<string>>();
export function imageUrl(src: string): Promise<string> {
if (!src.startsWith("/api/images/")) return Promise.resolve(src);
let p = imgCache.get(src);
if (!p) {
p = api<Blob>(src).then((b) => URL.createObjectURL(b));
p.catch(() => imgCache.delete(src));
imgCache.set(src, p);
}
return p;
}

145
frontend/src/crm.ts Normal file
View File

@@ -0,0 +1,145 @@
// Мост с CRM: postMessage (crm:ready → crm:auth, crm:refresh, crm:logout).
// Токен живёт только в памяти, никуда не логируется и не выводится.
import { useSyncExternalStore } from "react";
export type User = { id: string; email: string | null; name: string | null };
export type CrmWorkspace = { id?: string; kind?: string; title?: string };
export type AuthState = {
status: "standalone" | "waiting" | "checking" | "ok" | "error" | "logout";
error?: string;
user?: User;
workspace: string; // shared | personal:<uid> | <uuid>
workspaceTitle?: string;
};
let crmOrigin = "";
let token: string | null = null;
let expiresAt: number | null = null; // мс
let state: AuthState = { status: "waiting", workspace: "shared" };
const subs = new Set<() => void>();
let waiters: Array<() => void> = [];
let lastWs: CrmWorkspace | null = null;
function set(p: Partial<AuthState>) {
state = { ...state, ...p };
subs.forEach((f) => f());
}
export function useAuth() {
return useSyncExternalStore(
(f) => { subs.add(f); return () => subs.delete(f); },
() => state,
);
}
function toMs(v: unknown): number | null {
if (v == null) return null;
const n = Number(v);
if (Number.isFinite(n)) return n < 1e12 ? n * 1000 : n;
const d = Date.parse(String(v));
return Number.isNaN(d) ? null : d;
}
/** Workspace из сообщения CRM → id вики. Сервер всё равно проверяет доступ сам. */
function wsKey(w: CrmWorkspace | null | undefined, uid: string | undefined): { id: string; title?: string } {
if (!w || typeof w !== "object") return { id: "shared" };
const id = String(w.id ?? "");
const kind = String(w.kind ?? "");
if (kind === "shared" || id === "shared" || (!id && !kind)) return { id: "shared", title: w.title };
if (kind === "personal" || id === "personal" || id.startsWith("personal:"))
return uid ? { id: `personal:${uid}`, title: w.title } : { id: "shared" };
return { id, title: w.title };
}
async function onAuth(d: Record<string, unknown>) {
const t = d.access_token;
if (typeof t !== "string" || !t) { set({ status: "error", error: "crm:auth без access_token" }); return; }
const changedUser = !state.user || (d.user as { id?: string } | undefined)?.id !== state.user.id;
token = t;
expiresAt = toMs(d.expires_at);
if ("workspace" in d) lastWs = (d.workspace as CrmWorkspace) ?? null;
const ws0 = wsKey(lastWs, state.user?.id);
// Если пользователь тот же, повторно не проверяем: каждый запрос к API всё равно проверяется на сервере.
if (!changedUser && state.status === "ok") {
set({ workspace: ws0.id, workspaceTitle: ws0.title });
flushWaiters();
return;
}
if (state.status !== "ok") set({ status: "checking" });
try {
const r = await fetch("/api/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ access_token: t }),
});
const res = await r.json();
if (!r.ok || !res.ok) { set({ status: "error", error: res.error || `HTTP ${r.status}` }); return; }
if (!expiresAt && res.exp) expiresAt = res.exp * 1000;
const user: User = { id: res.id, email: res.email, name: res.name };
const ws = wsKey(lastWs, user.id);
set({ status: "ok", user, error: undefined, workspace: ws.id, workspaceTitle: ws.title });
flushWaiters();
} catch {
set({ status: "error", error: "не удалось проверить токен" });
}
}
function flushWaiters() {
const w = waiters;
waiters = [];
w.forEach((f) => f());
}
export async function initCrm() {
if (window.parent === window) { set({ status: "standalone" }); return; }
try {
const cfg = await (await fetch("/api/config")).json();
crmOrigin = cfg.crmOrigin;
} catch {
set({ status: "error", error: "нет связи с сервером вики" });
return;
}
window.addEventListener("message", (ev) => {
if (ev.origin !== crmOrigin || ev.source !== window.parent) return;
const d = ev.data;
if (!d || typeof d !== "object") return;
if (d.type === "crm:auth") void onAuth(d);
else if (d.type === "crm:workspace") {
// Будущее сообщение о смене workspace (задача Олега); формат: { workspace: { id, kind, title } }.
lastWs = d.workspace ?? null;
const ws = wsKey(lastWs, state.user?.id);
set({ workspace: ws.id, workspaceTitle: ws.title });
} else if (d.type === "crm:logout") {
token = null; expiresAt = null;
set({ status: "logout", user: undefined });
}
});
window.parent.postMessage({ type: "crm:ready", version: 1 }, crmOrigin);
}
function requestRefresh(): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
waiters = waiters.filter((w) => w !== done);
reject(new Error("CRM не прислала новый токен"));
}, 15000);
const done = () => { clearTimeout(timer); resolve(); };
waiters.push(done);
if (crmOrigin) window.parent.postMessage({ type: "crm:refresh" }, crmOrigin);
});
}
/** Токен для запроса: если истекает меньше чем через минуту — просим у CRM свежий. */
export async function getToken(force = false): Promise<string> {
const stale = !token || force || (expiresAt != null && expiresAt - Date.now() < 60_000);
if (stale) {
try { await requestRefresh(); } catch (e) { if (!token) throw e; }
}
if (!token) throw new Error("нет авторизации CRM");
return token;
}
export function currentWorkspace() {
return state.workspace;
}

8
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,8 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import { initCrm } from "./crm";
import "./wiki.css";
void initCrm();
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);

36
frontend/src/router.ts Normal file
View File

@@ -0,0 +1,36 @@
import { useSyncExternalStore } from "react";
// Адреса: / — корень workspace
// /f/<id> — папка
// /p/<id> — страница (так же выглядят ссылки между страницами в Markdown)
export type Route = { kind: "root" } | { kind: "folder"; id: string } | { kind: "page"; id: string };
const subs = new Set<() => void>();
let path = location.pathname;
window.addEventListener("popstate", () => { path = location.pathname; subs.forEach((f) => f()); });
let guard: (() => Promise<void>) | null = null;
/** Перед уходом со страницы (например, дождаться сохранения). */
export function setLeaveGuard(g: (() => Promise<void>) | null) { guard = g; }
export async function navigate(to: string, replace = false) {
if (to === path) return;
if (guard) { try { await guard(); } catch { /* ошибка сохранения видна на самой странице */ } }
if (replace) history.replaceState(null, "", to);
else history.pushState(null, "", to);
path = to;
subs.forEach((f) => f());
}
export function parse(p: string): Route {
const m = /^\/([pf])\/([A-Za-z0-9_-]+)\/?$/.exec(p);
if (!m) return { kind: "root" };
return m[1] === "p" ? { kind: "page", id: m[2] } : { kind: "folder", id: m[2] };
}
export function useRoute(): Route {
const p = useSyncExternalStore((f) => { subs.add(f); return () => subs.delete(f); }, () => path);
return parse(p);
}
export const hrefOf = (kind: "folder" | "page", id: string) => (kind === "page" ? `/p/${id}` : `/f/${id}`);

104
frontend/src/wiki.css Normal file
View File

@@ -0,0 +1,104 @@
/* Вики: только то, чего нет в tvorymo-ui.css / crm.css — раскладка «дерево + страница», строки дерева,
панель редактора и оформление текста страницы. Все значения — токены CRM (--sp-*, --fs-*, --r-*, цвета). */
/* ---------------------------------------------------------------- раскладка */
#main.wide{max-width:none}
.wk{display:grid;grid-template-columns:1fr;gap:var(--sp-24);align-items:start}
.wk-side{display:none}
.wk.side-open .wk-side{display:block}
.wk.side-open .wk-main{display:none}
@media (min-width:900px){
.wk{grid-template-columns:280px minmax(0,1fr)}
.wk-side{display:block;position:sticky;top:var(--sp-16);max-height:calc(100dvh - var(--sp-32));overflow-y:auto;
padding-right:var(--sp-14);border-right:1px solid var(--line)}
.wk.side-open .wk-main{display:block}
.wk-menu{display:none!important}
.wk-main{max-width:860px}
}
/* ---------------------------------------------------------------- дерево */
.wk-row{display:flex;align-items:center;gap:var(--sp-2);border-radius:var(--r-8);color:var(--mute);
font-size:var(--fs-13);font-weight:500;min-height:32px;text-decoration:none}
.wk-row:hover{background:var(--hover);color:var(--fg)}
.wk-row.on{background:var(--gold-dim);color:var(--fg)}
.wk-link{display:flex;align-items:center;gap:var(--sp-8);flex:1;min-width:0;padding:var(--sp-6) var(--sp-4);
color:inherit;text-decoration:none}
.wk-link svg{flex:none;stroke-width:var(--ic-w-thin)}
.wk-home{margin-bottom:var(--sp-2)}
.wk-home .wk-link{padding-left:var(--sp-8)}
.wk-chev{width:20px;height:20px;display:inline-flex;align-items:center;justify-content:center;flex:none;color:var(--mute2)}
.wk-chev svg{width:14px;height:14px;transition:transform var(--dur-fast)}
.wk-chev.open svg{transform:rotate(90deg)}
.wk-add{width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;flex:none;
border-radius:var(--r-6);color:var(--mute2);opacity:0}
.wk-add svg{width:14px;height:14px}
.wk-row:hover .wk-add,.wk-add:focus-visible{opacity:1}
.wk-add:hover{color:var(--gold-text);background:var(--hover)}
@media (hover:none){.wk-add{opacity:1}}
/* ---------------------------------------------------------------- шапка страницы */
.wk-crumbs{display:flex;align-items:center;flex-wrap:wrap;gap:var(--sp-6);font-size:var(--fs-12);color:var(--mute);
margin-bottom:var(--sp-14);min-height:30px}
.wk-crumbs a{color:var(--mute);text-decoration:none}
.wk-crumbs a:hover{color:var(--fg)}
.wk-crumbs .row{gap:var(--sp-6);min-width:0}
.wk-title{font-family:var(--fh);font-weight:700;font-size:var(--fs-22);line-height:var(--lh-tight);letter-spacing:-.01em;
padding:var(--sp-4) 0;border-bottom:1px solid transparent}
.wk-title:focus{border-bottom-color:var(--gold)}
.wk-title::placeholder{color:var(--mute2)}
@media (min-width:900px){.wk-title{font-size:var(--fs-27)}}
/* ---------------------------------------------------------------- панель редактора */
.wk-editor{margin-top:var(--sp-14)}
.wk-toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:var(--sp-2);position:sticky;top:0;z-index:var(--z-sticky);
background:var(--bg);padding:var(--sp-6) 0;border-bottom:1px solid var(--line)}
.wk-tb{min-width:30px;height:30px;padding:0 var(--sp-6);border-radius:var(--r-8);display:inline-flex;align-items:center;
justify-content:center;color:var(--mute);font-size:var(--fs-12)}
.wk-tb b{font-family:var(--fh);font-weight:700;font-size:var(--fs-11)}
.wk-tb:hover{background:var(--hover);color:var(--fg)}
.wk-tb.on{background:var(--gold-dim);color:var(--gold-text)}
.wk-tb[disabled]{opacity:.35;pointer-events:none}
.wk-sep{width:1px;height:18px;background:var(--line);margin:0 var(--sp-4)}
/* ---------------------------------------------------------------- текст страницы */
.wk-prose{outline:none;min-height:50vh;padding:var(--sp-16) 0 var(--sp-32);font-size:var(--fs-15);line-height:var(--lh-loose)}
.wk-prose>*+*{margin-top:var(--sp-10)}
.wk-prose h1{font-size:var(--fs-22);margin-top:var(--sp-28)}
.wk-prose h2{font-size:var(--fs-18);font-weight:700;letter-spacing:-.01em;margin-top:var(--sp-24)}
.wk-prose h3{font-size:var(--fs-16);margin-top:var(--sp-20)}
.wk-prose ul,.wk-prose ol{padding-left:var(--sp-24)}
.wk-prose ul{list-style:disc}
.wk-prose ol{list-style:decimal}
.wk-prose li>p{margin:0}
.wk-prose li+li{margin-top:var(--sp-2)}
.wk-prose ul[data-type="taskList"]{list-style:none;padding-left:var(--sp-2)}
.wk-prose ul[data-type="taskList"] li{display:flex;gap:var(--sp-8);align-items:flex-start}
.wk-prose ul[data-type="taskList"] li>label{flex:none;margin-top:var(--sp-4)}
.wk-prose ul[data-type="taskList"] li>label input{width:auto;accent-color:var(--gold)}
.wk-prose ul[data-type="taskList"] li[data-checked="true"]>div{color:var(--mute);text-decoration:line-through}
.wk-prose blockquote{border-left:3px solid var(--gold);padding:var(--sp-2) 0 var(--sp-2) var(--sp-14);color:var(--mute)}
.wk-prose code{font-family:var(--fm);font-size:.9em;background:var(--hover);border:1px solid var(--line);
border-radius:var(--r-4);padding:0 var(--sp-4)}
.wk-prose pre{font-family:var(--fm);font-size:var(--fs-13);background:var(--bg2);border:1px solid var(--line);
border-radius:var(--r-10);padding:var(--sp-12) var(--sp-14);overflow-x:auto;line-height:var(--lh-base)}
.wk-prose pre code{background:none;border:0;padding:0}
.wk-prose a{color:var(--gold-text);text-decoration:underline;text-underline-offset:2px;cursor:pointer}
.wk-prose a[href^="/p/"]{text-decoration-style:dotted}
.wk-prose hr{margin:var(--sp-20) 0}
.wk-prose img{max-width:100%;height:auto;border-radius:var(--r-8);border:1px solid var(--line);display:block}
.wk-prose img.ProseMirror-selectednode{outline:2px solid var(--gold)}
.wk-prose strong{font-weight:700}
.wk-prose em{font-style:italic}
.wk-prose s{text-decoration:line-through}
.wk-prose .tableWrapper{overflow-x:auto}
.wk-prose table{border-collapse:collapse;width:100%;font-size:var(--fs-14)}
.wk-prose th,.wk-prose td{border:1px solid var(--line2);padding:var(--sp-6) var(--sp-10);vertical-align:top;text-align:left;
position:relative;min-width:60px}
.wk-prose th{background:var(--hover);font-weight:700}
.wk-prose td>p,.wk-prose th>p{margin:0}
.wk-prose .selectedCell::after{content:'';position:absolute;inset:0;background:var(--gold-dim);pointer-events:none}
.wk-prose p.is-editor-empty:first-child::before{content:attr(data-placeholder);color:var(--mute2);float:left;height:0;pointer-events:none}
/* строки-ссылки (.mrow как <a>) — без подчёркивания и золотого цвета ссылок */
a.mrow,a.wk-row{color:inherit;text-decoration:none}
a.mrow:hover .t{color:var(--gold-text)}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

7
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [react()],
server: { proxy: { '/api': 'http://127.0.0.1:5080', '/health': 'http://127.0.0.1:5080' } },
})