first init
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY public/ /usr/share/nginx/html/
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
HEALTHCHECK --interval=15s --timeout=3s --start-period=3s --retries=3 \
|
||||
CMD wget -q -O /dev/null http://127.0.0.1/healthz || exit 1
|
||||
@@ -0,0 +1,20 @@
|
||||
# OVE API documentation
|
||||
|
||||
Static public API reference served at `https://docs.ove.rs`.
|
||||
|
||||
Regenerate the machine-readable catalog and OpenAPI documents:
|
||||
|
||||
```sh
|
||||
python3 scripts/generate.py
|
||||
```
|
||||
|
||||
Verify that every public route in the Messenger and Crypto Gateway source is
|
||||
documented:
|
||||
|
||||
```sh
|
||||
python3 scripts/verify.py
|
||||
```
|
||||
|
||||
The verifier intentionally excludes Messenger `/internal/*` routes and the
|
||||
Crypto Gateway browser console. Hosted checkout endpoints remain part of the
|
||||
public reference.
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location = /healthz {
|
||||
access_log off;
|
||||
default_type text/plain;
|
||||
return 200 "ok\n";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(?:css|js|json|txt)$ {
|
||||
expires 5m;
|
||||
add_header Cache-Control "public, max-age=300";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+142
@@ -0,0 +1,142 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const list = document.querySelector("#endpoint-list");
|
||||
const template = document.querySelector("#endpoint-template");
|
||||
const search = document.querySelector("#search");
|
||||
const filters = document.querySelector("#service-filters");
|
||||
const count = document.querySelector("#endpoint-count");
|
||||
const empty = document.querySelector("#empty");
|
||||
const sidebar = document.querySelector("#sidebar");
|
||||
const menu = document.querySelector("#menu");
|
||||
let manifest;
|
||||
let activeService = "all";
|
||||
|
||||
const escapeHtml = (value) => String(value)
|
||||
.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
|
||||
function curlFor(service, endpoint) {
|
||||
const url = `${service.base_url}${endpoint.path.replaceAll(/{([^}]+)}/g, "<$1>")}`;
|
||||
const lines = [`curl -X ${endpoint.method} '${url}'`];
|
||||
if (endpoint.auth === "bearer" || endpoint.auth === "admin") {
|
||||
lines.push(" -H 'Authorization: Bearer $TOKEN'");
|
||||
} else if (endpoint.auth === "hmac") {
|
||||
lines.push(" -H 'X-Api-Key: $API_KEY'",
|
||||
" -H 'X-Api-Timestamp: $TIMESTAMP'",
|
||||
" -H 'X-Api-Nonce: $UUID'",
|
||||
" -H 'X-Api-Signature: $SIGNATURE'");
|
||||
if (endpoint.method !== "GET") lines.push(" -H 'Idempotency-Key: $UUID'");
|
||||
}
|
||||
if (endpoint.body) {
|
||||
lines.push(" -H 'Content-Type: application/json'",
|
||||
` -d '${JSON.stringify(endpoint.body)}'`);
|
||||
}
|
||||
return lines.join(" \\\n");
|
||||
}
|
||||
|
||||
function endpointId(service, endpoint) {
|
||||
return `${service.id}-${endpoint.method.toLowerCase()}-${endpoint.path
|
||||
.replace(/[{}]/g, "").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "")}`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const query = search.value.trim().toLowerCase();
|
||||
list.replaceChildren();
|
||||
let shown = 0;
|
||||
manifest.services.forEach((service) => {
|
||||
service.endpoints.forEach((endpoint) => {
|
||||
const haystack = `${service.name} ${endpoint.method} ${endpoint.path} ${endpoint.title} ${endpoint.description} ${endpoint.group}`.toLowerCase();
|
||||
if ((activeService !== "all" && activeService !== service.id) || !haystack.includes(query)) return;
|
||||
shown += 1;
|
||||
const node = template.content.firstElementChild.cloneNode(true);
|
||||
node.id = endpointId(service, endpoint);
|
||||
node.dataset.service = service.id;
|
||||
const summary = node.querySelector(".endpoint-summary");
|
||||
const method = node.querySelector(".method");
|
||||
method.textContent = endpoint.method;
|
||||
method.classList.add(endpoint.method);
|
||||
node.querySelector(".path").textContent = endpoint.path;
|
||||
node.querySelector(".endpoint-title").textContent = endpoint.title;
|
||||
node.querySelector(".auth-badge").textContent = endpoint.auth_label;
|
||||
node.querySelector(".endpoint-description").textContent = endpoint.description;
|
||||
node.querySelector(".endpoint-meta").innerHTML =
|
||||
`<code>${escapeHtml(service.base_url)}</code><code>${escapeHtml(endpoint.group)}</code>`;
|
||||
const curl = curlFor(service, endpoint);
|
||||
node.querySelector(".endpoint-example").innerHTML =
|
||||
`<div class="code-card"><div class="code-head"><span>cURL</span><button class="copy">Копировать</button></div><pre><code>${escapeHtml(curl)}</code></pre></div>`;
|
||||
summary.addEventListener("click", () => {
|
||||
const open = node.classList.toggle("open");
|
||||
summary.setAttribute("aria-expanded", String(open));
|
||||
if (open) history.replaceState(null, "", `#${node.id}`);
|
||||
});
|
||||
node.querySelector(".copy").addEventListener("click", (event) => copyText(event.currentTarget, curl));
|
||||
list.append(node);
|
||||
});
|
||||
});
|
||||
count.textContent = `${shown} / ${manifest.total_endpoints}`;
|
||||
empty.hidden = shown !== 0;
|
||||
}
|
||||
|
||||
function copyText(button, text) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const old = button.textContent;
|
||||
button.textContent = "Скопировано";
|
||||
window.setTimeout(() => { button.textContent = old; }, 1300);
|
||||
});
|
||||
}
|
||||
|
||||
function makeFilters() {
|
||||
const choices = [["all", "Все"], ...manifest.services.map((service) => [service.id, service.name])];
|
||||
choices.forEach(([id, label]) => {
|
||||
const button = document.createElement("button");
|
||||
button.className = `filter${id === "all" ? " active" : ""}`;
|
||||
button.textContent = label;
|
||||
button.addEventListener("click", () => {
|
||||
activeService = id;
|
||||
filters.querySelectorAll(".filter").forEach((item) => item.classList.toggle("active", item === button));
|
||||
render();
|
||||
});
|
||||
filters.append(button);
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-copy-target]").forEach((button) => {
|
||||
button.addEventListener("click", () => copyText(button, document.querySelector(`#${button.dataset.copyTarget}`).innerText));
|
||||
});
|
||||
search.addEventListener("input", render);
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "/" && document.activeElement !== search) {
|
||||
event.preventDefault();
|
||||
search.focus();
|
||||
}
|
||||
if (event.key === "Escape") sidebar.classList.remove("open");
|
||||
});
|
||||
menu.addEventListener("click", () => {
|
||||
const open = sidebar.classList.toggle("open");
|
||||
menu.setAttribute("aria-expanded", String(open));
|
||||
});
|
||||
sidebar.querySelectorAll("a").forEach((link) => link.addEventListener("click", () => sidebar.classList.remove("open")));
|
||||
|
||||
fetch("/api-manifest.json")
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error(`manifest ${response.status}`);
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
manifest = data;
|
||||
makeFilters();
|
||||
render();
|
||||
if (location.hash.startsWith("#messenger-") || location.hash.startsWith("#gateway-")) {
|
||||
const target = document.querySelector(location.hash);
|
||||
if (target) {
|
||||
target.classList.add("open");
|
||||
target.querySelector(".endpoint-summary").setAttribute("aria-expanded", "true");
|
||||
target.scrollIntoView();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
empty.hidden = false;
|
||||
empty.textContent = "Не удалось загрузить API catalog.";
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,209 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="Публичная документация API сервисов OVE">
|
||||
<title>OVE API Reference</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
<script src="/app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="noise" aria-hidden="true"></div>
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<a class="brand" href="#top" aria-label="OVE API">
|
||||
<span class="brand-mark">O</span>
|
||||
<span><strong>OVE</strong><small>API reference</small></span>
|
||||
</a>
|
||||
<nav aria-label="Основная навигация">
|
||||
<a href="#overview">Обзор</a>
|
||||
<a href="#quickstart">Быстрый старт</a>
|
||||
<a href="#auth">Авторизация</a>
|
||||
<a href="#messenger">Messenger API</a>
|
||||
<a href="#bots">Bot API</a>
|
||||
<a href="#oauth">OAuth / OIDC</a>
|
||||
<a href="#gateway">Crypto Gateway</a>
|
||||
<a href="#donations">Donations + OBS</a>
|
||||
<a href="#webhooks">Webhooks</a>
|
||||
<a href="#errors">Ошибки</a>
|
||||
</nav>
|
||||
<div class="sidebar-foot">
|
||||
<span class="status-dot"></span> Production polygon
|
||||
<a href="/openapi/messenger.json" download>Messenger OpenAPI ↗</a>
|
||||
<a href="/openapi/gateway.json" download>Gateway OpenAPI ↗</a>
|
||||
<a href="/openapi/donations.json" download>Donations OpenAPI ↗</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main id="top">
|
||||
<header class="topbar">
|
||||
<button class="menu" id="menu" aria-label="Открыть меню" aria-expanded="false">☰</button>
|
||||
<label class="search">
|
||||
<span>⌕</span>
|
||||
<input id="search" type="search" placeholder="Поиск endpoint, пути или метода…" autocomplete="off">
|
||||
<kbd>/</kbd>
|
||||
</label>
|
||||
<a class="spec-link" href="/openapi/gateway.json">OpenAPI</a>
|
||||
</header>
|
||||
|
||||
<section class="hero" id="overview">
|
||||
<div class="eyebrow">PUBLIC API · OVE.RS</div>
|
||||
<h1>Один вход.<br><em>Все сервисы OVE.</em></h1>
|
||||
<p class="lead">Документация публичных HTTP API Messenger, Crypto Gateway и Donation Service: авторизация, сообщения, боты, платежи, OBS alerts, webhooks и операторские методы.</p>
|
||||
<div class="hero-actions">
|
||||
<a class="button primary" href="#quickstart">Начать интеграцию</a>
|
||||
<a class="button secondary" href="#endpoints">Все endpoints</a>
|
||||
</div>
|
||||
<div class="service-strip">
|
||||
<article><span class="service-icon messenger">M</span><div><b>Messenger</b><code>https://ms.ove.rs</code></div><i>REST · OAuth · WS</i></article>
|
||||
<article><span class="service-icon gateway">C</span><div><b>Crypto Gateway</b><code>https://cr.ove.rs</code></div><i>REST · HMAC</i></article>
|
||||
<article><span class="service-icon donations">D</span><div><b>Donations</b><code>https://do.ove.rs</code></div><i>REST · SSE · OBS</i></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="doc-section" id="quickstart">
|
||||
<div class="section-label">01 — Quick start</div>
|
||||
<h2>Первый запрос</h2>
|
||||
<p>Все ответы API — JSON, если endpoint явно не обозначен как HTML, WebSocket или файл. Для Messenger получите токен через email-код, затем передавайте его в заголовке Bearer.</p>
|
||||
<div class="code-card">
|
||||
<div class="code-head"><span>Email login</span><button class="copy" data-copy-target="quick-code">Копировать</button></div>
|
||||
<pre id="quick-code"><code>curl -X POST https://ms.ove.rs/auth/email/start \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"email":"you@example.com"}'
|
||||
|
||||
curl -X POST https://ms.ove.rs/auth/email/verify \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"email":"you@example.com","code":"123456"}'</code></pre>
|
||||
</div>
|
||||
<div class="callout"><strong>HTTPS в тестовом полигоне.</strong> Сертификаты выпущены локальным CA OVE. Добавьте CA в доверенные на устройстве; не отключайте проверку TLS в production-клиенте.</div>
|
||||
</section>
|
||||
|
||||
<section class="doc-section" id="auth">
|
||||
<div class="section-label">02 — Security</div>
|
||||
<h2>Три схемы авторизации</h2>
|
||||
<div class="auth-grid">
|
||||
<article><span>01</span><h3>Messenger Bearer</h3><p>Пользовательский или bot token передается как <code>Authorization: Bearer <token></code>.</p></article>
|
||||
<article><span>02</span><h3>Gateway HMAC</h3><p>Каждый merchant-запрос подписывается ключом, timestamp и одноразовым UUID nonce.</p></article>
|
||||
<article><span>03</span><h3>Operator Bearer</h3><p><code>/admin/v1/*</code> принимает отдельный секрет <code>GATEWAY_ADMIN_TOKEN</code>.</p></article>
|
||||
</div>
|
||||
<h3>Каноническая строка Gateway</h3>
|
||||
<pre><code>timestamp + "\n" +
|
||||
nonce + "\n" +
|
||||
HTTP_METHOD + "\n" +
|
||||
path_and_query + "\n" +
|
||||
hex(sha256(raw_request_body))</code></pre>
|
||||
<p>Результат подпишите HMAC-SHA256 и отправьте lowercase hex в <code>X-Api-Signature</code>. Также обязательны <code>X-Api-Key</code>, <code>X-Api-Timestamp</code>, <code>X-Api-Nonce</code>; для изменяющих запросов — <code>Idempotency-Key</code>.</p>
|
||||
</section>
|
||||
|
||||
<section class="doc-section" id="messenger">
|
||||
<div class="section-label">03 — Messenger</div>
|
||||
<h2>Клиенты, сообщения и real-time</h2>
|
||||
<p>HTTP API покрывает аккаунты, чаты, E2E-ключи, файлы, голосовые сессии, Dastars и очередь обновлений. Native Android транспорт MST5 работает на <code>ms.ove.rs:8080</code>: после защищённого handshake он передаёт CBOR-команды и мультиплексированные ответы внутри зашифрованного потока.</p>
|
||||
<div class="facts">
|
||||
<div><b>Updates</b><span>long polling + ACK</span></div>
|
||||
<div><b>Voice</b><span>WebSocket ticket</span></div>
|
||||
<div><b>E2E</b><span>не зависит от username</span></div>
|
||||
<div><b>Files</b><span>multipart upload</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="doc-section" id="bots">
|
||||
<div class="section-label">04 — Bots</div>
|
||||
<h2>Bot API</h2>
|
||||
<p>Создайте бота через проверенного <code>botfather</code> или <code>POST /bots</code>. Bot token использует ту же Bearer-схему. Обновления хранятся до подтверждения через <code>POST /updates/ack</code>.</p>
|
||||
<div class="two-col">
|
||||
<div><h3>Кнопки</h3><p><code>url</code>, <code>callback</code> и <code>pay_dsr</code>; не более 12 кнопок, текст до 64 символов.</p></div>
|
||||
<div><h3>Реакции</h3><p>Бот получает callback для обычных и платных реакций. Пакет платной реакции может содержать <code>amount > 1</code>.</p></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="doc-section" id="oauth">
|
||||
<div class="section-label">05 — Identity</div>
|
||||
<h2>OAuth 2.0 Device Flow + OIDC</h2>
|
||||
<p>Сервис запрашивает device code у Messenger, показывает QR или user code, а пользователь подтверждает вход в Android-клиенте. Токен обменивается только после решения пользователя.</p>
|
||||
<div class="flow"><span>1 · device_authorization</span><b>→</b><span>2 · QR / code</span><b>→</b><span>3 · approve</span><b>→</b><span>4 · token</span></div>
|
||||
</section>
|
||||
|
||||
<section class="doc-section" id="gateway">
|
||||
<div class="section-label">06 — Payments</div>
|
||||
<h2>Crypto Gateway</h2>
|
||||
<p>Создавайте invoice и withdrawal, сверяйте баланс и принимайте подписанные callbacks. Денежные величины всегда передаются строками в атомарных единицах — без float.</p>
|
||||
<div class="asset-table" role="table" aria-label="Поддерживаемые активы">
|
||||
<div class="thead"><span>Asset</span><span>Network</span><span>Decimals</span></div>
|
||||
<div><b>BTC</b><span>bitcoin</span><code>8</code></div>
|
||||
<div><b>LTC</b><span>litecoin</span><code>8</code></div>
|
||||
<div><b>TON</b><span>ton</span><code>9</code></div>
|
||||
<div><b>TRX</b><span>tron</span><code>6</code></div>
|
||||
<div><b>USDT</b><span>ton / tron</span><code>6</code></div>
|
||||
</div>
|
||||
<div class="callout"><strong>Hosted checkout.</strong> URL из ответа invoice открывается как <code>/pay/{token}</code>; JSON-статус доступен в <code>/pay/{token}/status</code>. Таймер оплаты синхронизирован с серверным временем.</div>
|
||||
</section>
|
||||
|
||||
<section class="doc-section" id="webhooks">
|
||||
<div class="section-label">07 — Events</div>
|
||||
<h2>Подпись callbacks</h2>
|
||||
<pre><code>signature = hex(HMAC-SHA256(
|
||||
webhook_secret,
|
||||
timestamp + "." + event_id + "." + raw_body
|
||||
))</code></pre>
|
||||
<p>Проверяйте <code>X-Gateway-Timestamp</code>, <code>X-Gateway-Event-Id</code> и <code>X-Gateway-Signature</code>. Доставка at-least-once повторяется до 24 часов — дедуплицируйте события по <code>event_id</code>.</p>
|
||||
</section>
|
||||
|
||||
<section class="doc-section" id="donations">
|
||||
<div class="section-label">08 — Donations</div>
|
||||
<h2>Страница доната и OBS Browser Source</h2>
|
||||
<p>Стример входит через Messenger, получает публичный адрес <code>do.ove.rs/u/{slug}</code> и секретную ссылку виджета. Доноры авторизоваться не обязаны: Donation Service создаёт invoice в Crypto Gateway и показывает его hosted checkout.</p>
|
||||
<div class="flow"><span>1 · public donation</span><b>→</b><span>2 · Gateway checkout</span><b>→</b><span>3 · signed webhook</span><b>→</b><span>4 · OBS SSE alert</span></div>
|
||||
<div class="callout"><strong>Вывод средств.</strong> Каждый запрос резервирует доступный баланс и отдельно подтверждается свайпом в Messenger. В окне подтверждения показаны актив, сумма, сеть и сокращённый адрес.</div>
|
||||
</section>
|
||||
|
||||
<section class="doc-section endpoints-section" id="endpoints">
|
||||
<div class="section-label">09 — Reference</div>
|
||||
<h2>Все endpoints</h2>
|
||||
<div class="endpoint-toolbar">
|
||||
<div id="service-filters" class="filters"></div>
|
||||
<span id="endpoint-count"></span>
|
||||
</div>
|
||||
<div id="endpoint-list" class="endpoint-list" aria-live="polite"></div>
|
||||
<div id="empty" class="empty" hidden>Ничего не найдено. Попробуйте другой запрос.</div>
|
||||
</section>
|
||||
|
||||
<section class="doc-section" id="errors">
|
||||
<div class="section-label">10 — Errors</div>
|
||||
<h2>Ошибки и повторные запросы</h2>
|
||||
<div class="error-grid">
|
||||
<div><code>400</code><span>Неверные поля или состояние</span></div>
|
||||
<div><code>401</code><span>Нет или неверна авторизация</span></div>
|
||||
<div><code>403</code><span>Недостаточно прав</span></div>
|
||||
<div><code>404</code><span>Объект не найден</span></div>
|
||||
<div><code>409</code><span>Конфликт / повтор</span></div>
|
||||
<div><code>429</code><span>Превышен лимит</span></div>
|
||||
<div><code>5xx</code><span>Временная ошибка сервиса</span></div>
|
||||
</div>
|
||||
<p>Для безопасного повтора merchant POST используйте тот же <code>Idempotency-Key</code>. Не повторяйте запрос с новым ключом, пока результат предыдущего неизвестен.</p>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<span>OVE API · public contract</span>
|
||||
<span>Internal wallet gRPC и системные bot nodes не публикуются.</span>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<template id="endpoint-template">
|
||||
<article class="endpoint">
|
||||
<button class="endpoint-summary" type="button" aria-expanded="false">
|
||||
<span class="method"></span>
|
||||
<code class="path"></code>
|
||||
<span class="endpoint-title"></span>
|
||||
<span class="auth-badge"></span>
|
||||
<span class="chevron">⌄</span>
|
||||
</button>
|
||||
<div class="endpoint-body">
|
||||
<p class="endpoint-description"></p>
|
||||
<div class="endpoint-meta"></div>
|
||||
<div class="endpoint-example"></div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,16 @@
|
||||
# OVE public API
|
||||
|
||||
Canonical human documentation: https://docs.ove.rs/
|
||||
Messenger OpenAPI: https://docs.ove.rs/openapi/messenger.json
|
||||
Crypto Gateway OpenAPI: https://docs.ove.rs/openapi/gateway.json
|
||||
Donation Service OpenAPI: https://docs.ove.rs/openapi/donations.json
|
||||
|
||||
Public services:
|
||||
- Messenger: https://ms.ove.rs
|
||||
- Crypto Gateway: https://cr.ove.rs
|
||||
- Donation Service: https://do.ove.rs
|
||||
|
||||
Messenger uses Bearer tokens for users and bots. Crypto Gateway merchant API
|
||||
uses request-specific HMAC-SHA256 signatures. Gateway operator API uses a
|
||||
Bearer admin token. Amounts in Crypto Gateway are decimal strings in atomic
|
||||
units.
|
||||
@@ -0,0 +1,660 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "OVE Donations API",
|
||||
"version": "1.0.0",
|
||||
"description": "OVE donation pages, Messenger login, Crypto Gateway payments and OBS alerts."
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://do.ove.rs"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/health": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Сервис"
|
||||
],
|
||||
"summary": "Состояние Gateway",
|
||||
"description": "Состояние Gateway. Возвращает JSON-ответ или стандартную JSON-ошибку.",
|
||||
"operationId": "get_health",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Успешный ответ"
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка запроса"
|
||||
},
|
||||
"401": {
|
||||
"description": "Ошибка авторизации"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/assets": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Сервис"
|
||||
],
|
||||
"summary": "Активы для донатов",
|
||||
"description": "Активы для донатов. Возвращает JSON-ответ или стандартную JSON-ошибку.",
|
||||
"operationId": "get_api_assets",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Успешный ответ"
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка запроса"
|
||||
},
|
||||
"401": {
|
||||
"description": "Ошибка авторизации"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/start": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Авторизация"
|
||||
],
|
||||
"summary": "Вход стримера через Messenger",
|
||||
"description": "Вход стримера через Messenger. Возвращает JSON-ответ или стандартную JSON-ошибку.",
|
||||
"operationId": "post_api_auth_start",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Успешный ответ"
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка запроса"
|
||||
},
|
||||
"401": {
|
||||
"description": "Ошибка авторизации"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
},
|
||||
"example": {
|
||||
"email": "streamer@example.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/poll/{flow}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Авторизация"
|
||||
],
|
||||
"summary": "Статус входа стримера",
|
||||
"description": "Статус входа стримера. Возвращает JSON-ответ или стандартную JSON-ошибку.",
|
||||
"operationId": "get_api_auth_poll_flow",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Успешный ответ"
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка запроса"
|
||||
},
|
||||
"401": {
|
||||
"description": "Ошибка авторизации"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "flow",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/logout": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Авторизация"
|
||||
],
|
||||
"summary": "Выход из Donation Service",
|
||||
"description": "Выход из Donation Service. Возвращает JSON-ответ или стандартную JSON-ошибку.",
|
||||
"operationId": "post_api_logout",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Успешный ответ"
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка запроса"
|
||||
},
|
||||
"401": {
|
||||
"description": "Ошибка авторизации"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"sessionCookie": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/me": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Стример"
|
||||
],
|
||||
"summary": "Панель стримера",
|
||||
"description": "Панель стримера. Возвращает JSON-ответ или стандартную JSON-ошибку.",
|
||||
"operationId": "get_api_me",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Успешный ответ"
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка запроса"
|
||||
},
|
||||
"401": {
|
||||
"description": "Ошибка авторизации"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"sessionCookie": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/profile": {
|
||||
"patch": {
|
||||
"tags": [
|
||||
"Стример"
|
||||
],
|
||||
"summary": "Настройки страницы и OBS",
|
||||
"description": "Настройки страницы и OBS. Возвращает JSON-ответ или стандартную JSON-ошибку.",
|
||||
"operationId": "patch_api_profile",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Успешный ответ"
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка запроса"
|
||||
},
|
||||
"401": {
|
||||
"description": "Ошибка авторизации"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"sessionCookie": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
},
|
||||
"example": {
|
||||
"display_name": "My channel",
|
||||
"slug": "my-channel",
|
||||
"accent_color": "#b9f541",
|
||||
"duration_seconds": 8,
|
||||
"show_donor_name": true,
|
||||
"show_message": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/sound": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"OBS"
|
||||
],
|
||||
"summary": "Загрузка звука",
|
||||
"description": "Загрузка звука. Возвращает JSON-ответ или стандартную JSON-ошибку.",
|
||||
"operationId": "put_api_sound",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Успешный ответ"
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка запроса"
|
||||
},
|
||||
"401": {
|
||||
"description": "Ошибка авторизации"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"sessionCookie": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"OBS"
|
||||
],
|
||||
"summary": "Удаление звука",
|
||||
"description": "Удаление звука. Возвращает JSON-ответ или стандартную JSON-ошибку.",
|
||||
"operationId": "delete_api_sound",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Успешный ответ"
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка запроса"
|
||||
},
|
||||
"401": {
|
||||
"description": "Ошибка авторизации"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"sessionCookie": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/widget/rotate": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"OBS"
|
||||
],
|
||||
"summary": "Ротация секретной OBS-ссылки",
|
||||
"description": "Ротация секретной OBS-ссылки. Возвращает JSON-ответ или стандартную JSON-ошибку.",
|
||||
"operationId": "post_api_widget_rotate",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Успешный ответ"
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка запроса"
|
||||
},
|
||||
"401": {
|
||||
"description": "Ошибка авторизации"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"sessionCookie": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/widget/test": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"OBS"
|
||||
],
|
||||
"summary": "Тестовый OBS alert",
|
||||
"description": "Тестовый OBS alert. Возвращает JSON-ответ или стандартную JSON-ошибку.",
|
||||
"operationId": "post_api_widget_test",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Успешный ответ"
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка запроса"
|
||||
},
|
||||
"401": {
|
||||
"description": "Ошибка авторизации"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"sessionCookie": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/widget/{token}/config": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"OBS"
|
||||
],
|
||||
"summary": "Конфигурация OBS-виджета",
|
||||
"description": "Конфигурация OBS-виджета. Возвращает JSON-ответ или стандартную JSON-ошибку.",
|
||||
"operationId": "get_api_widget_token_config",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Успешный ответ"
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка запроса"
|
||||
},
|
||||
"401": {
|
||||
"description": "Ошибка авторизации"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/widget/{token}/events": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"OBS"
|
||||
],
|
||||
"summary": "SSE-события OBS-виджета",
|
||||
"description": "Долгоживущий SSE-поток для OBS Browser Source. Виджет сам ставит alerts в очередь.",
|
||||
"operationId": "get_api_widget_token_events",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Успешный ответ"
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка запроса"
|
||||
},
|
||||
"401": {
|
||||
"description": "Ошибка авторизации"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/public/creators/{slug}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Донаты"
|
||||
],
|
||||
"summary": "Публичная страница стримера",
|
||||
"description": "Публичная страница стримера. Возвращает JSON-ответ или стандартную JSON-ошибку.",
|
||||
"operationId": "get_api_public_creators_slug",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Успешный ответ"
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка запроса"
|
||||
},
|
||||
"401": {
|
||||
"description": "Ошибка авторизации"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "slug",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/public/donations": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Донаты"
|
||||
],
|
||||
"summary": "Создание доната",
|
||||
"description": "Создает Gateway invoice с комиссией 5%. cover_fee=false удерживает комиссию из суммы; cover_fee=true точно увеличивает платеж так, чтобы стример получил введенную сумму после комиссии. Все расчеты выполняются в atomic без float.",
|
||||
"operationId": "post_api_public_donations",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Успешный ответ"
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка запроса"
|
||||
},
|
||||
"401": {
|
||||
"description": "Ошибка авторизации"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
},
|
||||
"example": {
|
||||
"slug": "my-channel",
|
||||
"donor_name": "Anonymous",
|
||||
"message": "Great stream!",
|
||||
"asset": "USDT",
|
||||
"network": "tron",
|
||||
"amount": "25.5",
|
||||
"cover_fee": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/public/donations/{token}/status": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Донаты"
|
||||
],
|
||||
"summary": "Статус доната",
|
||||
"description": "Статус доната. Возвращает JSON-ответ или стандартную JSON-ошибку.",
|
||||
"operationId": "get_api_public_donations_token_status",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Успешный ответ"
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка запроса"
|
||||
},
|
||||
"401": {
|
||||
"description": "Ошибка авторизации"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/withdrawals": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Вывод"
|
||||
],
|
||||
"summary": "Запрос вывода",
|
||||
"description": "Резервирует баланс и запускает новый Messenger Device Flow с описанием суммы и адреса.",
|
||||
"operationId": "post_api_withdrawals",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Успешный ответ"
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка запроса"
|
||||
},
|
||||
"401": {
|
||||
"description": "Ошибка авторизации"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"sessionCookie": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
},
|
||||
"example": {
|
||||
"asset": "USDT",
|
||||
"network": "tron",
|
||||
"amount": "10",
|
||||
"address": "TExampleRecipientAddress"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/withdrawals/{flow}/poll": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Вывод"
|
||||
],
|
||||
"summary": "Подтверждение вывода через Messenger",
|
||||
"description": "После свайпа тем же Messenger-пользователем идемпотентно отправляет withdrawal в Gateway.",
|
||||
"operationId": "get_api_withdrawals_flow_poll",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Успешный ответ"
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка запроса"
|
||||
},
|
||||
"401": {
|
||||
"description": "Ошибка авторизации"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"sessionCookie": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "flow",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/admin/status": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Оператор"
|
||||
],
|
||||
"summary": "Статус Donation Service",
|
||||
"description": "Статус Donation Service. Возвращает JSON-ответ или стандартную JSON-ошибку.",
|
||||
"operationId": "get_api_admin_status",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Успешный ответ"
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка запроса"
|
||||
},
|
||||
"401": {
|
||||
"description": "Ошибка авторизации"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"sessionCookie": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/admin/creators/{creator_id}/blocked": {
|
||||
"patch": {
|
||||
"tags": [
|
||||
"Оператор"
|
||||
],
|
||||
"summary": "Блокировка страницы стримера",
|
||||
"description": "Блокировка страницы стримера. Возвращает JSON-ответ или стандартную JSON-ошибку.",
|
||||
"operationId": "patch_api_admin_creators_creator_id_blocked",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Успешный ответ"
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка запроса"
|
||||
},
|
||||
"401": {
|
||||
"description": "Ошибка авторизации"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"sessionCookie": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "creator_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
},
|
||||
"example": {
|
||||
"blocked": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"sessionCookie": {
|
||||
"type": "apiKey",
|
||||
"in": "cookie",
|
||||
"name": "ove_donations_session",
|
||||
"description": "HttpOnly session; changing requests also require X-CSRF-Token."
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"Error": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"error"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
@@ -0,0 +1,137 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0b0d10;
|
||||
--panel: #111419;
|
||||
--panel-2: #171b21;
|
||||
--line: #262c34;
|
||||
--muted: #929ba8;
|
||||
--text: #f3f5f7;
|
||||
--lime: #b9f541;
|
||||
--cyan: #61ddff;
|
||||
--violet: #a78bfa;
|
||||
--orange: #ffb86b;
|
||||
--sidebar: 260px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; scroll-padding-top: 88px; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 15px/1.65 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
body::before {
|
||||
content: ""; position: fixed; inset: 0; pointer-events: none;
|
||||
background: radial-gradient(circle at 80% 5%, rgba(97,221,255,.07), transparent 28%),
|
||||
radial-gradient(circle at 30% 40%, rgba(185,245,65,.035), transparent 25%);
|
||||
}
|
||||
.noise { position: fixed; inset: 0; opacity: .025; pointer-events: none; z-index: 10;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
}
|
||||
a { color: inherit; }
|
||||
code, pre { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; }
|
||||
code { font-size: .9em; }
|
||||
.sidebar {
|
||||
position: fixed; inset: 0 auto 0 0; z-index: 20; width: var(--sidebar);
|
||||
display: flex; flex-direction: column; padding: 26px 20px;
|
||||
background: rgba(12,14,17,.96); border-right: 1px solid var(--line); backdrop-filter: blur(18px);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 12px; text-decoration: none; margin-bottom: 38px; }
|
||||
.brand-mark { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 11px;
|
||||
background: var(--lime); color: #0a0d08; font-weight: 900; font-size: 20px; box-shadow: 0 0 30px rgba(185,245,65,.15); }
|
||||
.brand strong { display: block; letter-spacing: .08em; }
|
||||
.brand small { display: block; color: var(--muted); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; }
|
||||
nav { display: grid; gap: 3px; }
|
||||
nav a { padding: 8px 11px; border-radius: 8px; color: #aab2bd; text-decoration: none; font-size: 13px; }
|
||||
nav a:hover, nav a.active { color: var(--text); background: var(--panel-2); }
|
||||
.sidebar-foot { margin-top: auto; display: grid; gap: 8px; color: var(--muted); font-size: 11px; }
|
||||
.sidebar-foot a { text-decoration: none; color: #c5cbd3; }
|
||||
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--lime); display: inline-block; box-shadow: 0 0 10px var(--lime); }
|
||||
main { margin-left: var(--sidebar); min-width: 0; }
|
||||
.topbar { height: 66px; position: sticky; top: 0; z-index: 15; display: flex; align-items: center; gap: 18px;
|
||||
padding: 0 max(32px, calc((100vw - var(--sidebar) - 980px) / 2));
|
||||
border-bottom: 1px solid rgba(38,44,52,.75); background: rgba(11,13,16,.82); backdrop-filter: blur(20px); }
|
||||
.menu { display: none; background: none; color: white; border: 0; font-size: 20px; }
|
||||
.search { flex: 1; max-width: 620px; height: 38px; display: flex; align-items: center; gap: 9px; padding: 0 11px;
|
||||
border: 1px solid var(--line); border-radius: 9px; background: #111419; color: var(--muted); }
|
||||
.search:focus-within { border-color: #53606e; }
|
||||
.search input { width: 100%; border: 0; outline: 0; color: var(--text); background: transparent; font: inherit; font-size: 13px; }
|
||||
kbd { border: 1px solid #343b45; border-radius: 5px; padding: 0 6px; font: 11px/19px inherit; color: var(--muted); }
|
||||
.spec-link { margin-left: auto; color: var(--lime); text-decoration: none; font-size: 12px; letter-spacing: .05em; text-transform: uppercase; }
|
||||
.hero, .doc-section, footer { width: min(980px, calc(100% - 64px)); margin-inline: auto; }
|
||||
.hero { min-height: 690px; display: flex; flex-direction: column; justify-content: center; padding: 100px 0 72px; }
|
||||
.eyebrow, .section-label { color: var(--lime); font: 700 11px/1.3 "SFMono-Regular", Consolas, monospace; letter-spacing: .14em; text-transform: uppercase; }
|
||||
h1 { margin: 24px 0 20px; font-size: clamp(50px, 7vw, 88px); line-height: .98; letter-spacing: -.065em; font-weight: 720; }
|
||||
h1 em { color: var(--muted); font-style: normal; }
|
||||
.lead { max-width: 670px; margin: 0; color: #b6bec8; font-size: 19px; line-height: 1.7; }
|
||||
.hero-actions { display: flex; gap: 12px; margin: 32px 0 66px; }
|
||||
.button { padding: 11px 17px; border-radius: 9px; text-decoration: none; font-weight: 650; font-size: 13px; }
|
||||
.button.primary { background: var(--lime); color: #10140a; }
|
||||
.button.secondary { border: 1px solid var(--line); background: var(--panel); }
|
||||
.service-strip { display: grid; grid-template-columns: 1fr 1fr; border: 1px solid var(--line); border-radius: 14px; overflow: hidden; }
|
||||
.service-strip article { display: flex; align-items: center; gap: 13px; padding: 18px; background: rgba(17,20,25,.7); }
|
||||
.service-strip article + article { border-left: 1px solid var(--line); }
|
||||
.service-icon { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 9px; font-weight: 800; color: #0b0d10; }
|
||||
.service-icon.messenger { background: var(--cyan); }.service-icon.gateway { background: var(--violet); }
|
||||
.service-strip b, .service-strip code { display: block; }
|
||||
.service-strip code { color: var(--muted); font-size: 11px; }
|
||||
.service-strip i { margin-left: auto; color: var(--muted); font: normal 10px/1.2 monospace; }
|
||||
.doc-section { padding: 100px 0; border-top: 1px solid var(--line); }
|
||||
h2 { margin: 14px 0 20px; font-size: clamp(32px, 4vw, 49px); line-height: 1.1; letter-spacing: -.045em; }
|
||||
h3 { margin: 28px 0 10px; font-size: 17px; }
|
||||
p { color: #b2bac5; max-width: 760px; }
|
||||
pre { position: relative; overflow-x: auto; margin: 22px 0; padding: 22px; border: 1px solid var(--line); border-radius: 12px;
|
||||
background: #0d1014; color: #dce4ec; font-size: 12px; line-height: 1.75; }
|
||||
.code-card { border: 1px solid var(--line); border-radius: 13px; overflow: hidden; margin-top: 28px; background: #0d1014; }
|
||||
.code-card pre { border: 0; border-radius: 0; margin: 0; }
|
||||
.code-head { display: flex; justify-content: space-between; align-items: center; padding: 9px 13px; border-bottom: 1px solid var(--line); color: var(--muted); font-size: 11px; }
|
||||
.copy { border: 0; color: var(--lime); background: none; cursor: pointer; font: inherit; }
|
||||
.callout { margin-top: 24px; padding: 18px 20px; border-left: 3px solid var(--lime); background: rgba(185,245,65,.055); color: #abb4bf; }
|
||||
.callout strong { color: var(--text); }
|
||||
.auth-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-top: 32px; }
|
||||
.auth-grid article { min-height: 190px; padding: 20px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); }
|
||||
.auth-grid article > span { color: var(--lime); font: 11px monospace; }.auth-grid h3 { margin-top: 32px; }.auth-grid p { font-size: 13px; }
|
||||
.facts { display: grid; grid-template-columns: repeat(4, 1fr); margin-top: 34px; border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
|
||||
.facts div { padding: 18px; }.facts div + div { border-left: 1px solid var(--line); }.facts b,.facts span { display:block; }.facts span { color:var(--muted);font-size:11px; }
|
||||
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }.two-col > div { padding: 0 20px 12px; border-left: 1px solid var(--line); }
|
||||
.flow { display: flex; align-items: center; gap: 12px; margin-top: 30px; overflow-x: auto; padding-bottom: 8px; }
|
||||
.flow span { flex: 0 0 auto; padding: 12px 15px; border: 1px solid var(--line); border-radius: 9px; background: var(--panel); font: 12px monospace; }.flow b { color: var(--lime); }
|
||||
.asset-table { max-width: 670px; margin-top: 28px; border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
|
||||
.asset-table > div { display: grid; grid-template-columns: 1fr 2fr 1fr; padding: 10px 15px; border-top: 1px solid var(--line); }
|
||||
.asset-table > div:first-child { border: 0; }.asset-table .thead { color: var(--muted); background: var(--panel); font-size: 11px; text-transform: uppercase; }
|
||||
.asset-table code { color: var(--lime); }
|
||||
.endpoint-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin: 28px 0 14px; }
|
||||
.filters { display: flex; gap: 7px; flex-wrap: wrap; }
|
||||
.filter { border: 1px solid var(--line); border-radius: 99px; background: var(--panel); color: #b7bec7; padding: 6px 11px; cursor: pointer; font: 11px inherit; }
|
||||
.filter.active { background: var(--text); color: var(--bg); border-color: var(--text); }
|
||||
#endpoint-count { color: var(--muted); font: 11px monospace; white-space: nowrap; }
|
||||
.endpoint-list { display: grid; gap: 7px; }
|
||||
.endpoint { border: 1px solid var(--line); border-radius: 11px; background: rgba(17,20,25,.74); overflow: hidden; scroll-margin-top: 80px; }
|
||||
.endpoint-summary { width: 100%; min-height: 57px; display: grid; grid-template-columns: 54px minmax(175px, 1.25fr) 1fr auto 20px; align-items: center; gap: 12px;
|
||||
border: 0; padding: 9px 14px; background: none; color: var(--text); text-align: left; cursor: pointer; }
|
||||
.method { width: 48px; padding: 3px 0; border-radius: 5px; text-align:center; font: 800 10px monospace; }
|
||||
.method.GET { color: var(--cyan); background: rgba(97,221,255,.09); }.method.POST { color: var(--lime); background: rgba(185,245,65,.09); }.method.DELETE { color: #ff718a; background: rgba(255,113,138,.09); }
|
||||
.path { overflow-wrap: anywhere; color: #dce2e9; font-size: 12px; }
|
||||
.endpoint-title { color: var(--muted); font-size: 12px; }
|
||||
.auth-badge { border: 1px solid #343b45; border-radius: 99px; padding: 2px 8px; color: var(--muted); font: 9px monospace; text-transform: uppercase; }
|
||||
.chevron { color: var(--muted); transition: transform .2s; }.endpoint.open .chevron { transform: rotate(180deg); }
|
||||
.endpoint-body { display: none; padding: 0 18px 18px 80px; border-top: 1px solid var(--line); }.endpoint.open .endpoint-body { display: block; }
|
||||
.endpoint-meta { display: flex; gap: 8px; flex-wrap: wrap; margin: 14px 0; }.endpoint-meta code { padding: 4px 8px; background:#0d1014; border-radius:5px;color:#aeb7c2; }
|
||||
.endpoint-example pre { margin-bottom: 0; }.empty { padding: 50px; text-align:center; color:var(--muted); }
|
||||
.error-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1px; overflow:hidden; margin: 25px 0; border:1px solid var(--line); border-radius:12px; background:var(--line); }
|
||||
.error-grid div { display:flex; gap:15px; padding:12px 15px; background:var(--panel); }.error-grid code{color:var(--orange)}.error-grid span{color:var(--muted)}
|
||||
footer { display: flex; justify-content: space-between; gap: 20px; padding: 40px 0 60px; border-top: 1px solid var(--line); color: var(--muted); font-size: 11px; }
|
||||
@media (max-width: 900px) {
|
||||
:root { --sidebar: 0px; }
|
||||
.sidebar { width: 260px; transform: translateX(-100%); transition: transform .25s; box-shadow: 20px 0 50px rgba(0,0,0,.4); }
|
||||
.sidebar.open { transform: translateX(0); }
|
||||
.menu { display:block; }.topbar{padding:0 20px}.hero,.doc-section,footer{width:min(100% - 38px,760px)}
|
||||
.auth-grid{grid-template-columns:1fr}.facts{grid-template-columns:1fr 1fr}.facts div:nth-child(3){border-left:0;border-top:1px solid var(--line)}.facts div:nth-child(4){border-top:1px solid var(--line)}
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.hero{min-height:auto;padding-top:75px}.service-strip{grid-template-columns:1fr}.service-strip article+article{border-left:0;border-top:1px solid var(--line)}
|
||||
.two-col{grid-template-columns:1fr}.endpoint-summary{grid-template-columns:48px 1fr 18px}.endpoint-title,.auth-badge{display:none}.endpoint-body{padding-left:18px}
|
||||
.facts{grid-template-columns:1fr}.facts div+div{border-left:0;border-top:1px solid var(--line)}.error-grid{grid-template-columns:1fr}.spec-link,kbd{display:none}
|
||||
footer{flex-direction:column}h1{font-size:48px}.lead{font-size:16px}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { html{scroll-behavior:auto}*{transition:none!important} }
|
||||
@@ -0,0 +1,512 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the browser catalog and OpenAPI documents from one endpoint list."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PUBLIC = ROOT / "public"
|
||||
OPENAPI = PUBLIC / "openapi"
|
||||
|
||||
|
||||
def title(path: str, method: str) -> str:
|
||||
names = {
|
||||
"/register": "Регистрация аккаунта",
|
||||
"/login": "Вход по паролю",
|
||||
"/auth/email/start": "Запрос email-кода",
|
||||
"/auth/email/verify": "Подтверждение email-кода",
|
||||
"/.well-known/openid-configuration": "OIDC Discovery",
|
||||
"/.well-known/oauth-authorization-server": "OAuth Server Metadata",
|
||||
"/oauth/jwks.json": "Публичные ключи OIDC",
|
||||
"/oauth/device_authorization": "Запуск Device Flow",
|
||||
"/oauth/device": "Страница подтверждения Device Flow",
|
||||
"/oauth/token": "Обмен device code на токены",
|
||||
"/oauth/userinfo": "Профиль OIDC",
|
||||
"/oauth/device/request": "Данные запроса входа",
|
||||
"/oauth/device/decision": "Решение пользователя",
|
||||
"/me": "Текущий пользователь",
|
||||
"/account/delete": "Удаление аккаунта",
|
||||
"/username": "Установка username",
|
||||
"/name": "Изменение отображаемого имени",
|
||||
"/profiles/description": "Описание профиля, бота или чата",
|
||||
"/privacy": "Настройки приватности",
|
||||
"/contacts": "Список контактов",
|
||||
"/contacts/add": "Добавление контакта",
|
||||
"/contacts/delete": "Удаление контакта",
|
||||
"/groups": "Создание группы",
|
||||
"/channels": "Создание канала",
|
||||
"/chats/title": "Изменение названия чата",
|
||||
"/channels/username": "Username канала",
|
||||
"/channels/comments/settings": "Настройки комментариев канала",
|
||||
"/channels/comments/send": "Комментарий к публикации",
|
||||
"/channels/comments": "Комментарии к публикации",
|
||||
"/chats/members/add": "Добавление участника",
|
||||
"/chats/members/remove": "Удаление участника",
|
||||
"/cloud-password": "Облачный пароль",
|
||||
"/cloud-password/reset": "Сброс облачного пароля",
|
||||
"/sessions": "Активные сессии",
|
||||
"/sessions/revoke": "Завершение сессии",
|
||||
"/sessions/revoke-others": "Завершение других сессий",
|
||||
"/bots": "Создание бота",
|
||||
"/bots/token/reset": "Перевыпуск bot token",
|
||||
"/e2e/key": "Публичный E2E-ключ" if method == "GET" else "Публикация E2E-ключа",
|
||||
"/e2e/backup": "Резервная копия E2E" if method == "GET" else "Сохранение E2E-копии",
|
||||
"/e2e/reset": "Сброс E2E-ключей",
|
||||
"/wallet": "Баланс Dastars",
|
||||
"/wallet/send": "Перевод Dastars",
|
||||
"/wallet/history": "История Dastars",
|
||||
"/call": "Создание звонка",
|
||||
"/voice-ticket": "Билет голосовой сессии",
|
||||
"/voice/participants": "Участники голосовой сессии",
|
||||
"/voice": "Голосовой WebSocket",
|
||||
"/send": "Отправка текстового сообщения",
|
||||
"/edit": "Редактирование сообщения",
|
||||
"/callback": "Callback кнопки",
|
||||
"/reactions": "Emoji-реакция",
|
||||
"/reactions/paid": "Платная реакция Dastars",
|
||||
"/read": "Отметка о прочтении",
|
||||
"/delete": "Удаление сообщения",
|
||||
"/favorite": "Избранное сообщение",
|
||||
"/media/quote": "Расчет стоимости медиа",
|
||||
"/messages/prepare": "Подготовка сообщения с медиа",
|
||||
"/messages/commit": "Публикация сообщения с медиа",
|
||||
"/messages/cancel": "Отмена сообщения с медиа",
|
||||
"/forward": "Пересылка сообщения с медиа",
|
||||
"/file/ticket": "Билет скачивания файла",
|
||||
"/health/live": "Проверка процесса Messenger",
|
||||
"/health/ready": "Готовность Messenger",
|
||||
"/metrics": "Метрики Messenger",
|
||||
"/nodes/status": "Статус сервисных узлов",
|
||||
"/chats": "Список чатов",
|
||||
"/chats/delete": "Удаление чата",
|
||||
"/users/ban": "Блокировка пользователя",
|
||||
"/users/unban": "Разблокировка пользователя",
|
||||
"/history": "История сообщений",
|
||||
"/updates": "Очередь обновлений",
|
||||
"/updates/ack": "Подтверждение обновлений",
|
||||
"/file/{id}": "Скачивание файла",
|
||||
"/health": "Состояние Gateway",
|
||||
"/v1/assets": "Доступные активы",
|
||||
"/v1/invoices": "Список invoices" if method == "GET" else "Создание invoice",
|
||||
"/v1/invoices/{invoice_id}": "Получение invoice",
|
||||
"/v1/withdrawals": "Список withdrawals" if method == "GET" else "Создание withdrawal",
|
||||
"/v1/withdrawals/{withdrawal_id}": "Получение withdrawal",
|
||||
"/v1/balances": "Merchant-балансы",
|
||||
"/v1/api-keys": "Список API-ключей",
|
||||
"/admin/v1/status": "Операторский статус",
|
||||
"/admin/v1/callbacks": "Инциденты callbacks",
|
||||
"/admin/v1/consolidations": "Инциденты консолидации",
|
||||
"/admin/v1/callbacks/{callback_id}/retry": "Повтор callback",
|
||||
"/admin/v1/merchants": "Создание merchant",
|
||||
"/admin/v1/fee-policies": "Глобальная fee policy",
|
||||
"/admin/v1/merchants/{merchant_id}/api-keys": "Выпуск merchant API-ключа",
|
||||
"/admin/v1/merchants/{merchant_id}/api-keys/{api_key_id}": "Отзыв merchant API-ключа",
|
||||
"/admin/v1/merchants/{merchant_id}/policies": "Merchant policy",
|
||||
"/admin/v1/withdrawals/{withdrawal_id}/resolve": "Решение manual review",
|
||||
"/admin/v1/consolidations/{consolidation_id}/retry": "Повтор консолидации",
|
||||
"/pay/{token}": "Hosted checkout",
|
||||
"/pay/{token}/status": "Статус hosted checkout",
|
||||
"/api/assets": "Активы для донатов",
|
||||
"/api/auth/start": "Вход стримера через Messenger",
|
||||
"/api/auth/poll/{flow}": "Статус входа стримера",
|
||||
"/api/logout": "Выход из Donation Service",
|
||||
"/api/me": "Панель стримера",
|
||||
"/api/profile": "Настройки страницы и OBS",
|
||||
"/api/sound": "Удаление звука" if method == "DELETE" else "Загрузка звука",
|
||||
"/api/widget/rotate": "Ротация секретной OBS-ссылки",
|
||||
"/api/widget/test": "Тестовый OBS alert",
|
||||
"/api/widget/{token}/config": "Конфигурация OBS-виджета",
|
||||
"/api/widget/{token}/events": "SSE-события OBS-виджета",
|
||||
"/api/public/creators/{slug}": "Публичная страница стримера",
|
||||
"/api/public/donations": "Создание доната",
|
||||
"/api/public/donations/{token}/status": "Статус доната",
|
||||
"/api/withdrawals": "Запрос вывода",
|
||||
"/api/withdrawals/{flow}/poll": "Подтверждение вывода через Messenger",
|
||||
"/api/admin/status": "Статус Donation Service",
|
||||
"/api/admin/creators/{creator_id}/blocked": "Блокировка страницы стримера",
|
||||
}
|
||||
return names.get(path, f"{method} {path}")
|
||||
|
||||
|
||||
MESSENGER = [
|
||||
("POST", "/register", "Аккаунт", "public"),
|
||||
("POST", "/login", "Аккаунт", "public"),
|
||||
("POST", "/auth/email/start", "Аккаунт", "public"),
|
||||
("POST", "/auth/email/verify", "Аккаунт", "public"),
|
||||
("GET", "/.well-known/openid-configuration", "OAuth", "public"),
|
||||
("GET", "/.well-known/oauth-authorization-server", "OAuth", "public"),
|
||||
("GET", "/oauth/jwks.json", "OAuth", "public"),
|
||||
("POST", "/oauth/device_authorization", "OAuth", "oauth_client"),
|
||||
("GET", "/oauth/device", "OAuth", "public"),
|
||||
("POST", "/oauth/token", "OAuth", "oauth_client"),
|
||||
("GET", "/oauth/userinfo", "OAuth", "bearer"),
|
||||
("GET", "/oauth/device/request", "OAuth", "bearer"),
|
||||
("POST", "/oauth/device/decision", "OAuth", "bearer"),
|
||||
("GET", "/me", "Аккаунт", "bearer"),
|
||||
("POST", "/account/delete", "Аккаунт", "bearer"),
|
||||
("POST", "/username", "Аккаунт", "bearer"),
|
||||
("POST", "/name", "Аккаунт", "bearer"),
|
||||
("POST", "/profiles/description", "Аккаунт", "bearer"),
|
||||
("POST", "/privacy", "Аккаунт", "bearer"),
|
||||
("GET", "/contacts", "Контакты", "bearer"),
|
||||
("POST", "/contacts/add", "Контакты", "bearer"),
|
||||
("POST", "/contacts/delete", "Контакты", "bearer"),
|
||||
("POST", "/groups", "Чаты", "bearer"),
|
||||
("POST", "/channels", "Чаты", "bearer"),
|
||||
("POST", "/chats/title", "Чаты", "bearer"),
|
||||
("POST", "/channels/username", "Чаты", "bearer"),
|
||||
("POST", "/channels/comments/settings", "Чаты", "bearer"),
|
||||
("POST", "/channels/comments/send", "Чаты", "bearer"),
|
||||
("GET", "/channels/comments", "Чаты", "bearer"),
|
||||
("POST", "/chats/members/add", "Чаты", "bearer"),
|
||||
("POST", "/chats/members/remove", "Чаты", "bearer"),
|
||||
("POST", "/cloud-password", "Безопасность", "bearer"),
|
||||
("POST", "/cloud-password/reset", "Безопасность", "bearer"),
|
||||
("GET", "/sessions", "Безопасность", "bearer"),
|
||||
("POST", "/sessions/revoke", "Безопасность", "bearer"),
|
||||
("POST", "/sessions/revoke-others", "Безопасность", "bearer"),
|
||||
("POST", "/bots", "Боты", "bearer"),
|
||||
("POST", "/bots/token/reset", "Боты", "bearer"),
|
||||
("POST", "/e2e/key", "E2E", "bearer"),
|
||||
("GET", "/e2e/key", "E2E", "bearer"),
|
||||
("POST", "/e2e/backup", "E2E", "bearer"),
|
||||
("GET", "/e2e/backup", "E2E", "bearer"),
|
||||
("POST", "/e2e/reset", "E2E", "bearer"),
|
||||
("GET", "/wallet", "Dastars", "bearer"),
|
||||
("POST", "/wallet/send", "Dastars", "bearer"),
|
||||
("GET", "/wallet/history", "Dastars", "bearer"),
|
||||
("POST", "/call", "Звонки", "bearer"),
|
||||
("POST", "/voice-ticket", "Звонки", "bearer"),
|
||||
("GET", "/voice/participants", "Звонки", "bearer"),
|
||||
("GET", "/voice", "Звонки", "bearer"),
|
||||
("POST", "/send", "Сообщения", "bearer"),
|
||||
("POST", "/edit", "Сообщения", "bearer"),
|
||||
("POST", "/callback", "Боты", "bearer"),
|
||||
("POST", "/reactions", "Реакции", "bearer"),
|
||||
("POST", "/reactions/paid", "Реакции", "bearer"),
|
||||
("POST", "/read", "Сообщения", "bearer"),
|
||||
("POST", "/delete", "Сообщения", "bearer"),
|
||||
("POST", "/favorite", "Сообщения", "bearer"),
|
||||
("POST", "/media/quote", "Файлы", "bearer"),
|
||||
("POST", "/messages/prepare", "Файлы", "bearer"),
|
||||
("POST", "/messages/commit", "Файлы", "bearer"),
|
||||
("POST", "/messages/cancel", "Файлы", "bearer"),
|
||||
("POST", "/forward", "Файлы", "bearer"),
|
||||
("GET", "/file/ticket", "Файлы", "bearer"),
|
||||
("GET", "/nodes/status", "Сервис", "bearer"),
|
||||
("GET", "/chats", "Чаты", "bearer"),
|
||||
("POST", "/chats/delete", "Чаты", "bearer"),
|
||||
("POST", "/users/ban", "Модерация", "bearer"),
|
||||
("POST", "/users/unban", "Модерация", "bearer"),
|
||||
("GET", "/history", "Сообщения", "bearer"),
|
||||
("GET", "/updates", "Updates", "bearer"),
|
||||
("POST", "/updates/ack", "Updates", "bearer"),
|
||||
("GET", "/file/{id}", "Файлы", "bearer"),
|
||||
("GET", "/health/live", "Сервис", "public"),
|
||||
("GET", "/health/ready", "Сервис", "public"),
|
||||
("GET", "/metrics", "Сервис", "public"),
|
||||
]
|
||||
|
||||
GATEWAY = [
|
||||
("GET", "/health", "Сервис", "public"),
|
||||
("GET", "/v1/assets", "Сервис", "public"),
|
||||
("GET", "/v1/invoices", "Invoices", "hmac"),
|
||||
("POST", "/v1/invoices", "Invoices", "hmac"),
|
||||
("GET", "/v1/invoices/{invoice_id}", "Invoices", "hmac"),
|
||||
("GET", "/v1/withdrawals", "Withdrawals", "hmac"),
|
||||
("POST", "/v1/withdrawals", "Withdrawals", "hmac"),
|
||||
("GET", "/v1/withdrawals/{withdrawal_id}", "Withdrawals", "hmac"),
|
||||
("GET", "/v1/balances", "Баланс", "hmac"),
|
||||
("GET", "/v1/api-keys", "Ключи", "hmac"),
|
||||
("GET", "/admin/v1/status", "Operator", "admin"),
|
||||
("GET", "/admin/v1/callbacks", "Operator", "admin"),
|
||||
("GET", "/admin/v1/consolidations", "Operator", "admin"),
|
||||
("POST", "/admin/v1/callbacks/{callback_id}/retry", "Operator", "admin"),
|
||||
("POST", "/admin/v1/merchants", "Operator", "admin"),
|
||||
("POST", "/admin/v1/fee-policies", "Operator", "admin"),
|
||||
("POST", "/admin/v1/merchants/{merchant_id}/api-keys", "Operator", "admin"),
|
||||
("DELETE", "/admin/v1/merchants/{merchant_id}/api-keys/{api_key_id}", "Operator", "admin"),
|
||||
("POST", "/admin/v1/merchants/{merchant_id}/policies", "Operator", "admin"),
|
||||
("POST", "/admin/v1/withdrawals/{withdrawal_id}/resolve", "Operator", "admin"),
|
||||
("POST", "/admin/v1/consolidations/{consolidation_id}/retry", "Operator", "admin"),
|
||||
("GET", "/pay/{token}", "Checkout", "public"),
|
||||
("GET", "/pay/{token}/status", "Checkout", "public"),
|
||||
]
|
||||
|
||||
DONATIONS = [
|
||||
("GET", "/health", "Сервис", "public"),
|
||||
("GET", "/api/assets", "Сервис", "public"),
|
||||
("POST", "/api/auth/start", "Авторизация", "public"),
|
||||
("GET", "/api/auth/poll/{flow}", "Авторизация", "public"),
|
||||
("POST", "/api/logout", "Авторизация", "session"),
|
||||
("GET", "/api/me", "Стример", "session"),
|
||||
("PATCH", "/api/profile", "Стример", "session"),
|
||||
("PUT", "/api/sound", "OBS", "session"),
|
||||
("DELETE", "/api/sound", "OBS", "session"),
|
||||
("POST", "/api/widget/rotate", "OBS", "session"),
|
||||
("POST", "/api/widget/test", "OBS", "session"),
|
||||
("GET", "/api/widget/{token}/config", "OBS", "widget"),
|
||||
("GET", "/api/widget/{token}/events", "OBS", "widget"),
|
||||
("GET", "/api/public/creators/{slug}", "Донаты", "public"),
|
||||
("POST", "/api/public/donations", "Донаты", "public"),
|
||||
("GET", "/api/public/donations/{token}/status", "Донаты", "public"),
|
||||
("POST", "/api/withdrawals", "Вывод", "session"),
|
||||
("GET", "/api/withdrawals/{flow}/poll", "Вывод", "session"),
|
||||
("GET", "/api/admin/status", "Оператор", "session"),
|
||||
("PATCH", "/api/admin/creators/{creator_id}/blocked", "Оператор", "session"),
|
||||
]
|
||||
|
||||
AUTH_LABELS = {
|
||||
"public": "public",
|
||||
"bearer": "Bearer",
|
||||
"oauth_client": "OAuth client",
|
||||
"hmac": "HMAC",
|
||||
"admin": "Admin Bearer",
|
||||
"session": "Secure session",
|
||||
"widget": "Widget token",
|
||||
}
|
||||
|
||||
BODIES = {
|
||||
("messenger", "POST", "/register"): {"email": "you@example.com", "password": "correct horse battery staple"},
|
||||
("messenger", "POST", "/login"): {"email": "you@example.com", "password": "correct horse battery staple"},
|
||||
("messenger", "POST", "/auth/email/start"): {"email": "you@example.com"},
|
||||
("messenger", "POST", "/auth/email/verify"): {"email": "you@example.com", "code": "123456"},
|
||||
("messenger", "POST", "/send"): {"to": 42, "text": "Привет!", "client_message_id": "0190f6d4-example"},
|
||||
("messenger", "POST", "/media/quote"): {"media": [{"client_id": "attachment-1", "name": "photo.jpg", "mime": "image/jpeg", "size": 524288}]},
|
||||
("messenger", "POST", "/messages/prepare"): {"to": 42, "text": "Caption", "client_message_id": "0190f6d4-media", "media": [{"client_id": "attachment-1", "name": "photo.jpg", "mime": "image/jpeg", "size": 524288}]},
|
||||
("messenger", "POST", "/messages/commit"): {"operation_id": "message-op-1-0190f6d4-media"},
|
||||
("messenger", "POST", "/messages/cancel"): {"operation_id": "message-op-1-0190f6d4-media"},
|
||||
("messenger", "POST", "/forward"): {"message_id": 1001, "to": 42, "client_message_id": "0190f6d4-forward"},
|
||||
("messenger", "POST", "/edit"): {"chat_id": 42, "message_id": 1001, "text": "Исправленный текст"},
|
||||
("messenger", "POST", "/reactions"): {"chat_id": 42, "message_id": 1001, "emoji": "🔥"},
|
||||
("messenger", "POST", "/reactions/paid"): {"chat_id": 42, "message_id": 1001, "amount": 5},
|
||||
("messenger", "POST", "/updates/ack"): {"update_id": 2048},
|
||||
("messenger", "POST", "/bots"): {"username": "shop_helper_bot", "name": "Shop helper"},
|
||||
("gateway", "POST", "/v1/invoices"): {
|
||||
"merchant_order_id": "order-123", "asset": "USDT", "network": "tron",
|
||||
"amount_atomic": "25000000", "expires_in_seconds": 1800,
|
||||
"description": "Order 123", "success_url": "https://merchant.example/orders/123",
|
||||
"metadata": {"customer": "42"},
|
||||
},
|
||||
("gateway", "POST", "/v1/withdrawals"): {
|
||||
"asset": "USDT", "network": "tron", "amount_atomic": "10000000",
|
||||
"address": "T...", "fee_mode": "additional", "metadata": {"payout": "p-123"},
|
||||
},
|
||||
("gateway", "POST", "/admin/v1/merchants"): {
|
||||
"name": "Example shop", "callback_url": "https://merchant.example/payments",
|
||||
"callback_hosts": ["merchant.example"],
|
||||
},
|
||||
("gateway", "POST", "/admin/v1/fee-policies"): {
|
||||
"asset": "USDT", "network": "tron", "platform_fee_basis_points": 500,
|
||||
"platform_fee_cap_atomic": "10000000",
|
||||
},
|
||||
("donations", "POST", "/api/auth/start"): {"email": "streamer@example.com"},
|
||||
("donations", "PATCH", "/api/profile"): {
|
||||
"display_name": "My channel", "slug": "my-channel", "accent_color": "#b9f541",
|
||||
"duration_seconds": 8, "show_donor_name": True, "show_message": True,
|
||||
},
|
||||
("donations", "POST", "/api/public/donations"): {
|
||||
"slug": "my-channel", "donor_name": "Anonymous", "message": "Great stream!",
|
||||
"asset": "USDT", "network": "tron", "amount": "25.5", "cover_fee": True,
|
||||
},
|
||||
("donations", "POST", "/api/withdrawals"): {
|
||||
"asset": "USDT", "network": "tron", "amount": "10",
|
||||
"address": "TExampleRecipientAddress",
|
||||
},
|
||||
("donations", "PATCH", "/api/admin/creators/{creator_id}/blocked"): {"blocked": True},
|
||||
}
|
||||
|
||||
DESCRIPTIONS = {
|
||||
"/oauth/device_authorization": "Создает device_code, user_code и verification URI для универсального входа через Messenger.",
|
||||
"/oauth/token": "Обменивает подтвержденный device_code на OIDC access/id token. Во время ожидания возвращает authorization_pending.",
|
||||
"/send": "Отправляет сообщение без медиа. Для клиентской очереди используйте стабильный client_message_id.",
|
||||
"/media/quote": "Рассчитывает единую стоимость новых вложений: сумма байтов округляется вверх до MiB один раз на сообщение.",
|
||||
"/messages/prepare": "Атомарно резервирует оплату и выдает до 10 upload tickets. Текст и media[] опциональны по отдельности, но сообщение не может быть пустым.",
|
||||
"/messages/commit": "Проверяет все загруженные вложения, одним платежом списывает DSR и публикует или редактирует одно сообщение.",
|
||||
"/messages/cancel": "Удаляет незавершенные загрузки и отменяет резерв оплаты.",
|
||||
"/updates": "Long polling событий клиента или бота. Bot updates долговечны и остаются до явного ACK.",
|
||||
"/reactions/paid": "Переводит указанное количество Dastars автору сообщения; для бота — его владельцу. amount может быть больше 1.",
|
||||
"/voice": "Переключает соединение на WebSocket после получения voice ticket.",
|
||||
"/v1/invoices": "Создает invoice или возвращает историю с cursor pagination, в зависимости от HTTP-метода.",
|
||||
"/v1/withdrawals": "Создает withdrawal или возвращает историю. Для token assets разрешен только fee_mode=additional.",
|
||||
"/v1/assets": "Возвращает только пары asset/network, для которых wallet seed готов к работе.",
|
||||
"/pay/{token}": "Публичная HTML-страница оплаты с QR, серверно-синхронизированным таймером и live status.",
|
||||
"/pay/{token}/status": "JSON-состояние checkout для polling; включает серверное время и срок действия.",
|
||||
"/api/public/donations": "Создает Gateway invoice с комиссией 5%. cover_fee=false удерживает комиссию из суммы; cover_fee=true точно увеличивает платеж так, чтобы стример получил введенную сумму после комиссии. Все расчеты выполняются в atomic без float.",
|
||||
"/api/widget/{token}/events": "Долгоживущий SSE-поток для OBS Browser Source. Виджет сам ставит alerts в очередь.",
|
||||
"/api/withdrawals": "Резервирует баланс и запускает новый Messenger Device Flow с описанием суммы и адреса.",
|
||||
"/api/withdrawals/{flow}/poll": "После свайпа тем же Messenger-пользователем идемпотентно отправляет withdrawal в Gateway.",
|
||||
}
|
||||
|
||||
|
||||
def endpoint(service_id: str, row: tuple[str, str, str, str]) -> dict:
|
||||
method, path, group, auth = row
|
||||
default = f"{title(path, method)}. Возвращает JSON-ответ или стандартную JSON-ошибку."
|
||||
item = {
|
||||
"method": method,
|
||||
"path": path,
|
||||
"group": group,
|
||||
"auth": auth,
|
||||
"auth_label": AUTH_LABELS[auth],
|
||||
"title": title(path, method),
|
||||
"description": DESCRIPTIONS.get(path, default),
|
||||
}
|
||||
body = BODIES.get((service_id, method, path))
|
||||
if body is not None:
|
||||
item["body"] = body
|
||||
return item
|
||||
|
||||
|
||||
def openapi(service: dict) -> dict:
|
||||
paths: dict[str, dict] = {}
|
||||
for item in service["endpoints"]:
|
||||
operation: dict = {
|
||||
"tags": [item["group"]],
|
||||
"summary": item["title"],
|
||||
"description": item["description"],
|
||||
"operationId": (
|
||||
item["method"].lower() + "_" +
|
||||
item["path"].strip("/").replace("/", "_").replace("{", "").replace("}", "")
|
||||
).replace("-", "_").replace(".", "_") or "root",
|
||||
"responses": {
|
||||
"200": {"description": "Успешный ответ"},
|
||||
"400": {"description": "Ошибка запроса"},
|
||||
"401": {"description": "Ошибка авторизации"},
|
||||
},
|
||||
}
|
||||
if item["auth"] == "bearer":
|
||||
operation["security"] = [{"bearerAuth": []}]
|
||||
elif item["auth"] == "admin":
|
||||
operation["security"] = [{"adminBearer": []}]
|
||||
elif item["auth"] == "hmac":
|
||||
operation["security"] = [{"merchantHmac": []}]
|
||||
operation["parameters"] = [
|
||||
{"name": "X-Api-Timestamp", "in": "header", "required": True, "schema": {"type": "integer"}},
|
||||
{"name": "X-Api-Nonce", "in": "header", "required": True, "schema": {"type": "string", "format": "uuid"}},
|
||||
{"name": "X-Api-Signature", "in": "header", "required": True, "schema": {"type": "string"}},
|
||||
]
|
||||
elif item["auth"] == "oauth_client":
|
||||
operation["security"] = [{"oauthClient": []}]
|
||||
elif item["auth"] == "session":
|
||||
operation["security"] = [{"sessionCookie": []}]
|
||||
parameters = operation.setdefault("parameters", [])
|
||||
for parameter in (part[1:-1] for part in item["path"].split("/") if part.startswith("{")):
|
||||
parameters.append({
|
||||
"name": parameter, "in": "path", "required": True,
|
||||
"schema": {"type": "string"},
|
||||
})
|
||||
if not parameters:
|
||||
operation.pop("parameters", None)
|
||||
if item.get("body") is not None:
|
||||
operation["requestBody"] = {
|
||||
"required": True,
|
||||
"content": {"application/json": {"schema": {"type": "object"}, "example": item["body"]}},
|
||||
}
|
||||
paths.setdefault(item["path"], {})[item["method"].lower()] = operation
|
||||
|
||||
schemes = {
|
||||
"bearerAuth": {"type": "http", "scheme": "bearer"},
|
||||
"oauthClient": {
|
||||
"type": "http",
|
||||
"scheme": "basic",
|
||||
"description": (
|
||||
"OAuth client_id/client_secret. Device Authorization Grant не имеет "
|
||||
"отдельного стандартного flow в OpenAPI 3.1; discovery доступен в "
|
||||
"/.well-known/oauth-authorization-server."
|
||||
),
|
||||
},
|
||||
"merchantHmac": {"type": "apiKey", "in": "header", "name": "X-Api-Key",
|
||||
"description": "Дополнительно обязательна HMAC-SHA256 подпись запроса."},
|
||||
"adminBearer": {"type": "http", "scheme": "bearer", "description": "Operator admin token"},
|
||||
"sessionCookie": {"type": "apiKey", "in": "cookie", "name": "ove_donations_session",
|
||||
"description": "HttpOnly session; changing requests also require X-CSRF-Token."},
|
||||
}
|
||||
scheme_auth = {
|
||||
"bearerAuth": "bearer",
|
||||
"oauthClient": "oauth_client",
|
||||
"merchantHmac": "hmac",
|
||||
"adminBearer": "admin",
|
||||
"sessionCookie": "session",
|
||||
}
|
||||
active_auth = {item["auth"] for item in service["endpoints"]}
|
||||
return {
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": f"OVE {service['name']} API",
|
||||
"version": "1.0.0",
|
||||
"description": service["description"],
|
||||
},
|
||||
"servers": [{"url": service["base_url"]}],
|
||||
"paths": paths,
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
key: value for key, value in schemes.items()
|
||||
if scheme_auth[key] in active_auth
|
||||
},
|
||||
"schemas": {
|
||||
"Error": {
|
||||
"type": "object",
|
||||
"properties": {"error": {"type": "string"}},
|
||||
"required": ["error"],
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
services = [
|
||||
{
|
||||
"id": "messenger",
|
||||
"name": "Messenger",
|
||||
"base_url": "https://ms.ove.rs",
|
||||
"description": "OVE Messenger user, bot, OAuth, files, calls and realtime updates API.",
|
||||
"endpoints": [endpoint("messenger", row) for row in MESSENGER],
|
||||
},
|
||||
{
|
||||
"id": "gateway",
|
||||
"name": "Crypto Gateway",
|
||||
"base_url": "https://cr.ove.rs",
|
||||
"description": "OVE multi-merchant cryptocurrency payments, checkout and operator API.",
|
||||
"endpoints": [endpoint("gateway", row) for row in GATEWAY],
|
||||
},
|
||||
{
|
||||
"id": "donations",
|
||||
"name": "Donations",
|
||||
"base_url": "https://do.ove.rs",
|
||||
"description": "OVE donation pages, Messenger login, Crypto Gateway payments and OBS alerts.",
|
||||
"endpoints": [endpoint("donations", row) for row in DONATIONS],
|
||||
},
|
||||
]
|
||||
manifest = {
|
||||
"version": 1,
|
||||
"generated_from": [
|
||||
"micro-chat/micromsg/src/app_routes.rs",
|
||||
"crypto-gateway/crates/gateway-api/src/main.rs",
|
||||
"crypto-gateway/crates/gateway-api/src/web.rs",
|
||||
"donations/src/main.rs",
|
||||
],
|
||||
"total_endpoints": sum(len(service["endpoints"]) for service in services),
|
||||
"services": services,
|
||||
}
|
||||
OPENAPI.mkdir(parents=True, exist_ok=True)
|
||||
(PUBLIC / "api-manifest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
for service in services:
|
||||
(OPENAPI / f"{service['id']}.json").write_text(
|
||||
json.dumps(openapi(service), ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
gateway_spec = OPENAPI / "gateway.json"
|
||||
if not gateway_spec.exists():
|
||||
(OPENAPI / "gateway.json").write_text(
|
||||
(OPENAPI / "crypto-gateway.json").read_text(encoding="utf-8"), encoding="utf-8"
|
||||
)
|
||||
(OPENAPI / "crypto-gateway.json").unlink()
|
||||
print(f"generated {manifest['total_endpoints']} endpoints")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail when a public service route is missing from docs or OpenAPI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
DOCS = Path(__file__).resolve().parents[1]
|
||||
REPO = DOCS.parent
|
||||
PUBLIC = DOCS / "public"
|
||||
|
||||
|
||||
def messenger_routes() -> set[tuple[str, str]]:
|
||||
source = (REPO / "micro-chat/micromsg/src/app_routes.rs").read_text(encoding="utf-8")
|
||||
routes = set(re.findall(r'\("([A-Z]+)", "([^"]+)"\)\s*=>', source))
|
||||
routes = {(method, path) for method, path in routes if not path.startswith("/internal/")}
|
||||
if 'path.starts_with("/file/")' in source:
|
||||
routes.add(("GET", "/file/{id}"))
|
||||
return routes
|
||||
|
||||
|
||||
def gateway_routes() -> set[tuple[str, str]]:
|
||||
source = (REPO / "crypto-gateway/crates/gateway-api/src/main.rs").read_text(encoding="utf-8")
|
||||
routes: set[tuple[str, str]] = set()
|
||||
cursor = 0
|
||||
while (start := source.find(".route(", cursor)) != -1:
|
||||
depth = 0
|
||||
end = start + len(".route(")
|
||||
while end < len(source):
|
||||
if source[end] == "(":
|
||||
depth += 1
|
||||
elif source[end] == ")":
|
||||
if depth == 0:
|
||||
break
|
||||
depth -= 1
|
||||
end += 1
|
||||
block = source[start:end + 1]
|
||||
match = re.search(r'\.route\(\s*"([^"]+)"\s*,(.*)\)$', block, re.S)
|
||||
cursor = end + 1
|
||||
if not match:
|
||||
continue
|
||||
path, handlers = match.groups()
|
||||
if path.startswith("/v1/") or path.startswith("/admin/v1/") or path == "/health":
|
||||
for method in re.findall(r'\b(get|post|delete)\s*\(', handlers):
|
||||
routes.add((method.upper(), path))
|
||||
web = (REPO / "crypto-gateway/crates/gateway-api/src/web.rs").read_text(encoding="utf-8")
|
||||
for path in ("/pay/{token}", "/pay/{token}/status"):
|
||||
if f'.route("{path}", get(' not in web:
|
||||
raise AssertionError(f"hosted checkout route disappeared: {path}")
|
||||
routes.add(("GET", path))
|
||||
return routes
|
||||
|
||||
|
||||
def donations_routes() -> set[tuple[str, str]]:
|
||||
source = (REPO / "donations/src/main.rs").read_text(encoding="utf-8")
|
||||
routes: set[tuple[str, str]] = set()
|
||||
cursor = 0
|
||||
while (start := source.find(".route(", cursor)) != -1:
|
||||
depth = 0
|
||||
end = start + len(".route(")
|
||||
while end < len(source):
|
||||
if source[end] == "(":
|
||||
depth += 1
|
||||
elif source[end] == ")":
|
||||
if depth == 0:
|
||||
break
|
||||
depth -= 1
|
||||
end += 1
|
||||
block = source[start:end + 1]
|
||||
match = re.search(r'\.route\(\s*"([^"]+)"\s*,(.*)\)$', block, re.S)
|
||||
cursor = end + 1
|
||||
if not match:
|
||||
continue
|
||||
path, handlers = match.groups()
|
||||
if path == "/health" or path.startswith("/api/"):
|
||||
for method in re.findall(r'\b(get|post|delete|put|patch)\s*\(', handlers):
|
||||
routes.add((method.upper(), path))
|
||||
for method in re.findall(r'\.(delete|put|patch)\s*\(', handlers):
|
||||
routes.add((method.upper(), path))
|
||||
return routes
|
||||
|
||||
|
||||
def main() -> int:
|
||||
manifest = json.loads((PUBLIC / "api-manifest.json").read_text(encoding="utf-8"))
|
||||
documented = {
|
||||
service["id"]: {(item["method"], item["path"]) for item in service["endpoints"]}
|
||||
for service in manifest["services"]
|
||||
}
|
||||
source = {
|
||||
"messenger": messenger_routes(),
|
||||
"gateway": gateway_routes(),
|
||||
"donations": donations_routes(),
|
||||
}
|
||||
failed = False
|
||||
for service_id, actual in source.items():
|
||||
missing = actual - documented[service_id]
|
||||
stale = documented[service_id] - actual
|
||||
if missing:
|
||||
print(f"{service_id}: undocumented routes: {sorted(missing)}", file=sys.stderr)
|
||||
failed = True
|
||||
if stale:
|
||||
print(f"{service_id}: stale routes: {sorted(stale)}", file=sys.stderr)
|
||||
failed = True
|
||||
spec = json.loads((PUBLIC / "openapi" / f"{service_id}.json").read_text(encoding="utf-8"))
|
||||
in_spec = {(method.upper(), path) for path, operations in spec["paths"].items()
|
||||
for method in operations if method.upper() in {"GET", "POST", "DELETE", "PUT", "PATCH"}}
|
||||
if in_spec != documented[service_id]:
|
||||
print(f"{service_id}: OpenAPI mismatch", file=sys.stderr)
|
||||
failed = True
|
||||
counted = sum(len(routes) for routes in documented.values())
|
||||
if counted != manifest["total_endpoints"]:
|
||||
print(f"manifest total is {manifest['total_endpoints']}, counted {counted}", file=sys.stderr)
|
||||
failed = True
|
||||
if failed:
|
||||
return 1
|
||||
print(f"verified {counted} public endpoints across {len(source)} services")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user