Доступ для акаунтів CRM: чужое личное по access_grants (projects=all, level read/full), custom через shared=all; режим «лише перегляд» (403 на изменения, access в /api/me и /api/tree); кэш доступа к workspace — 1 мин

This commit is contained in:
EugeneTes
2026-10-16 15:20:28 +00:00
parent c474bb3607
commit e0e4a18476
10 changed files with 235 additions and 50 deletions

View File

@@ -16,9 +16,9 @@
| `Authorization` | `Bearer <access_token Supabase из CRM>` | | `Authorization` | `Bearer <access_token Supabase из CRM>` |
| `X-Wiki-Workspace` | `shared` (по умолчанию), `personal:<uid>` или id пользовательского workspace CRM (например, `wsmv5l7d425re160`, как отдаёт `crm workspaces list`) | | `X-Wiki-Workspace` | `shared` (по умолчанию), `personal:<uid>` или id пользовательского workspace CRM (например, `wsmv5l7d425re160`, как отдаёт `crm workspaces list`) |
Сервер проверяет токен: `exp`, `aud = "authenticated"` и `GET <SUPABASE_URL>/auth/v1/user` с publishable-ключом (результат кэшируется на 5 минут по SHA-256 токена). Workspace из запроса не доверяется: `shared` доступен всем, `personal:<uid>` только владельцу `uid`, id пользовательского workspace проверяется запросом `GET /rest/v1/workspaces?id=eq.<id>` с токеном пользователя (RLS Supabase: строку видят только владелец и участники). Id в таблице `workspaces` CRM текстовые: `shared`, `personal:<uid>` и для пользовательских `ws` + base36 (`createWorkspace` в CRM). Принимается любой id из `[A-Za-z0-9_-]{1,64}` (в том числе uuid), другие символы отклоняются ещё до запроса к Supabase. Ответы: `401` — токен не принят или истёк, `403` — нет доступа к workspace. Сервер проверяет токен: `exp`, `aud = "authenticated"` и `GET <SUPABASE_URL>/auth/v1/user` с publishable-ключом (результат кэшируется на 5 минут по SHA-256 токена). Workspace из запроса не доверяется: `shared` доступен всем; `personal:<uid>` — владельцу `uid`, а другим — по выдаче доступа CRM («Доступ для акаунтів», таблица `access_grants`, строка `<owner>:<grantee>`): `GET /rest/v1/access_grants?owner_uid=eq.<uid>&grantee_uid=eq.<я>` с токеном пользователя, личную вики открывает `projects = all` («Мої особисті проєкти: Усі»), права — `level` (`read` — лишь просмотр, `full` — полные); «Вибрані» проекты и один лишь план вики не открывают. Id пользовательского workspace проверяется запросом `GET /rest/v1/workspaces?id=eq.<id>&select=id,owner_uid,members` с токеном пользователя (RLS Supabase): владелец и участники — полные права; строка видна, но я не владелец и не участник — значит, её открыли через `access_grants.shared = all` владельцем или участником, права по `level`. Результат проверки workspace кэшируется на 1 минуту, поэтому выданный или отозванный в CRM доступ вступает в силу в пределах минуты. В workspace «лишь просмотр» любой запрос, кроме `GET`/`HEAD`, получает `403`; `/api/me` и `/api/tree` возвращают `access: "read" | "full"`, интерфейс в режиме `read` прячет кнопки изменения. Id в таблице `workspaces` CRM текстовые: `shared`, `personal:<uid>` и для пользовательских `ws` + base36 (`createWorkspace` в CRM). Принимается любой id из `[A-Za-z0-9_-]{1,64}` (в том числе uuid), другие символы отклоняются ещё до запроса к 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`. Пока CRM не передаёт workspace (задача Олега `mv6p5b92slw1an`), фронтенд работает в `shared`. Когда в `crm:auth` появится `workspace: { id, kind, title }`, он подхватится автоматически: `kind: "shared"` → `shared`, `kind: "personal"` → `personal:<uid>` (свой), иначе `id` (в том числе `personal:<uid владельца>` с `kind: "granted"` — чужое личное, открытое мне). Сообщение о смене workspace тоже обрабатывается (`{ type: "crm:workspace", workspace: {…} }`), если Олег выберет другое имя — поправить в `frontend/src/crm.ts`.
## Ссылки на страницы в CRM ## Ссылки на страницы в CRM
@@ -39,8 +39,8 @@
| `GET` | `/health` | `{ status, dbCreatedAt }` — без авторизации; `dbCreatedAt` не меняется, пока жив том с базой | | `GET` | `/health` | `{ status, dbCreatedAt }` — без авторизации; `dbCreatedAt` не меняется, пока жив том с базой |
| `GET` | `/api/config` | `{ crmOrigins: [...] }` — без авторизации | | `GET` | `/api/config` | `{ crmOrigins: [...] }` — без авторизации |
| `POST` | `/api/verify` | `{ access_token }` → `{ ok, id, email, name, exp }` — без авторизации | | `POST` | `/api/verify` | `{ access_token }` → `{ ok, id, email, name, exp }` — без авторизации |
| `GET` | `/api/me` | текущий пользователь и workspace | | `GET` | `/api/me` | текущий пользователь, workspace и `access` (`read` / `full`) |
| `GET` | `/api/tree` | `{ workspace, nodes: [Node] }` — все папки и страницы workspace без текста | | `GET` | `/api/tree` | `{ workspace, access, nodes: [Node] }` — все папки и страницы workspace без текста |
| `GET` | `/api/nodes/{id}` | папка или страница (Page) | | `GET` | `/api/nodes/{id}` | папка или страница (Page) |
| `GET` | `/api/pages/{id}` | страница (Page), `404` для папки | | `GET` | `/api/pages/{id}` | страница (Page), `404` для папки |
| `GET` | `/api/pages/{id}/markdown` | текст страницы, `text/markdown` | | `GET` | `/api/pages/{id}/markdown` | текст страницы, `text/markdown` |

View File

@@ -24,6 +24,7 @@
- Сейчас CRM workspace во встроенную страницу не передаёт: в `crm:auth` только `access_token`, `expires_at`, `user { id, email, name }` (проверено по `js/pagebridge.js` 2026-10-13). Выбранный workspace CRM держит в `localStorage`. - Сейчас 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. - Нужна доработка протокола от Олега (задача в CRM): поле `workspace: { id, kind, title }` в `crm:auth` и новое сообщение при переключении workspace.
- Сервер вики id из сообщения не доверяет, а сверяет доступ: `personal:<uid>` только для владельца, `shared` для всех, `custom` проверяется запросом к таблице `workspaces` Supabase с токеном пользователя (RLS). - Сервер вики id из сообщения не доверяет, а сверяет доступ: `personal:<uid>` только для владельца, `shared` для всех, `custom` проверяется запросом к таблице `workspaces` Supabase с токеном пользователя (RLS).
- Доступ для акаунтів (CRM, 2026-10): владелец открывает другому акаунту свой личный воркспейс (`access_grants`: `projects` none/selected/all, `level` read/full, `plan`, `shared`). Личную вики открывает `projects = all`, права — `level`; через `shared = all` открываются и спільні воркспейсы владельца с правами `level`. `level = read` — вики только для чтения.
- У каждой папки и страницы поле `workspace_id`, дерево показывает только текущий workspace. - У каждой папки и страницы поле `workspace_id`, дерево показывает только текущий workspace.
## Первая версия ## Первая версия

View File

@@ -10,16 +10,41 @@ public class WorkspaceAccessTests
private const string Uid = "11111111-1111-1111-1111-111111111111"; private const string Uid = "11111111-1111-1111-1111-111111111111";
private static readonly WikiUser User = new(Uid, "u@test", "U", DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds()); private static readonly WikiUser User = new(Uid, "u@test", "U", DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds());
/// <summary>Фальшивый Supabase: отвечает на /rest/v1/workspaces строкой, если id в списке «видимых» пользователю.</summary> private const string Owner = "33333333-3333-3333-3333-333333333333";
/// <summary>
/// Фальшивый Supabase: /rest/v1/workspaces отдаёт строку, если id в списке «видимых» пользователю (по умолчанию он
/// её участник); /rest/v1/access_grants — строки Grants, отфильтрованные как PostgREST (owner_uid, grantee_uid, shared).
/// </summary>
private sealed class FakeSupabase(params string[] visible) : HttpMessageHandler private sealed class FakeSupabase(params string[] visible) : HttpMessageHandler
{ {
public List<Uri> Requests { get; } = []; public List<Uri> Requests { get; } = [];
public string WsOwner { get; set; } = Owner;
public string[] WsMembers { get; set; } = [Uid];
public List<(string Owner, string Grantee, string Projects, string Level, string Shared)> Grants { get; } = [];
public HttpStatusCode GrantsStatus { get; set; } = HttpStatusCode.OK;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage req, CancellationToken ct) protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage req, CancellationToken ct)
{ {
Requests.Add(req.RequestUri!); Requests.Add(req.RequestUri!);
var q = System.Web.HttpUtility.ParseQueryString(req.RequestUri!.Query); var q = System.Web.HttpUtility.ParseQueryString(req.RequestUri!.Query);
var id = (q["id"] ?? "").Replace("eq.", ""); string? Eq(string k) => q[k]?.Replace("eq.", "");
var body = visible.Contains(id) ? $"[{{\"id\":\"{id}\"}}]" : "[]"; string body;
if (req.RequestUri.AbsolutePath == "/rest/v1/access_grants")
{
if (GrantsStatus != HttpStatusCode.OK) return Task.FromResult(new HttpResponseMessage(GrantsStatus));
var rows = Grants.Where(g => (Eq("owner_uid") is not { } o || g.Owner == o)
&& (Eq("grantee_uid") is not { } u || g.Grantee == u)
&& (Eq("shared") is not { } sh || g.Shared == sh))
.Select(g => $"{{\"owner_uid\":\"{g.Owner}\",\"projects\":\"{g.Projects}\",\"level\":\"{g.Level}\"}}");
body = "[" + string.Join(",", rows) + "]";
}
else
{
var id = Eq("id") ?? "";
var members = string.Join(",", WsMembers.Select(m => $"\"{m}\""));
body = visible.Contains(id) ? $"[{{\"id\":\"{id}\",\"owner_uid\":\"{WsOwner}\",\"members\":[{members}]}}]" : "[]";
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body) }); return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body) });
} }
} }
@@ -73,7 +98,7 @@ public class WorkspaceAccessTests
var (auth, fake) = Make(); var (auth, fake) = Make();
Assert.True(await auth.CanAccessAsync(User, "token", "shared", default)); Assert.True(await auth.CanAccessAsync(User, "token", "shared", default));
Assert.True(await auth.CanAccessAsync(User, "token", $"personal:{Uid}", default)); Assert.True(await auth.CanAccessAsync(User, "token", $"personal:{Uid}", default));
Assert.False(await auth.CanAccessAsync(User, "token", "personal:someone-else", default)); Assert.False(await auth.CanAccessAsync(User, "token", "personal:someone-else", default)); // не uuid — без запроса
Assert.Empty(fake.Requests); Assert.Empty(fake.Requests);
} }
@@ -107,4 +132,91 @@ public class WorkspaceAccessTests
Assert.True(await auth.CanAccessAsync(User, "token", "wsmv5l7d425re160", default)); Assert.True(await auth.CanAccessAsync(User, "token", "wsmv5l7d425re160", default));
Assert.Single(fake.Requests); Assert.Single(fake.Requests);
} }
// ------------------------------------------------------------ чужое личное: access_grants
[Fact]
public async Task OthersPersonal_DeniedWithoutGrant()
{
var (auth, fake) = Make();
Assert.Equal(SupabaseAuth.Access.None, await auth.AccessAsync(User, "token", $"personal:{Owner}", default));
var uri = Assert.Single(fake.Requests);
Assert.Equal("/rest/v1/access_grants", uri.AbsolutePath);
Assert.Contains($"owner_uid=eq.{Owner}", uri.Query);
Assert.Contains($"grantee_uid=eq.{Uid}", uri.Query);
}
[Theory]
[InlineData("all", "full", SupabaseAuth.Access.Full)]
[InlineData("all", "read", SupabaseAuth.Access.Read)]
[InlineData("selected", "full", SupabaseAuth.Access.None)] // вибрані проєкти вики не открывают
[InlineData("none", "full", SupabaseAuth.Access.None)] // только план — тоже
public async Task OthersPersonal_ByGrantProjects(string projects, string level, SupabaseAuth.Access expected)
{
var (auth, fake) = Make();
fake.Grants.Add((Owner, Uid, projects, level, "none"));
Assert.Equal(expected, await auth.AccessAsync(User, "token", $"personal:{Owner}", default));
}
[Fact]
public async Task OthersPersonal_GrantToSomeoneElse_DoesNotCount()
{
var (auth, fake) = Make();
fake.Grants.Add((Owner, "44444444-4444-4444-4444-444444444444", "all", "full", "none"));
Assert.Equal(SupabaseAuth.Access.None, await auth.AccessAsync(User, "token", $"personal:{Owner}", default));
}
[Fact]
public async Task OthersPersonal_SupabaseError_DeniedAndNotCached()
{
var (auth, fake) = Make();
fake.Grants.Add((Owner, Uid, "all", "full", "none"));
fake.GrantsStatus = HttpStatusCode.InternalServerError;
Assert.Equal(SupabaseAuth.Access.None, await auth.AccessAsync(User, "token", $"personal:{Owner}", default));
fake.GrantsStatus = HttpStatusCode.OK;
Assert.Equal(SupabaseAuth.Access.Full, await auth.AccessAsync(User, "token", $"personal:{Owner}", default));
}
[Theory]
[InlineData("personal:")]
[InlineData("personal:x&owner_uid=neq.0")]
[InlineData("personal:33333333-3333-3333-3333-333333333333&select=*")]
public async Task OthersPersonal_NonUuidOwner_RejectedBeforeSupabase(string id)
{
var (auth, fake) = Make();
Assert.Equal(SupabaseAuth.Access.None, await auth.AccessAsync(User, "token", id, default));
Assert.Empty(fake.Requests);
}
// ------------------------------------------------------------ custom: участник или через access_grants.shared
[Fact]
public async Task Custom_OwnerOrMember_Full()
{
var (auth, fake) = Make("wsmv5l7d425re160", "wsmv5l7d425re161");
Assert.Equal(SupabaseAuth.Access.Full, await auth.AccessAsync(User, "token", "wsmv5l7d425re160", default)); // участник
fake.WsOwner = Uid; fake.WsMembers = [];
Assert.Equal(SupabaseAuth.Access.Full, await auth.AccessAsync(User, "token", "wsmv5l7d425re161", default)); // владелец
Assert.DoesNotContain(fake.Requests, u => u.AbsolutePath == "/rest/v1/access_grants");
}
[Theory]
[InlineData("full", SupabaseAuth.Access.Full)]
[InlineData("read", SupabaseAuth.Access.Read)]
public async Task Custom_VisibleViaSharedGrant_LevelFromGrant(string level, SupabaseAuth.Access expected)
{
var (auth, fake) = Make("wsmv5l7d425re160");
fake.WsMembers = ["55555555-5555-5555-5555-555555555555"];
fake.Grants.Add(("55555555-5555-5555-5555-555555555555", Uid, "none", level, "all"));
Assert.Equal(expected, await auth.AccessAsync(User, "token", "wsmv5l7d425re160", default));
}
[Fact]
public async Task Custom_VisibleButNotMemberAndNoGrant_Denied()
{
var (auth, fake) = Make("wsmv5l7d425re160");
fake.WsMembers = [];
fake.Grants.Add((Owner, Uid, "all", "full", "none")); // личное открыто, спільні — нет
Assert.Equal(SupabaseAuth.Access.None, await auth.AccessAsync(User, "token", "wsmv5l7d425re160", default));
}
} }

View File

@@ -18,8 +18,10 @@ public sealed partial class SupabaseAuth(IHttpClientFactory http, IConfiguration
private readonly string _url = (cfg["Supabase:Url"] ?? "").TrimEnd('/'); private readonly string _url = (cfg["Supabase:Url"] ?? "").TrimEnd('/');
private readonly string _key = cfg["Supabase:PublishableKey"] ?? ""; private readonly string _key = cfg["Supabase:PublishableKey"] ?? "";
private readonly ConcurrentDictionary<string, (WikiUser User, DateTimeOffset Until)> _users = new(); private readonly ConcurrentDictionary<string, (WikiUser User, DateTimeOffset Until)> _users = new();
private readonly ConcurrentDictionary<string, (bool Ok, DateTimeOffset Until)> _ws = new(); private readonly ConcurrentDictionary<string, (Access Access, DateTimeOffset Until)> _ws = new();
private static readonly TimeSpan CacheFor = TimeSpan.FromMinutes(5); private static readonly TimeSpan CacheFor = TimeSpan.FromMinutes(5);
// Доступ к workspace кэшируем коротко: выданный или отозванный в CRM доступ вступает в силу в пределах минуты.
private static readonly TimeSpan WsCacheFor = TimeSpan.FromMinutes(1);
public const string Shared = "shared"; public const string Shared = "shared";
@@ -76,36 +78,90 @@ public sealed partial class SupabaseAuth(IHttpClientFactory http, IConfiguration
} }
} }
/// <summary>Права в workspace: нет, только чтение, полные.</summary>
public enum Access { None, Read, Full }
public async Task<bool> CanAccessAsync(WikiUser user, string token, string workspaceId, CancellationToken ct) =>
await AccessAsync(user, token, workspaceId, ct) != Access.None;
/// <summary> /// <summary>
/// Нормализует и проверяет workspace. id из клиента не доверяем: /// Нормализует и проверяет workspace. id из клиента не доверяем:
/// shared — для всех, personal:&lt;uid&gt; — только владельцу, остальные (custom, напр. wsmv5l7d425re160) — /// shared — для всех; personal:&lt;uid&gt; — владельцу, а другим — по выдаче доступа CRM (access_grants);
/// запросом к таблице workspaces Supabase с токеном пользователя: RLS отдаёт строку, только если он владелец или участник. /// остальные (custom, напр. wsmv5l7d425re160) — запросом к таблице workspaces Supabase с токеном пользователя
/// (RLS отдаёт строку владельцу, участнику и тому, кому открыли спільні воркспейси через access_grants.shared).
/// </summary> /// </summary>
public async Task<bool> CanAccessAsync(WikiUser user, string token, string workspaceId, CancellationToken ct) public async Task<Access> AccessAsync(WikiUser user, string token, string workspaceId, CancellationToken ct)
{ {
if (workspaceId == Shared) return true; if (workspaceId == Shared) return Access.Full;
string? owner = null;
if (workspaceId.StartsWith("personal:", StringComparison.Ordinal)) if (workspaceId.StartsWith("personal:", StringComparison.Ordinal))
return workspaceId == $"personal:{user.Id}"; {
if (!CustomWsRx().IsMatch(workspaceId)) return false; owner = workspaceId["personal:".Length..];
if (owner == user.Id) return Access.Full;
if (!Guid.TryParse(owner, out _)) return Access.None; // uid Supabase — uuid; значение идёт в фильтр PostgREST
}
else if (!CustomWsRx().IsMatch(workspaceId)) return Access.None;
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
var key = user.Id + "|" + workspaceId; var key = user.Id + "|" + workspaceId;
if (_ws.TryGetValue(key, out var hit) && hit.Until > now) return hit.Ok; if (_ws.TryGetValue(key, out var hit) && hit.Until > now) return hit.Access;
bool ok; Access? got;
try try { got = owner is not null ? await PersonalGrantAsync(user, token, owner, ct) : await CustomAsync(user, token, workspaceId, ct); }
catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException) { return Access.None; }
if (got is not { } access) return Access.None; // ошибку не кэшируем
_ws[key] = (access, now + WsCacheFor);
return access;
}
/// <summary>
/// Чужое личное: строка access_grants владельца для меня (RLS отдаёт адресату его строки). Личная вики — часть
/// личного воркспейса, поэтому её открывает «Мої особисті проєкти: Усі» (projects = all), права — level (read/full).
/// «Вибрані» проєкты и один лишь план вики не открывают.
/// </summary>
private async Task<Access?> PersonalGrantAsync(WikiUser user, string token, string owner, CancellationToken ct)
{ {
using var req = new HttpRequestMessage(HttpMethod.Get, var rows = await SelectAsync(token,
$"{_url}/rest/v1/workspaces?id=eq.{Uri.EscapeDataString(workspaceId)}&select=id"); $"access_grants?owner_uid=eq.{owner}&grantee_uid=eq.{Uri.EscapeDataString(user.Id)}&select=projects,level", ct);
if (rows is null) return null;
foreach (var g in rows.Value.EnumerateArray())
if (Str(g, "projects") == "all") return Str(g, "level") == "full" ? Access.Full : Access.Read;
return Access.None;
}
/// <summary>
/// Свой custom (владелец или участник) — полные права. Строка видна, но я не владелец и не участник — значит,
/// её открыли через access_grants.shared = all владельцем или участником этого воркспейса: права по level, как в CRM.
/// </summary>
private async Task<Access?> CustomAsync(WikiUser user, string token, string workspaceId, CancellationToken ct)
{
var ws = await SelectAsync(token, $"workspaces?id=eq.{Uri.EscapeDataString(workspaceId)}&select=id,owner_uid,members", ct);
if (ws is null) return null;
if (ws.Value.GetArrayLength() == 0) return Access.None;
var row = ws.Value[0];
var people = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (Str(row, "owner_uid") is { } o) people.Add(o);
if (row.TryGetProperty("members", out var m) && m.ValueKind == JsonValueKind.Array)
foreach (var x in m.EnumerateArray()) if (x.ValueKind == JsonValueKind.String) people.Add(x.GetString()!);
if (people.Contains(user.Id)) return Access.Full;
var grants = await SelectAsync(token,
$"access_grants?grantee_uid=eq.{Uri.EscapeDataString(user.Id)}&shared=eq.all&select=owner_uid,level", ct);
if (grants is null) return null;
var via = grants.Value.EnumerateArray().Where(g => Str(g, "owner_uid") is { } ow && people.Contains(ow)).ToList();
if (via.Count == 0) return Access.None;
return via.Any(g => Str(g, "level") == "full") ? Access.Full : Access.Read;
}
/// <summary>GET /rest/v1/&lt;query&gt; с токеном пользователя. null — ошибка (не 2xx или не массив).</summary>
private async Task<JsonElement?> SelectAsync(string token, string query, CancellationToken ct)
{
using var req = new HttpRequestMessage(HttpMethod.Get, $"{_url}/rest/v1/{query}");
req.Headers.Add("apikey", _key); req.Headers.Add("apikey", _key);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var r = await http.CreateClient("supabase").SendAsync(req, ct); using var r = await http.CreateClient("supabase").SendAsync(req, ct);
if (!r.IsSuccessStatusCode) return false; // ошибку не кэшируем if (!r.IsSuccessStatusCode) return null;
using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct)); using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct));
ok = doc.RootElement.ValueKind == JsonValueKind.Array && doc.RootElement.GetArrayLength() > 0; return doc.RootElement.ValueKind == JsonValueKind.Array ? doc.RootElement.Clone() : null;
}
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) private void Sweep(DateTimeOffset now)

View File

@@ -100,13 +100,18 @@ app.Use(async (ctx, next) =>
} }
var ws = ctx.Request.Headers["X-Wiki-Workspace"].ToString().Trim(); var ws = ctx.Request.Headers["X-Wiki-Workspace"].ToString().Trim();
if (ws.Length == 0) ws = SupabaseAuth.Shared; if (ws.Length == 0) ws = SupabaseAuth.Shared;
if (!await sa.CanAccessAsync(user, token, ws, ctx.RequestAborted)) { await Deny(ctx, 403, "немає доступу до воркспейсу"); return; } var access = await sa.AccessAsync(user, token, ws, ctx.RequestAborted);
if (access == SupabaseAuth.Access.None) { await Deny(ctx, 403, "немає доступу до воркспейсу"); return; }
// Доступ «лише перегляд» (выдача CRM с level = read): читать можно, менять — нет.
if (access == SupabaseAuth.Access.Read && !HttpMethods.IsGet(ctx.Request.Method) && !HttpMethods.IsHead(ctx.Request.Method))
{ await Deny(ctx, 403, "воркспейс відкрито лише для перегляду"); return; }
// Профиль (имя, аватар из довідника CRM) — для поля «автор»; сбой не мешает запросу. // Профиль (имя, аватар из довідника CRM) — для поля «автор»; сбой не мешает запросу.
try { await ctx.RequestServices.GetRequiredService<Profiles>().EnsureAsync(user, token, ctx.RequestAborted); } try { await ctx.RequestServices.GetRequiredService<Profiles>().EnsureAsync(user, token, ctx.RequestAborted); }
catch (Exception e) when (e is not OperationCanceledException) { /* профиль обновится в следующий раз */ } catch (Exception e) when (e is not OperationCanceledException) { /* профиль обновится в следующий раз */ }
ctx.Items["user"] = user; ctx.Items["user"] = user;
ctx.Items["token"] = token; ctx.Items["token"] = token;
ctx.Items["ws"] = ws; ctx.Items["ws"] = ws;
ctx.Items["access"] = access;
await next(); await next();
}); });
@@ -117,6 +122,7 @@ static Task Deny(HttpContext ctx, int status, string error)
} }
static WikiUser U(HttpContext c) => (WikiUser)c.Items["user"]!; static WikiUser U(HttpContext c) => (WikiUser)c.Items["user"]!;
static string Ws(HttpContext c) => (string)c.Items["ws"]!; static string Ws(HttpContext c) => (string)c.Items["ws"]!;
static string Acc(HttpContext c) => (SupabaseAuth.Access)c.Items["access"]! == SupabaseAuth.Access.Read ? "read" : "full";
// ---------------------------------------------------------------- статика SPA // ---------------------------------------------------------------- статика SPA
var types = new FileExtensionContentTypeProvider(); var types = new FileExtensionContentTypeProvider();
@@ -153,9 +159,9 @@ app.MapPost("/api/verify", async (HttpContext ctx, SupabaseAuth sa) =>
// ---------------------------------------------------------------- вики // ---------------------------------------------------------------- вики
var api = app.MapGroup("/api"); 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("/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), access = Acc(c) }); });
api.MapGet("/tree", (HttpContext c, Store s) => Results.Ok(new { workspace = Ws(c), nodes = s.Tree(Ws(c)) })); api.MapGet("/tree", (HttpContext c, Store s) => Results.Ok(new { workspace = Ws(c), access = Acc(c), nodes = s.Tree(Ws(c)) }));
api.MapGet("/nodes/{id}", (HttpContext c, Store s, string id) => api.MapGet("/nodes/{id}", (HttpContext c, Store s, string id) =>
s.Get(Ws(c), id) is { } p ? Results.Ok(p) : Results.NotFound(new { error = "не знайдено" })); s.Get(Ws(c), id) is { } p ? Results.Ok(p) : Results.NotFound(new { error = "не знайдено" }));

View File

@@ -99,9 +99,10 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) {
const [hits, setHits] = useState<SearchHit[] | null>(null); const [hits, setHits] = useState<SearchHit[] | null>(null);
const [sideOpen, setSideOpen] = useState(false); const [sideOpen, setSideOpen] = useState(false);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [readOnly, setReadOnly] = useState(false); // воркспейс открыт мне в CRM лишь для просмотра
const reloadTree = useCallback(async () => { const reloadTree = useCallback(async () => {
try { setNodes((await Api.tree()).nodes); setTreeErr(""); } try { const t = await Api.tree(); setNodes(t.nodes); setReadOnly(t.access === "read"); setTreeErr(""); }
catch (e) { setTreeErr((e as Error).message); } catch (e) { setTreeErr((e as Error).message); }
}, []); }, []);
@@ -153,7 +154,7 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) {
const current: Node | undefined = currentId ? byId.get(currentId) : undefined; const current: Node | undefined = currentId ? byId.get(currentId) : undefined;
const crumbs: Crumb[] = page && page.id === currentId ? page.path : []; const crumbs: Crumb[] = page && page.id === currentId ? page.path : [];
const wsLabel = props.ws === "shared" ? "Загальний" : props.ws.startsWith("personal:") ? "Мій" : props.wsTitle || "Воркспейс"; const wsLabel = props.ws === "shared" ? "Загальний" : props.wsTitle || (props.ws.startsWith("personal:") ? "Мій" : "Воркспейс");
const listing = (parentId: string | null) => { const listing = (parentId: string | null) => {
const list = kids.get(parentId) ?? []; const list = kids.get(parentId) ?? [];
@@ -166,7 +167,13 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) {
)); ));
}; };
const actions = (n: Node) => ( const actions = (n: Node) => readOnly ? (
<div className="row wrap">
<ShareButton path={hrefOf(n.kind, n.id)} />
{n.kind === "page" && <CopyButton text={`[${n.title}](/p/${n.id})`} label="Markdown-посилання"
title="Посилання виду /p/<id> не ламається при перейменуванні та перенесенні" />}
</div>
) : (
<div className="row wrap"> <div className="row wrap">
{n.kind === "page" && !editing && ( {n.kind === "page" && !editing && (
<button className="btn sm ghost" onClick={() => setDlg({ kind: "rename", node: n })}><Icon name="edit" />Перейменувати</button> <button className="btn sm ghost" onClick={() => setDlg({ kind: "rename", node: n })}><Icon name="edit" />Перейменувати</button>
@@ -188,12 +195,12 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) {
if (route.kind === "root") { if (route.kind === "root") {
content = <> content = <>
<div className="head"> <div className="head">
<div><h1>Вікі</h1><div className="sub">Воркспейс: <b>{wsLabel}</b></div></div> <div><h1>Вікі</h1><div className="sub">Воркспейс: <b>{wsLabel}</b>{readOnly && " · лише перегляд"}</div></div>
</div> </div>
<div className="row wrap"> {!readOnly && <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 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> <button className="btn sm" onClick={() => setDlg({ kind: "new", what: "folder", parentId: null })}><Icon name="folder" />Папка</button>
</div> </div>}
<div className="sec"><h2>Вміст <b>{kids.get(null)?.length ?? 0}</b></h2></div> <div className="sec"><h2>Вміст <b>{kids.get(null)?.length ?? 0}</b></h2></div>
{nodes ? listing(null) : <div className="empty">Завантаження…</div>} {nodes ? listing(null) : <div className="empty">Завантаження…</div>}
</>; </>;
@@ -211,7 +218,7 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) {
</>; </>;
} else { } else {
content = <> content = <>
<PageView key={`${page.id}:${page.version}`} page={page} nodes={nodes ?? []} onModeChange={setEditing} <PageView key={`${page.id}:${page.version}`} page={page} nodes={nodes ?? []} onModeChange={setEditing} readOnly={readOnly}
onSaved={(p, titleChanged) => { 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); 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(); if (titleChanged) void reloadTree();
@@ -246,16 +253,16 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) {
<> <>
<div className="sec"> <div className="sec">
<h2>{wsLabel}</h2> <h2>{wsLabel}</h2>
<div className="row"> {!readOnly && <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: "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> <button className="icon-btn sm" title="Нова сторінка" aria-label="Нова сторінка" onClick={() => setDlg({ kind: "new", what: "page", parentId: null })}><Icon name="plus" /></button>
</div> </div>}
</div> </div>
<a className={"wk-row wk-home" + (route.kind === "root" ? " on" : "")} href="/" onClick={(e) => { e.preventDefault(); void navigate("/"); }}> <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> <span className="wk-link"><Icon name="home" /><span>Головна</span></span>
</a> </a>
{treeErr && <div className="err">{treeErr}</div>} {treeErr && <div className="err">{treeErr}</div>}
{nodes ? <Tree nodes={nodes} ws={props.ws} currentId={currentId} onAddPage={(pid) => setDlg({ kind: "new", what: "page", parentId: pid })} /> {nodes ? <Tree nodes={nodes} ws={props.ws} currentId={currentId} onAddPage={readOnly ? undefined : (pid) => setDlg({ kind: "new", what: "page", parentId: pid })} />
: !treeErr && <div className="empty">Завантаження…</div>} : !treeErr && <div className="empty">Завантаження…</div>}
</> </>
)} )}

View File

@@ -20,6 +20,7 @@ export const markOpenInEdit = (id: string) => openInEdit.add(id);
*/ */
export function PageView(props: { export function PageView(props: {
page: Page; nodes: Node[]; onSaved: (p: Page, titleChanged: boolean) => void; onModeChange?: (editing: boolean) => void; page: Page; nodes: Node[]; onSaved: (p: Page, titleChanged: boolean) => void; onModeChange?: (editing: boolean) => void;
readOnly?: boolean;
}) { }) {
const [editing, setEditing] = useState(() => openInEdit.delete(props.page.id)); const [editing, setEditing] = useState(() => openInEdit.delete(props.page.id));
const [leaveMsg, setLeaveMsg] = useState(""); const [leaveMsg, setLeaveMsg] = useState("");
@@ -149,7 +150,7 @@ export function PageView(props: {
{editing ? ( {editing ? (
<button key="done" className="btn sm gold" onClick={() => void finish()} disabled={state === "saving" || !!conflict} <button key="done" className="btn sm gold" onClick={() => void finish()} disabled={state === "saving" || !!conflict}
title="Зберегти зміни й повернутися до перегляду"><Icon name="check" />Готово</button> title="Зберегти зміни й повернутися до перегляду"><Icon name="check" />Готово</button>
) : ( ) : !props.readOnly && (
<button key="edit" className="btn sm gold" onClick={() => setEditing(true)}><Icon name="edit" />Редагувати</button> <button key="edit" className="btn sm gold" onClick={() => setEditing(true)}><Icon name="edit" />Редагувати</button>
)} )}
</div> </div>

View File

@@ -22,7 +22,7 @@ export function childrenMap(nodes: Node[]) {
const storeKey = (ws: string) => `wiki.open.${ws}`; const storeKey = (ws: string) => `wiki.open.${ws}`;
/** Дерево папок и страниц: раскрыть/свернуть, текущий узел подсвечен, предки раскрыты. */ /** Дерево папок и страниц: раскрыть/свернуть, текущий узел подсвечен, предки раскрыты. */
export function Tree(props: { nodes: Node[]; ws: string; currentId: string | null; onAddPage: (parentId: string) => void }) { export function Tree(props: { nodes: Node[]; ws: string; currentId: string | null; onAddPage?: (parentId: string) => void }) {
const kids = useMemo(() => childrenMap(props.nodes), [props.nodes]); const kids = useMemo(() => childrenMap(props.nodes), [props.nodes]);
const byId = useMemo(() => new Map(props.nodes.map((n) => [n.id, n])), [props.nodes]); const byId = useMemo(() => new Map(props.nodes.map((n) => [n.id, n])), [props.nodes]);
const [open, setOpen] = useState<Set<string>>(() => { const [open, setOpen] = useState<Set<string>>(() => {
@@ -71,8 +71,8 @@ export function Tree(props: { nodes: Node[]; ws: string; currentId: string | nul
<Icon name={n.kind === "folder" ? "folder" : "page"} /> <Icon name={n.kind === "folder" ? "folder" : "page"} />
<span className="trunc">{n.title}</span> <span className="trunc">{n.title}</span>
</a> </a>
{n.kind === "folder" && ( {n.kind === "folder" && props.onAddPage && (
<button className="wk-add" title="Нова сторінка в папці" aria-label="Нова сторінка в папці" onClick={() => props.onAddPage(n.id)}> <button className="wk-add" title="Нова сторінка в папці" aria-label="Нова сторінка в папці" onClick={() => props.onAddPage?.(n.id)}>
<Icon name="plus" /> <Icon name="plus" />
</button> </button>
)} )}

View File

@@ -40,7 +40,8 @@ export async function api<T = any>(path: string, init: RequestInit = {}, ws = cu
const json = (method: string, body: unknown): RequestInit => ({ method, body: JSON.stringify(body) }); const json = (method: string, body: unknown): RequestInit => ({ method, body: JSON.stringify(body) });
export const Api = { export const Api = {
tree: () => api<{ workspace: string; nodes: Node[] }>("/api/tree"), /** access: read — воркспейс открыт мне в CRM лишь для просмотра (сервер не примет изменений). */
tree: () => api<{ workspace: string; access: "read" | "full"; nodes: Node[] }>("/api/tree"),
page: (id: string) => api<Page>(`/api/nodes/${encodeURIComponent(id)}`), page: (id: string) => api<Page>(`/api/nodes/${encodeURIComponent(id)}`),
createPage: (title: string, parentId: string | null, markdown = "") => createPage: (title: string, parentId: string | null, markdown = "") =>
api<Page>("/api/pages", json("POST", { title, parentId, markdown })), api<Page>("/api/pages", json("POST", { title, parentId, markdown })),

View File

@@ -70,8 +70,9 @@ function wsKey(w: CrmWorkspace | null | undefined, uid: string | undefined): { i
const id = String(w.id ?? ""); const id = String(w.id ?? "");
const kind = String(w.kind ?? ""); const kind = String(w.kind ?? "");
if (kind === "shared" || id === "shared" || (!id && !kind)) return { id: "shared", title: w.title }; if (kind === "shared" || id === "shared" || (!id && !kind)) return { id: "shared", title: w.title };
if (kind === "personal" || id === "personal" || id.startsWith("personal:")) if (kind === "personal" || id === "personal")
return uid ? { id: `personal:${uid}`, title: w.title } : { id: "shared" }; return uid ? { id: `personal:${uid}`, title: w.title } : { id: "shared" };
// Иначе id как есть — custom или personal:<uid> чужого, открытое мне в CRM (kind: "granted"); доступ проверит сервер.
return { id, title: w.title }; return { id, title: w.title };
} }