Вики для CRM: ASP.NET Core + SQLite/FTS5 + React/TipTap, авторизация через CRM
This commit is contained in:
133
backend/Wiki.Api/Auth.cs
Normal file
133
backend/Wiki.Api/Auth.cs
Normal 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:<uid> — только владельцу, <uuid> (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
110
backend/Wiki.Api/Db.cs
Normal 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
252
backend/Wiki.Api/Program.cs
Normal 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
308
backend/Wiki.Api/Store.cs
Normal 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;
|
||||
}
|
||||
13
backend/Wiki.Api/Wiki.Api.csproj
Normal file
13
backend/Wiki.Api/Wiki.Api.csproj
Normal 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>
|
||||
16
backend/Wiki.Api/appsettings.json
Normal file
16
backend/Wiki.Api/appsettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user