Доступ для акаунтів CRM: чужое личное по access_grants (projects=all, level read/full), custom через shared=all; режим «лише перегляд» (403 на изменения, access в /api/me и /api/tree); кэш доступа к workspace — 1 мин
This commit is contained in:
@@ -16,9 +16,9 @@
|
||||
| `Authorization` | `Bearer <access_token Supabase из CRM>` |
|
||||
| `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
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
| `GET` | `/health` | `{ status, dbCreatedAt }` — без авторизации; `dbCreatedAt` не меняется, пока жив том с базой |
|
||||
| `GET` | `/api/config` | `{ crmOrigins: [...] }` — без авторизации |
|
||||
| `POST` | `/api/verify` | `{ access_token }` → `{ ok, id, email, name, exp }` — без авторизации |
|
||||
| `GET` | `/api/me` | текущий пользователь и workspace |
|
||||
| `GET` | `/api/tree` | `{ workspace, nodes: [Node] }` — все папки и страницы workspace без текста |
|
||||
| `GET` | `/api/me` | текущий пользователь, workspace и `access` (`read` / `full`) |
|
||||
| `GET` | `/api/tree` | `{ workspace, access, nodes: [Node] }` — все папки и страницы workspace без текста |
|
||||
| `GET` | `/api/nodes/{id}` | папка или страница (Page) |
|
||||
| `GET` | `/api/pages/{id}` | страница (Page), `404` для папки |
|
||||
| `GET` | `/api/pages/{id}/markdown` | текст страницы, `text/markdown` |
|
||||
|
||||
1
SPEC.md
1
SPEC.md
@@ -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: { id, kind, title }` в `crm:auth` и новое сообщение при переключении workspace.
|
||||
- Сервер вики 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.
|
||||
|
||||
## Первая версия
|
||||
|
||||
@@ -10,16 +10,41 @@ public class WorkspaceAccessTests
|
||||
private const string Uid = "11111111-1111-1111-1111-111111111111";
|
||||
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
|
||||
{
|
||||
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)
|
||||
{
|
||||
Requests.Add(req.RequestUri!);
|
||||
var q = System.Web.HttpUtility.ParseQueryString(req.RequestUri!.Query);
|
||||
var id = (q["id"] ?? "").Replace("eq.", "");
|
||||
var body = visible.Contains(id) ? $"[{{\"id\":\"{id}\"}}]" : "[]";
|
||||
string? Eq(string k) => q[k]?.Replace("eq.", "");
|
||||
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) });
|
||||
}
|
||||
}
|
||||
@@ -73,7 +98,7 @@ public class WorkspaceAccessTests
|
||||
var (auth, fake) = Make();
|
||||
Assert.True(await auth.CanAccessAsync(User, "token", "shared", 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);
|
||||
}
|
||||
|
||||
@@ -107,4 +132,91 @@ public class WorkspaceAccessTests
|
||||
Assert.True(await auth.CanAccessAsync(User, "token", "wsmv5l7d425re160", default));
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,10 @@ public sealed partial class SupabaseAuth(IHttpClientFactory http, IConfiguration
|
||||
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 readonly ConcurrentDictionary<string, (Access Access, DateTimeOffset Until)> _ws = new();
|
||||
private static readonly TimeSpan CacheFor = TimeSpan.FromMinutes(5);
|
||||
// Доступ к workspace кэшируем коротко: выданный или отозванный в CRM доступ вступает в силу в пределах минуты.
|
||||
private static readonly TimeSpan WsCacheFor = TimeSpan.FromMinutes(1);
|
||||
|
||||
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>
|
||||
/// Нормализует и проверяет workspace. id из клиента не доверяем:
|
||||
/// shared — для всех, personal:<uid> — только владельцу, остальные (custom, напр. wsmv5l7d425re160) —
|
||||
/// запросом к таблице workspaces Supabase с токеном пользователя: RLS отдаёт строку, только если он владелец или участник.
|
||||
/// shared — для всех; personal:<uid> — владельцу, а другим — по выдаче доступа CRM (access_grants);
|
||||
/// остальные (custom, напр. wsmv5l7d425re160) — запросом к таблице workspaces Supabase с токеном пользователя
|
||||
/// (RLS отдаёт строку владельцу, участнику и тому, кому открыли спільні воркспейси через access_grants.shared).
|
||||
/// </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))
|
||||
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 key = user.Id + "|" + workspaceId;
|
||||
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.{Uri.EscapeDataString(workspaceId)}&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;
|
||||
if (_ws.TryGetValue(key, out var hit) && hit.Until > now) return hit.Access;
|
||||
Access? got;
|
||||
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)
|
||||
{
|
||||
var rows = await SelectAsync(token,
|
||||
$"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/<query> с токеном пользователя. 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.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
using var r = await http.CreateClient("supabase").SendAsync(req, ct);
|
||||
if (!r.IsSuccessStatusCode) return null;
|
||||
using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct));
|
||||
return doc.RootElement.ValueKind == JsonValueKind.Array ? doc.RootElement.Clone() : null;
|
||||
}
|
||||
|
||||
private void Sweep(DateTimeOffset now)
|
||||
|
||||
@@ -100,13 +100,18 @@ app.Use(async (ctx, next) =>
|
||||
}
|
||||
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, "немає доступу до воркспейсу"); 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) — для поля «автор»; сбой не мешает запросу.
|
||||
try { await ctx.RequestServices.GetRequiredService<Profiles>().EnsureAsync(user, token, ctx.RequestAborted); }
|
||||
catch (Exception e) when (e is not OperationCanceledException) { /* профиль обновится в следующий раз */ }
|
||||
ctx.Items["user"] = user;
|
||||
ctx.Items["token"] = token;
|
||||
ctx.Items["ws"] = ws;
|
||||
ctx.Items["access"] = access;
|
||||
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 string Ws(HttpContext c) => (string)c.Items["ws"]!;
|
||||
static string Acc(HttpContext c) => (SupabaseAuth.Access)c.Items["access"]! == SupabaseAuth.Access.Read ? "read" : "full";
|
||||
|
||||
// ---------------------------------------------------------------- статика SPA
|
||||
var types = new FileExtensionContentTypeProvider();
|
||||
@@ -153,9 +159,9 @@ app.MapPost("/api/verify", async (HttpContext ctx, SupabaseAuth sa) =>
|
||||
// ---------------------------------------------------------------- вики
|
||||
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) =>
|
||||
s.Get(Ws(c), id) is { } p ? Results.Ok(p) : Results.NotFound(new { error = "не знайдено" }));
|
||||
|
||||
@@ -99,9 +99,10 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) {
|
||||
const [hits, setHits] = useState<SearchHit[] | null>(null);
|
||||
const [sideOpen, setSideOpen] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [readOnly, setReadOnly] = useState(false); // воркспейс открыт мне в CRM лишь для просмотра
|
||||
|
||||
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); }
|
||||
}, []);
|
||||
|
||||
@@ -153,7 +154,7 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) {
|
||||
|
||||
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 || "Воркспейс";
|
||||
const wsLabel = props.ws === "shared" ? "Загальний" : props.wsTitle || (props.ws.startsWith("personal:") ? "Мій" : "Воркспейс");
|
||||
|
||||
const listing = (parentId: string | null) => {
|
||||
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">
|
||||
{n.kind === "page" && !editing && (
|
||||
<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") {
|
||||
content = <>
|
||||
<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 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" 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>
|
||||
{nodes ? listing(null) : <div className="empty">Завантаження…</div>}
|
||||
</>;
|
||||
@@ -211,7 +218,7 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) {
|
||||
</>;
|
||||
} else {
|
||||
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) => {
|
||||
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();
|
||||
@@ -246,16 +253,16 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) {
|
||||
<>
|
||||
<div className="sec">
|
||||
<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: "page", parentId: null })}><Icon name="plus" /></button>
|
||||
</div>
|
||||
</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 })} />
|
||||
{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>}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -20,6 +20,7 @@ export const markOpenInEdit = (id: string) => openInEdit.add(id);
|
||||
*/
|
||||
export function PageView(props: {
|
||||
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 [leaveMsg, setLeaveMsg] = useState("");
|
||||
@@ -149,7 +150,7 @@ export function PageView(props: {
|
||||
{editing ? (
|
||||
<button key="done" className="btn sm gold" onClick={() => void finish()} disabled={state === "saving" || !!conflict}
|
||||
title="Зберегти зміни й повернутися до перегляду"><Icon name="check" />Готово</button>
|
||||
) : (
|
||||
) : !props.readOnly && (
|
||||
<button key="edit" className="btn sm gold" onClick={() => setEditing(true)}><Icon name="edit" />Редагувати</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ export function childrenMap(nodes: Node[]) {
|
||||
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 byId = useMemo(() => new Map(props.nodes.map((n) => [n.id, n])), [props.nodes]);
|
||||
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"} />
|
||||
<span className="trunc">{n.title}</span>
|
||||
</a>
|
||||
{n.kind === "folder" && (
|
||||
<button className="wk-add" title="Нова сторінка в папці" aria-label="Нова сторінка в папці" onClick={() => props.onAddPage(n.id)}>
|
||||
{n.kind === "folder" && props.onAddPage && (
|
||||
<button className="wk-add" title="Нова сторінка в папці" aria-label="Нова сторінка в папці" onClick={() => props.onAddPage?.(n.id)}>
|
||||
<Icon name="plus" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -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) });
|
||||
|
||||
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)}`),
|
||||
createPage: (title: string, parentId: string | null, markdown = "") =>
|
||||
api<Page>("/api/pages", json("POST", { title, parentId, markdown })),
|
||||
|
||||
@@ -70,8 +70,9 @@ function wsKey(w: CrmWorkspace | null | undefined, uid: string | undefined): { i
|
||||
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:"))
|
||||
if (kind === "personal" || id === "personal")
|
||||
return uid ? { id: `personal:${uid}`, title: w.title } : { id: "shared" };
|
||||
// Иначе id как есть — custom или personal:<uid> чужого, открытое мне в CRM (kind: "granted"); доступ проверит сервер.
|
||||
return { id, title: w.title };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user