Checkpoint: первая рабочая версия звонков, сигналинг будет переделан
This commit is contained in:
parent
b05da86197
commit
310863faec
@ -14,3 +14,12 @@
|
||||
- `client.version` — версия клиентского UI.
|
||||
- `server.version` — версия серверной части.
|
||||
- Базовое правило инкремента: `+1` по последнему числовому сегменту (patch), если не оговорено иное.
|
||||
|
||||
## Deploy
|
||||
- Все документы и заметки по деплою хранить в папке `Deploy Server/`.
|
||||
- Для сервера `VPS-05` (`45.136.124.227`) доступ выполнять через пользователя `player`.
|
||||
- По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке.
|
||||
- Деплой UI выполнять только в один целевой контур за запуск: либо `prod` (основной), либо один выбранный тестовый (`ui_1`, `ui_2`, `ui_3`, `ui_drygmira`, `ui_milana`, `ui_aidar`).
|
||||
- Если из запроса неясно, куда деплоить, обязательно сначала спросить пользователя, какой именно целевой контур нужен.
|
||||
- По умолчанию сначала деплой и проверка на тестовом контуре; на основной (`prod`) деплоить только после явного подтверждения пользователя, что версия проверена и готова.
|
||||
- При уточняющем вопросе отдельно предупреждать: деплой на основной выполнять только если точно подтверждена корректная работа.
|
||||
|
||||
53
Deploy Server/servers-inventory.md
Normal file
53
Deploy Server/servers-inventory.md
Normal file
@ -0,0 +1,53 @@
|
||||
# SHiNE Deployment Servers Inventory
|
||||
|
||||
## Scope
|
||||
This folder contains all deployment-related notes and server records for SHiNE.
|
||||
|
||||
## Legacy Production Server
|
||||
- Name: `VPS-02` (legacy)
|
||||
- Access: `root@194.87.0.247`
|
||||
- Current role: old production server
|
||||
- Confirmed services:
|
||||
- `coturn` is installed and active (`systemd: active/running`)
|
||||
- `caddy` is installed (reported by project context; verify version on host if needed)
|
||||
- TURN configuration observed on host:
|
||||
- `listening-port=3478`
|
||||
- `external-ip=194.87.0.247`
|
||||
- `relay-ip=194.87.0.247`
|
||||
- auth mode: `use-auth-secret` + `static-auth-secret`
|
||||
- SHiNE deployment note:
|
||||
- This host is used as current/legacy runtime for SHiNE.
|
||||
- Gradle-based deployment is used in this project (see repository deploy tasks and scripts).
|
||||
|
||||
## Target Production Server (Migration)
|
||||
- Name: `VPS-05` (new)
|
||||
- Access: `root@45.136.124.227`
|
||||
- Planned role: new primary production server for gradual migration
|
||||
- Baseline setup done:
|
||||
- `ripgrep` installed
|
||||
- user `player` created
|
||||
- user `player` added to `sudo` group
|
||||
- deployment directory created: `/home/player/SHiNE`
|
||||
- Rule:
|
||||
- All SHiNE-related runtime files and deployments on VPS-05 should be placed under `/home/player/SHiNE`.
|
||||
|
||||
## Additional TURN Node
|
||||
- Name: `promo-node-93`
|
||||
- Access: `ubuntu@93.170.12.154` (and `player` user for SHiNE operations)
|
||||
- Role: additional TURN node for SHiNE calls
|
||||
- TURN setup:
|
||||
- `coturn` installed and active
|
||||
- `listening-port=3478`
|
||||
- `tls-listening-port=5349`
|
||||
- `use-auth-secret` + shared `static-auth-secret`
|
||||
- relay UDP port range: `49152-50152`
|
||||
- Runtime files:
|
||||
- `/etc/turnserver.conf`
|
||||
- `/home/player/SHiNE/coturn/turnserver.conf`
|
||||
- Cleanup done:
|
||||
- Disabled old reverse SSH tunnel (`reverse-ssh.service`) that exposed `0.0.0.0:1200 -> localhost:22` to `194.87.0.247`.
|
||||
|
||||
## Next Migration Steps (recommended)
|
||||
1. Install and configure runtime dependencies (JDK, Caddy, DB, TURN if required).
|
||||
2. Mirror SHiNE deployment process from VPS-02 using existing Gradle deployment flow.
|
||||
3. Move traffic gradually and validate logs/metrics before final cutover.
|
||||
@ -31,3 +31,11 @@
|
||||
- количество успешных вставок пар,
|
||||
- доля доставок в WS/push,
|
||||
- количество ретраев межсерверной пересылки.
|
||||
|
||||
## 6) Ограничение текущих звонков (важно)
|
||||
- Сейчас звонки работают только в рамках одного сигнального сервера (или единого контура, где обе стороны уже подключены).
|
||||
- Сценарий «пользователь A на своих серверах, пользователь B на других серверах» пока не поддержан.
|
||||
- TODO на будущее:
|
||||
- временная межсерверная авторизация/сессия для старта звонка,
|
||||
- отправка сигнальных сообщений между разными серверами пользователей,
|
||||
- аккуратное завершение временной сессии после установления/завершения звонка.
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
client.version=1.2.36
|
||||
server.version=1.2.30
|
||||
client.version=1.2.37
|
||||
server.version=1.2.31
|
||||
|
||||
105
docs/Ответ_на_вопрос_о_блокчейне_каналах_и_расширяемости.md
Normal file
105
docs/Ответ_на_вопрос_о_блокчейне_каналах_и_расширяемости.md
Normal file
@ -0,0 +1,105 @@
|
||||
Дата: 27.04.2026
|
||||
|
||||
# Ответ по блокчейну, форматам, каналам и расширяемости
|
||||
|
||||
Короткий итог: текущую систему можно запускать и развивать дальше. Архитектура уже в целом готова к расширению версиями, но расширять нужно по строгим правилам совместимости.
|
||||
|
||||
## 1) Что важно зафиксировать про эволюцию форматов
|
||||
|
||||
Да, ваш подход верный:
|
||||
- новые возможности добавляем через новые `type/subType/version` и/или новый `frameCode`;
|
||||
- старые блоки не переписываем и не «переподписываем»;
|
||||
- делаем конвертер/адаптер чтения: старые блоки читаются и приводятся к новой внутренней модели.
|
||||
|
||||
Это и есть правильная схема «старое автоматически читается и представляется в новом формате».
|
||||
|
||||
## 2) Есть ли в коде узкие места, где нельзя расширять
|
||||
|
||||
Критичных тупиков не видно, но есть важные ограничения:
|
||||
- Парсер сейчас строгий: неизвестные `type/subType/version` и неизвестный frame отклоняются.
|
||||
- Значит новые форматы нужно явно добавлять в парсер и серверную валидацию.
|
||||
- Старые блоки без нужных полей не проблема, если новый код умеет читать их как legacy-ветку.
|
||||
|
||||
Вывод: расширение возможно, но только через явную поддержку версий, а не «само появится».
|
||||
|
||||
## 3) Про канал `0`
|
||||
|
||||
Зафиксировано правило:
|
||||
- канал `0` оставляем как технический root;
|
||||
- контент-посты туда пока не публикуем.
|
||||
|
||||
Это теперь отмечено в коде комментариями и проверками:
|
||||
- на клиенте (UI) пост в `lineCode=0` блокируется с понятной ошибкой;
|
||||
- на сервере добавлена валидация, которая тоже отклоняет `TEXT_POST` в канал `0`.
|
||||
|
||||
## 4) Формат аватара (`ava`) — обновлено
|
||||
|
||||
Раньше:
|
||||
- `AR:<txId>`
|
||||
|
||||
Теперь поддерживается составной формат:
|
||||
- `SHA256:<hash>,AR:<txId>`
|
||||
|
||||
Что сделано:
|
||||
- парсер и сборщик формата в UI обновлены;
|
||||
- при загрузке нового аватара считается `SHA-256` оптимизированного файла и сохраняется вместе с `AR`;
|
||||
- при выборе существующего `txId` сначала качается файл, считается `SHA-256`, и только потом пишется `ava`;
|
||||
- серверный граф связей теперь умеет извлекать `AR` из составной строки (включая fallback для legacy/кривых значений).
|
||||
|
||||
Это улучшает целостность: у вас есть не только адрес файла, но и контрольный хэш.
|
||||
|
||||
## 5) Как устроены ответы в каналах и насколько это надёжно
|
||||
|
||||
Ответы (`REPLY` / `EDIT_REPLY`) сделаны отдельно от линейных постов:
|
||||
- `REPLY` хранит ссылку на цель (`toBlockchainName + blockNumber + hash32`) и текст;
|
||||
- `EDIT_REPLY` хранит ссылку на оригинальный reply (`blockNumber + hash32`) и новый текст.
|
||||
|
||||
Сильные стороны:
|
||||
- ссылка идёт по номеру + хэшу (устойчиво к подмене цели);
|
||||
- редактирование отделено от оригинала и может агрегироваться в чтении.
|
||||
|
||||
Для будущего это расширяемо:
|
||||
- можно добавить `TEXT_REPLY_V2` с новым payload, сохранив `V1` для старых клиентов.
|
||||
|
||||
## 6) Вложенные файлы и «мини-разметка» внутри сообщения
|
||||
|
||||
Идея рабочая, но лучше делать аккуратно.
|
||||
|
||||
### Вариант A: «теги в тексте» (ваша идея)
|
||||
|
||||
Плюсы:
|
||||
- быстро внедрить;
|
||||
- стандартно для пользователей (похоже на HTML/Markdown).
|
||||
|
||||
Риски:
|
||||
- экранирование `<` `>` и безопасность рендера;
|
||||
- сложнее валидировать и безопасно отображать (XSS/инъекции).
|
||||
|
||||
Если идти этим путём, лучше:
|
||||
- разрешить только whitelist-теги (`img/file/h1/h2/h3/b/i` и т.д.);
|
||||
- хранить метаданные файла явно: `type,size,sha256,ar`;
|
||||
- рендерить через безопасный парсер, а не через «сырой HTML».
|
||||
|
||||
### Вариант B: структурированный payload (рекомендуется как основной)
|
||||
|
||||
Сделать `TEXT_POST_V2`:
|
||||
- массив сегментов: `text`, `image`, `file`, `heading`, `style`;
|
||||
- для файла хранить `kind`, `mime`, `size`, `sha256`, `storage`, `address`.
|
||||
|
||||
Плюс:
|
||||
- надёжная валидация и безопасный рендер.
|
||||
|
||||
Оптимальная стратегия:
|
||||
1. В UI можно дать «мини-разметку» для ввода.
|
||||
2. На запись преобразовывать её в структурированный payload V2.
|
||||
3. Для старых клиентов отдавать fallback plain text.
|
||||
|
||||
## 7) Практический roadmap без остановки разработки
|
||||
|
||||
1. Оставить текущий blockchain running.
|
||||
2. Формально описать versioning policy (что считается breaking/non-breaking).
|
||||
3. Зафиксировать channel `0` как технический root-only.
|
||||
4. Использовать `ava = SHA256 + AR` как новый стандарт.
|
||||
5. Запланировать `TEXT_*_V2` для вложений и форматирования.
|
||||
|
||||
Итог: стартовать и развивать можно уже сейчас. Основа хорошая, если строго держать версионирование и адаптеры чтения для legacy-блоков.
|
||||
24
predeploy/servers.md
Normal file
24
predeploy/servers.md
Normal file
@ -0,0 +1,24 @@
|
||||
# SHiNE Predeploy Servers
|
||||
|
||||
## Access policy
|
||||
- VPS-05 (`45.136.124.227`): use `player@45.136.124.227` for regular deployment operations.
|
||||
- TURN shared secret (both TURN nodes): `def6d444734d380d2f67a9d345b1debf985eaba0973c343e392c060d97c30106`
|
||||
|
||||
## Servers
|
||||
1. `VPS-02` (legacy): `root@194.87.0.247`
|
||||
- Legacy production host.
|
||||
- caddy installed.
|
||||
- coturn installed.
|
||||
2. `VPS-05` (target): `root@45.136.124.227`
|
||||
- New migration target.
|
||||
- User `player` created, in `sudo`.
|
||||
- SHiNE base dir: `/home/player/SHiNE`.
|
||||
- TURN config path: `/etc/turnserver.conf` (coturn), work docs/scripts in `/home/player/SHiNE/coturn`.
|
||||
3. `promo-node-93` (TURN node): `ubuntu@93.170.12.154`
|
||||
- TURN installed: `coturn` active.
|
||||
- TURN ports: `3478/tcp`, `3478/udp`, `5349/tcp`, `5349/udp`.
|
||||
- TURN relay ports: `49152-50152/udp`.
|
||||
- TURN config path: `/etc/turnserver.conf`.
|
||||
- SHiNE dir for player: `/home/player/SHiNE/coturn`.
|
||||
- Access user `player` created with sudo and SSH key copied from `ubuntu`.
|
||||
- Legacy reverse SSH tunnel to `194.87.0.247:1200` was disabled (`reverse-ssh.service`).
|
||||
17
shine-TURN-server/README.md
Normal file
17
shine-TURN-server/README.md
Normal file
@ -0,0 +1,17 @@
|
||||
# SHiNE TURN Server
|
||||
|
||||
This directory stores TURN setup scripts and operational instructions.
|
||||
|
||||
## Purpose
|
||||
- Install and configure coturn for SHiNE calls.
|
||||
- Keep repeatable setup scripts for new TURN nodes.
|
||||
- Keep TURN-related config templates.
|
||||
|
||||
## Current production model
|
||||
- Multiple TURN servers are supported by backend config section:
|
||||
- `call.ice.turn.servers.1.*`
|
||||
- `call.ice.turn.servers.2.*`
|
||||
- ...
|
||||
- Each server can use:
|
||||
- REST auth (`sharedSecret`) for temporary credentials, or
|
||||
- static `username`/`password` (fallback).
|
||||
@ -1,5 +1,5 @@
|
||||
import { state } from '../state.js';
|
||||
import { buildArweaveDataUrl, validateArweaveTxId } from '../services/arweave-file-service.js';
|
||||
import { buildArweaveDataUrl, validateArweaveTxId, validateSha256Hex } from '../services/arweave-file-service.js';
|
||||
import { getCachedAvatarObjectUrl } from '../services/arweave-avatar-cache-service.js';
|
||||
|
||||
function normalizeLogin(value) {
|
||||
@ -52,6 +52,8 @@ export function renderUserAvatar({
|
||||
if (!validateArweaveTxId(txId)) {
|
||||
return wrap;
|
||||
}
|
||||
const sha256Hex = String(avatar?.sha256Hex || avatar?.sha256 || '').trim().toLowerCase();
|
||||
const expectedSha256Hex = validateSha256Hex(sha256Hex) ? sha256Hex : '';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.alt = 'Аватар';
|
||||
@ -65,7 +67,7 @@ export function renderUserAvatar({
|
||||
setLoadedState(false);
|
||||
|
||||
const gateway = state?.entrySettings?.arweaveServer;
|
||||
void getCachedAvatarObjectUrl({ gateway, txId })
|
||||
void getCachedAvatarObjectUrl({ gateway, txId, expectedSha256Hex })
|
||||
.then((objectUrl) => {
|
||||
const directUrl = buildArweaveDataUrl({ gateway, txId });
|
||||
let triedDirectUrl = false;
|
||||
@ -83,6 +85,12 @@ export function renderUserAvatar({
|
||||
};
|
||||
img.onerror = () => {
|
||||
if (!triedDirectUrl) {
|
||||
if (expectedSha256Hex) {
|
||||
releaseObjectUrl();
|
||||
img.removeAttribute('src');
|
||||
setLoadedState(false);
|
||||
return;
|
||||
}
|
||||
triedDirectUrl = true;
|
||||
releaseObjectUrl();
|
||||
img.src = directUrl;
|
||||
@ -95,6 +103,11 @@ export function renderUserAvatar({
|
||||
img.src = objectUrl;
|
||||
})
|
||||
.catch(() => {
|
||||
if (expectedSha256Hex) {
|
||||
img.removeAttribute('src');
|
||||
setLoadedState(false);
|
||||
return;
|
||||
}
|
||||
let directUrl = '';
|
||||
try {
|
||||
directUrl = buildArweaveDataUrl({ gateway, txId });
|
||||
|
||||
@ -3,8 +3,10 @@ import {
|
||||
buildArweaveDataUrl,
|
||||
getArweaveUploadPrice,
|
||||
prepareAvatarImageFile,
|
||||
sha256HexFromArrayBuffer,
|
||||
uploadArweaveFile,
|
||||
validateArweaveTxId,
|
||||
validateSha256Hex,
|
||||
validateAvatarSourceFile,
|
||||
} from '../services/arweave-file-service.js';
|
||||
import { saveProfileAvatarArweave } from '../services/user-profile-params.js';
|
||||
@ -69,6 +71,7 @@ export function openAvatarWizard({
|
||||
let optimized = null;
|
||||
let priceInfo = null;
|
||||
let uploadedTxId = '';
|
||||
let uploadedSha256Hex = '';
|
||||
|
||||
function revokePreviewUrl() {
|
||||
if (!lastPreviewUrl) return;
|
||||
@ -158,6 +161,7 @@ export function openAvatarWizard({
|
||||
const showStepExistingPreview = async (txId) => {
|
||||
if (closed) return;
|
||||
const previewUrl = buildArweaveDataUrl({ gateway: cleanGateway, txId });
|
||||
let existingSha256Hex = '';
|
||||
root.innerHTML = `
|
||||
<div class="modal" data-avatar-wizard-modal="true">
|
||||
<div class="modal-card stack avatar-wizard-card">
|
||||
@ -165,7 +169,7 @@ export function openAvatarWizard({
|
||||
<div class="avatar-preview-circle avatar-wizard-preview">
|
||||
<img alt="Предпросмотр аватара" data-preview-image="true" />
|
||||
</div>
|
||||
<p class="meta-muted">После сохранения в профиль будет записан только Transaction ID. Сам файл хранится в Arweave.</p>
|
||||
<p class="meta-muted">После сохранения в профиль будут записаны SHA-256 и Transaction ID. Сам файл хранится в Arweave.</p>
|
||||
<p class="avatar-wizard-error" data-error="true"></p>
|
||||
<div class="avatar-wizard-actions">
|
||||
<button class="secondary-btn" type="button" data-action="back">Назад</button>
|
||||
@ -186,15 +190,19 @@ export function openAvatarWizard({
|
||||
|
||||
try {
|
||||
await ensurePreviewImage(previewUrl, imageEl);
|
||||
const response = await fetch(previewUrl, { method: 'GET', cache: 'no-store' });
|
||||
if (!response.ok) throw new Error('BAD_AVATAR_FETCH');
|
||||
existingSha256Hex = await sha256HexFromArrayBuffer(await response.arrayBuffer());
|
||||
if (!validateSha256Hex(existingSha256Hex)) throw new Error('BAD_AVATAR_HASH');
|
||||
if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = false;
|
||||
} catch (error) {
|
||||
setNodeText(errorEl, 'Не удалось загрузить изображение по этому Transaction ID');
|
||||
setNodeText(errorEl, 'Не удалось проверить файл по этому Transaction ID.');
|
||||
if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = true;
|
||||
}
|
||||
|
||||
saveBtn?.addEventListener('click', async () => {
|
||||
try {
|
||||
await saveProfileAvatarArweave(cleanLogin, txId);
|
||||
await saveProfileAvatarArweave(cleanLogin, txId, existingSha256Hex);
|
||||
if (typeof onAvatarSaved === 'function') await onAvatarSaved();
|
||||
close(true, resolve);
|
||||
} catch {
|
||||
@ -238,7 +246,7 @@ export function openAvatarWizard({
|
||||
<img alt="Предпросмотр аватара" data-preview-image="true" />
|
||||
</div>
|
||||
<div class="avatar-wizard-meta" data-meta="true"></div>
|
||||
<p class="meta-muted">После сохранения в профиль будет записан только Transaction ID. Сам файл хранится в Arweave.</p>
|
||||
<p class="meta-muted">После сохранения в профиль будут записаны SHA-256 и Transaction ID. Сам файл хранится в Arweave.</p>
|
||||
<p class="avatar-wizard-error" data-error="true"></p>
|
||||
<div class="avatar-wizard-actions">
|
||||
<button class="secondary-btn" type="button" data-action="back">Назад</button>
|
||||
@ -259,6 +267,7 @@ export function openAvatarWizard({
|
||||
let selectedFile = null;
|
||||
optimized = null;
|
||||
priceInfo = null;
|
||||
uploadedSha256Hex = '';
|
||||
|
||||
modal?.addEventListener('click', (event) => {
|
||||
if (event.target === modal) close(false, resolve);
|
||||
@ -295,6 +304,7 @@ export function openAvatarWizard({
|
||||
<div>Итоговый размер: ${escapeHtml(formatBytes(optimized.optimizedSizeBytes))}</div>
|
||||
<div>Итоговое разрешение: ${escapeHtml(`${optimized.width} × ${optimized.height}`)}</div>
|
||||
<div>Тип файла: ${escapeHtml(optimized.contentType)}</div>
|
||||
<div>SHA-256: ${escapeHtml(String(optimized.sha256Hex || '').toLowerCase())}</div>
|
||||
<div>Примерная цена загрузки: ${escapeHtml(formatAr(priceInfo.ar))} AR</div>
|
||||
`;
|
||||
|
||||
@ -338,9 +348,13 @@ export function openAvatarWizard({
|
||||
],
|
||||
});
|
||||
uploadedTxId = String(uploaded.id || '').trim();
|
||||
uploadedSha256Hex = String(optimized?.sha256Hex || '').trim().toLowerCase();
|
||||
if (!uploadedTxId) {
|
||||
throw new Error('Пустой Transaction ID');
|
||||
}
|
||||
if (!validateSha256Hex(uploadedSha256Hex)) {
|
||||
throw new Error('Некорректный SHA256');
|
||||
}
|
||||
showStepUploaded();
|
||||
} catch {
|
||||
setNodeText(errorEl, 'Не удалось загрузить файл в Arweave.');
|
||||
@ -357,6 +371,8 @@ export function openAvatarWizard({
|
||||
<h3 class="modal-title">Файл загружен в Arweave</h3>
|
||||
<p class="meta-muted">Transaction ID:</p>
|
||||
<p class="avatar-wizard-meta">${escapeHtml(uploadedTxId)}</p>
|
||||
<p class="meta-muted">SHA-256:</p>
|
||||
<p class="avatar-wizard-meta">${escapeHtml(uploadedSha256Hex)}</p>
|
||||
<p class="avatar-wizard-error" data-error="true"></p>
|
||||
<div class="avatar-wizard-actions">
|
||||
<button class="ghost-btn" type="button" data-action="copy-id">Скопировать ID</button>
|
||||
@ -378,7 +394,7 @@ export function openAvatarWizard({
|
||||
});
|
||||
root.querySelector('[data-action="set-avatar"]')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await saveProfileAvatarArweave(cleanLogin, uploadedTxId);
|
||||
await saveProfileAvatarArweave(cleanLogin, uploadedTxId, uploadedSha256Hex);
|
||||
if (typeof onAvatarSaved === 'function') await onAvatarSaved();
|
||||
close(true, resolve);
|
||||
} catch {
|
||||
|
||||
@ -151,7 +151,7 @@ export function render({ navigate }) {
|
||||
let currentFields = [];
|
||||
let currentToggles = [];
|
||||
let currentGender = PROFILE_GENDER_UNKNOWN;
|
||||
let currentAvatar = { value: '', source: '', txId: '', timeMs: 0 };
|
||||
let currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
|
||||
const identityEl = topRow.querySelector('[data-profile-identity="true"]');
|
||||
const avatarSlotEl = topRow.querySelector('[data-profile-avatar-slot="true"]');
|
||||
|
||||
@ -226,7 +226,9 @@ export function render({ navigate }) {
|
||||
login,
|
||||
firstName,
|
||||
lastName,
|
||||
avatar: currentAvatar?.txId ? { ar: currentAvatar.txId } : null,
|
||||
avatar: currentAvatar?.txId
|
||||
? { ar: currentAvatar.txId, sha256Hex: String(currentAvatar?.sha256Hex || '').trim().toLowerCase() }
|
||||
: null,
|
||||
size: 'large',
|
||||
className: 'profile-avatar',
|
||||
}));
|
||||
@ -574,7 +576,7 @@ export function render({ navigate }) {
|
||||
currentFields = snapshot.fields;
|
||||
currentToggles = snapshot.toggles;
|
||||
currentGender = snapshot.gender || PROFILE_GENDER_UNKNOWN;
|
||||
currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', timeMs: 0 };
|
||||
currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
|
||||
|
||||
syncIdentity();
|
||||
renderFields(currentFields);
|
||||
|
||||
@ -88,7 +88,7 @@ export function render({ navigate }) {
|
||||
let currentFields = [];
|
||||
let currentToggles = [];
|
||||
let currentGender = 'unknown';
|
||||
let currentAvatar = { value: '', source: '', txId: '', timeMs: 0 };
|
||||
let currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
|
||||
|
||||
function syncIdentity() {
|
||||
if (!identityEl) return;
|
||||
@ -109,7 +109,9 @@ export function render({ navigate }) {
|
||||
login,
|
||||
firstName,
|
||||
lastName,
|
||||
avatar: currentAvatar?.txId ? { ar: currentAvatar.txId } : null,
|
||||
avatar: currentAvatar?.txId
|
||||
? { ar: currentAvatar.txId, sha256Hex: String(currentAvatar?.sha256Hex || '').trim().toLowerCase() }
|
||||
: null,
|
||||
size: 'large',
|
||||
className: 'profile-avatar',
|
||||
}));
|
||||
@ -161,7 +163,7 @@ export function render({ navigate }) {
|
||||
currentFields = Array.isArray(snapshot.fields) ? snapshot.fields : [];
|
||||
currentToggles = Array.isArray(snapshot.toggles) ? snapshot.toggles : [];
|
||||
currentGender = snapshot.gender || 'unknown';
|
||||
currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', timeMs: 0 };
|
||||
currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
|
||||
syncIdentity();
|
||||
updateAvatarUi();
|
||||
updateTogglesUi();
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { buildArweaveDataUrl, validateArweaveTxId } from './arweave-file-service.js';
|
||||
import {
|
||||
buildArweaveDataUrl,
|
||||
sha256HexFromArrayBuffer,
|
||||
validateArweaveTxId,
|
||||
validateSha256Hex,
|
||||
} from './arweave-file-service.js';
|
||||
|
||||
const DB_NAME = 'shine-ui-avatar-cache';
|
||||
const DB_VERSION = 1;
|
||||
@ -55,6 +60,14 @@ async function putRecord(record) {
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyBlobSha256(blob, expectedSha256Hex) {
|
||||
const expected = String(expectedSha256Hex || '').trim().toLowerCase();
|
||||
if (!validateSha256Hex(expected)) return true;
|
||||
if (!(blob instanceof Blob)) return false;
|
||||
const actual = await sha256HexFromArrayBuffer(await blob.arrayBuffer());
|
||||
return actual === expected;
|
||||
}
|
||||
|
||||
async function getAllRecords() {
|
||||
return withStore('readonly', (store, _tx, resolve, reject) => {
|
||||
const req = store.getAll();
|
||||
@ -146,22 +159,41 @@ function detectImageMime(bytes) {
|
||||
return '';
|
||||
}
|
||||
|
||||
async function getBlobFromCacheOrGateway({ gateway, txId }) {
|
||||
async function getBlobFromCacheOrGateway({ gateway, txId, expectedSha256Hex = '' }) {
|
||||
const expected = String(expectedSha256Hex || '').trim().toLowerCase();
|
||||
try {
|
||||
const cached = await getRecord(txId);
|
||||
if (cached?.blob instanceof Blob) {
|
||||
if (!validateSha256Hex(expected)) return cached.blob;
|
||||
const cacheSha = String(cached?.sha256Hex || '').trim().toLowerCase();
|
||||
if (cacheSha && cacheSha === expected) {
|
||||
return cached.blob;
|
||||
}
|
||||
const ok = await verifyBlobSha256(cached.blob, expected);
|
||||
if (ok) {
|
||||
return cached.blob;
|
||||
}
|
||||
// кэш повреждён или не совпадает с ожидаемым хэшем: удаляем и перекачиваем
|
||||
await deleteRecords([txId]);
|
||||
}
|
||||
} catch {
|
||||
// ignore IndexedDB errors and fallback to fetch
|
||||
}
|
||||
|
||||
const blob = await fetchAvatarBlob({ gateway, txId });
|
||||
if (validateSha256Hex(expected)) {
|
||||
const ok = await verifyBlobSha256(blob, expected);
|
||||
if (!ok) {
|
||||
throw new Error('SHA256_MISMATCH');
|
||||
}
|
||||
}
|
||||
const computedSha256Hex = await sha256HexFromArrayBuffer(await blob.arrayBuffer());
|
||||
const record = {
|
||||
txId,
|
||||
blob,
|
||||
contentType: String(blob.type || 'application/octet-stream'),
|
||||
sizeBytes: Number(blob.size || 0),
|
||||
sha256Hex: computedSha256Hex,
|
||||
cachedAtMs: Date.now(),
|
||||
};
|
||||
try {
|
||||
@ -173,12 +205,16 @@ async function getBlobFromCacheOrGateway({ gateway, txId }) {
|
||||
return blob;
|
||||
}
|
||||
|
||||
export async function getCachedAvatarObjectUrl({ gateway, txId }) {
|
||||
export async function getCachedAvatarObjectUrl({ gateway, txId, expectedSha256Hex = '' }) {
|
||||
const cleanTxId = String(txId || '').trim();
|
||||
if (!validateArweaveTxId(cleanTxId)) {
|
||||
throw new Error('Некорректный Transaction ID Arweave');
|
||||
}
|
||||
const blob = await getBlobFromCacheOrGateway({ gateway, txId: cleanTxId });
|
||||
const blob = await getBlobFromCacheOrGateway({
|
||||
gateway,
|
||||
txId: cleanTxId,
|
||||
expectedSha256Hex,
|
||||
});
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ const MAX_AVATAR_SOURCE_BYTES = 10 * 1024 * 1024;
|
||||
const MAX_AVATAR_SIDE_PX = 768;
|
||||
const AVATAR_QUALITY = 0.86;
|
||||
const TX_ID_RE = /^[A-Za-z0-9_-]{43}$/;
|
||||
const SHA256_HEX_RE = /^[A-Fa-f0-9]{64}$/;
|
||||
|
||||
let arweaveLibPromise = null;
|
||||
|
||||
@ -92,29 +93,64 @@ function canvasToBlob(canvas, type, quality) {
|
||||
});
|
||||
}
|
||||
|
||||
function bytesToHex(bytes) {
|
||||
return Array.from(bytes, (item) => item.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export function validateArweaveTxId(txId) {
|
||||
const value = String(txId || '').trim();
|
||||
return TX_ID_RE.test(value);
|
||||
}
|
||||
|
||||
export function buildArweaveAvatarValue(txId) {
|
||||
export function validateSha256Hex(sha256Hex) {
|
||||
const value = String(sha256Hex || '').trim();
|
||||
return SHA256_HEX_RE.test(value);
|
||||
}
|
||||
|
||||
export async function sha256HexFromArrayBuffer(buffer) {
|
||||
if (!(buffer instanceof ArrayBuffer)) throw new Error('Некорректные данные для SHA256');
|
||||
const digest = await crypto.subtle.digest('SHA-256', buffer);
|
||||
return bytesToHex(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
export function buildArweaveAvatarValue(txId, sha256Hex = '') {
|
||||
const cleanTxId = String(txId || '').trim();
|
||||
if (!validateArweaveTxId(cleanTxId)) {
|
||||
throw new Error('Некорректный Transaction ID Arweave');
|
||||
}
|
||||
return `AR:${cleanTxId}`;
|
||||
const cleanSha = String(sha256Hex || '').trim().toLowerCase();
|
||||
if (!cleanSha) return `AR:${cleanTxId}`;
|
||||
if (!validateSha256Hex(cleanSha)) {
|
||||
throw new Error('Некорректный SHA256 хэш аватара');
|
||||
}
|
||||
return `SHA256:${cleanSha},AR:${cleanTxId}`;
|
||||
}
|
||||
|
||||
export function parseArweaveAvatarValue(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw.startsWith('AR:')) {
|
||||
return { ok: false, network: '', txId: '' };
|
||||
if (!raw) {
|
||||
return { ok: false, network: '', txId: '', sha256Hex: '' };
|
||||
}
|
||||
|
||||
const arMatch = raw.match(/(?:^|,)\s*AR:([A-Za-z0-9_-]{43})\s*(?:,|$)/);
|
||||
let txId = String(arMatch?.[1] || '').trim();
|
||||
if (!txId) {
|
||||
// fallback для старых/кривых значений без запятых: "...AR:<txid>..."
|
||||
const fallbackAr = raw.match(/AR:([A-Za-z0-9_-]{43})/);
|
||||
txId = String(fallbackAr?.[1] || '').trim();
|
||||
}
|
||||
const txId = raw.slice(3).trim();
|
||||
if (!validateArweaveTxId(txId)) {
|
||||
return { ok: false, network: '', txId: '' };
|
||||
return { ok: false, network: '', txId: '', sha256Hex: '' };
|
||||
}
|
||||
return { ok: true, network: 'AR', txId };
|
||||
|
||||
const shaMatch = raw.match(/(?:^|,)\s*SHA256:([A-Fa-f0-9]{64})\s*(?:,|$)/);
|
||||
let sha256Hex = String(shaMatch?.[1] || '').trim().toLowerCase();
|
||||
if (!sha256Hex) {
|
||||
const fallbackSha = raw.match(/SHA256:([A-Fa-f0-9]{64})/);
|
||||
sha256Hex = String(fallbackSha?.[1] || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
return { ok: true, network: 'AR', txId, sha256Hex };
|
||||
}
|
||||
|
||||
export function buildArweaveDataUrl({ gateway, txId }) {
|
||||
@ -209,6 +245,8 @@ export async function prepareAvatarImageFile(file) {
|
||||
}
|
||||
|
||||
const optimizedFile = blobToFile(blob, fileName, contentType);
|
||||
const optimizedArrayBuffer = await optimizedFile.arrayBuffer();
|
||||
const sha256Hex = await sha256HexFromArrayBuffer(optimizedArrayBuffer);
|
||||
return {
|
||||
file: optimizedFile,
|
||||
originalSizeBytes: Number(file.size || 0),
|
||||
@ -218,6 +256,7 @@ export async function prepareAvatarImageFile(file) {
|
||||
width,
|
||||
height,
|
||||
contentType,
|
||||
sha256Hex,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error) throw error;
|
||||
|
||||
@ -199,7 +199,12 @@ export async function loadUserProfileCard(login) {
|
||||
gender: String(snapshot?.gender || 'unknown').trim().toLowerCase() || 'unknown',
|
||||
official: Boolean(toggles.official),
|
||||
shine: Boolean(toggles.shine),
|
||||
avatar: snapshot?.avatar?.txId ? { ar: String(snapshot.avatar.txId).trim() } : null,
|
||||
avatar: snapshot?.avatar?.txId
|
||||
? {
|
||||
ar: String(snapshot.avatar.txId).trim(),
|
||||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import { authService, state } from '../state.js';
|
||||
import { buildArweaveAvatarValue, parseArweaveAvatarValue, validateArweaveTxId } from './arweave-file-service.js';
|
||||
import {
|
||||
buildArweaveAvatarValue,
|
||||
parseArweaveAvatarValue,
|
||||
validateArweaveTxId,
|
||||
validateSha256Hex,
|
||||
} from './arweave-file-service.js';
|
||||
|
||||
export const profileFieldDefs = [
|
||||
{ key: 'first_name', readKeys: ['first_name'], label: 'Имя', placeholder: 'Введите имя' },
|
||||
@ -123,15 +128,17 @@ export async function loadProfileSnapshot(login) {
|
||||
const parsedAvatar = parseArweaveAvatarValue(latestAvatar?.value || '');
|
||||
const avatar = parsedAvatar.ok
|
||||
? {
|
||||
value: buildArweaveAvatarValue(parsedAvatar.txId),
|
||||
value: buildArweaveAvatarValue(parsedAvatar.txId, parsedAvatar.sha256Hex),
|
||||
source: 'arweave',
|
||||
txId: parsedAvatar.txId,
|
||||
sha256Hex: parsedAvatar.sha256Hex || '',
|
||||
timeMs: latestAvatar?.timeMs || 0,
|
||||
}
|
||||
: {
|
||||
value: '',
|
||||
source: '',
|
||||
txId: '',
|
||||
sha256Hex: '',
|
||||
timeMs: latestAvatar?.timeMs || 0,
|
||||
};
|
||||
|
||||
@ -175,10 +182,14 @@ export async function saveProfileGender(login, gender) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveProfileAvatarArweave(login, txId) {
|
||||
export async function saveProfileAvatarArweave(login, txId, sha256Hex) {
|
||||
const cleanTxId = String(txId || '').trim();
|
||||
const cleanSha = String(sha256Hex || '').trim().toLowerCase();
|
||||
if (!validateArweaveTxId(cleanTxId)) {
|
||||
throw new Error('Некорректный Transaction ID Arweave');
|
||||
}
|
||||
await saveProfileParamBlock(login, 'ava', buildArweaveAvatarValue(cleanTxId));
|
||||
if (!validateSha256Hex(cleanSha)) {
|
||||
throw new Error('Некорректный SHA256 хэш аватара');
|
||||
}
|
||||
await saveProfileParamBlock(login, 'ava', buildArweaveAvatarValue(cleanTxId, cleanSha));
|
||||
}
|
||||
|
||||
@ -142,6 +142,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
case "prev_line_block_not_found" -> "Не найден блок prevLineNumber для проверки линии";
|
||||
case "bad_prev_line_hash" -> "Некорректный prevLineHash";
|
||||
case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine";
|
||||
case "channel_zero_writes_disabled" -> "Запись в канал 0 временно отключена";
|
||||
case "channel_name_already_exists" -> "Такое название канала уже занято";
|
||||
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
||||
default -> "Ошибка: " + code;
|
||||
@ -337,6 +338,17 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
prevLineHash32 = bl.prevLineBlockHash32();
|
||||
thisLineNumber = bl.lineSeq();
|
||||
|
||||
// Канал 0 сохраняем как технический root, но публикации в него пока не принимаем.
|
||||
// Это правило защищает от "случайных" постов в дефолтный канал.
|
||||
int msgType = block.type & 0xFFFF;
|
||||
int msgSubType = block.subType & 0xFFFF;
|
||||
if (msgType == 1
|
||||
&& msgSubType == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
&& lineCode != null
|
||||
&& lineCode == 0) {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "channel_zero_writes_disabled", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
|
||||
// Нормализация: -1 не пишем в БД (для совместимости со старым TextBody)
|
||||
if (prevLineNumber != null && prevLineNumber == -1) {
|
||||
lineCode = null;
|
||||
|
||||
@ -25,6 +25,7 @@ import java.util.regex.Pattern;
|
||||
|
||||
public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
private static final Pattern AR_TX_ID_PATTERN = Pattern.compile("^[A-Za-z0-9_-]{43}$");
|
||||
private static final Pattern AVATAR_AR_TOKEN_PATTERN = Pattern.compile("(?:^|,)\\s*AR:([A-Za-z0-9_-]{43})\\s*(?:,|$)");
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
||||
@ -201,8 +202,18 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
|
||||
private String extractArAvatarTxId(String rawValue) {
|
||||
String value = String.valueOf(rawValue == null ? "" : rawValue).trim();
|
||||
if (!value.startsWith("AR:")) return null;
|
||||
String txId = value.substring(3).trim();
|
||||
if (value.isEmpty()) return null;
|
||||
var tokenMatch = AVATAR_AR_TOKEN_PATTERN.matcher(value);
|
||||
String txId = tokenMatch.find() ? String.valueOf(tokenMatch.group(1)).trim() : "";
|
||||
// fallback для старых/кривых значений без запятых: "SHA256:...AR:<txid>"
|
||||
if (txId.isEmpty()) {
|
||||
int idx = value.indexOf("AR:");
|
||||
if (idx >= 0) {
|
||||
int start = idx + 3;
|
||||
int end = value.indexOf(',', start);
|
||||
txId = (end >= 0 ? value.substring(start, end) : value.substring(start)).trim();
|
||||
}
|
||||
}
|
||||
if (!AR_TX_ID_PATTERN.matcher(txId).matches()) return null;
|
||||
return txId;
|
||||
}
|
||||
|
||||
@ -51,12 +51,18 @@ public class Net_GetCallIceConfig_Handler implements JsonMessageHandler {
|
||||
|
||||
String turnUsername = "";
|
||||
String turnPassword = "";
|
||||
List<Net_GetCallIceConfig_Response.TurnServerConfig> turnServers = buildTurnServers(ctx, nowMs, ttlSec);
|
||||
|
||||
String sharedSecret = readStr("call.ice.turn.sharedSecret", "");
|
||||
String staticUsername = readStr("call.ice.turn.username", "");
|
||||
String staticPassword = readStr("call.ice.turn.password", "");
|
||||
|
||||
if (!turnUrls.isEmpty()) {
|
||||
if (!turnServers.isEmpty()) {
|
||||
Net_GetCallIceConfig_Response.TurnServerConfig primary = turnServers.get(0);
|
||||
turnUrls = primary.getUrls();
|
||||
turnUsername = primary.getUsername();
|
||||
turnPassword = primary.getPassword();
|
||||
} else if (!turnUrls.isEmpty()) {
|
||||
if (!sharedSecret.isBlank()) {
|
||||
long expiresEpochSec = nowMs / 1000L + ttlSec;
|
||||
expiresAtMs = expiresEpochSec * 1000L;
|
||||
@ -78,6 +84,7 @@ public class Net_GetCallIceConfig_Handler implements JsonMessageHandler {
|
||||
resp.setTurnUrls(turnUrls);
|
||||
resp.setTurnUsername(turnUsername);
|
||||
resp.setTurnPassword(turnPassword);
|
||||
resp.setTurnServers(turnServers);
|
||||
resp.setTurnEnabled(!turnUrls.isEmpty() && !turnUsername.isBlank() && !turnPassword.isBlank());
|
||||
resp.setGeneratedAtMs(nowMs);
|
||||
resp.setExpiresAtMs(expiresAtMs);
|
||||
@ -85,6 +92,40 @@ public class Net_GetCallIceConfig_Handler implements JsonMessageHandler {
|
||||
return resp;
|
||||
}
|
||||
|
||||
private static List<Net_GetCallIceConfig_Response.TurnServerConfig> buildTurnServers(ConnectionContext ctx, long nowMs, int ttlSec) {
|
||||
List<Net_GetCallIceConfig_Response.TurnServerConfig> out = new ArrayList<>();
|
||||
for (int i = 1; i <= 16; i++) {
|
||||
String base = "call.ice.turn.servers." + i + ".";
|
||||
List<String> urls = parseUrls(readStr(base + "urls", ""));
|
||||
if (urls.isEmpty()) continue;
|
||||
|
||||
String id = readStr(base + "id", "turn-" + i);
|
||||
String sharedSecret = readStr(base + "sharedSecret", "");
|
||||
String staticUsername = readStr(base + "username", "");
|
||||
String staticPassword = readStr(base + "password", "");
|
||||
String username = "";
|
||||
String password = "";
|
||||
if (!sharedSecret.isBlank()) {
|
||||
long expiresEpochSec = nowMs / 1000L + ttlSec;
|
||||
String prefix = readStr("call.ice.turn.userPrefix", "shine");
|
||||
String safeLogin = sanitizeLogin(ctx.getLogin());
|
||||
username = expiresEpochSec + ":" + prefix + "_" + safeLogin;
|
||||
password = makeTurnRestPassword(sharedSecret, username);
|
||||
} else if (!staticUsername.isBlank() && !staticPassword.isBlank()) {
|
||||
username = staticUsername;
|
||||
password = staticPassword;
|
||||
}
|
||||
if (username.isBlank() || password.isBlank()) continue;
|
||||
Net_GetCallIceConfig_Response.TurnServerConfig item = new Net_GetCallIceConfig_Response.TurnServerConfig();
|
||||
item.setId(id);
|
||||
item.setUrls(urls);
|
||||
item.setUsername(username);
|
||||
item.setPassword(password);
|
||||
out.add(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static int readInt(String key, int fallback) {
|
||||
String value = CONFIG.getParam(key);
|
||||
if (value == null || value.isBlank()) return fallback;
|
||||
@ -146,4 +187,3 @@ public class Net_GetCallIceConfig_Handler implements JsonMessageHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -6,10 +6,27 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_GetCallIceConfig_Response extends Net_Response {
|
||||
public static class TurnServerConfig {
|
||||
private String id = "";
|
||||
private List<String> urls = new ArrayList<>();
|
||||
private String username = "";
|
||||
private String password = "";
|
||||
|
||||
public String getId() { return id; }
|
||||
public void setId(String id) { this.id = id; }
|
||||
public List<String> getUrls() { return urls; }
|
||||
public void setUrls(List<String> urls) { this.urls = urls; }
|
||||
public String getUsername() { return username; }
|
||||
public void setUsername(String username) { this.username = username; }
|
||||
public String getPassword() { return password; }
|
||||
public void setPassword(String password) { this.password = password; }
|
||||
}
|
||||
|
||||
private List<String> stunUrls = new ArrayList<>();
|
||||
private List<String> turnUrls = new ArrayList<>();
|
||||
private String turnUsername = "";
|
||||
private String turnPassword = "";
|
||||
private List<TurnServerConfig> turnServers = new ArrayList<>();
|
||||
private boolean turnEnabled;
|
||||
private long generatedAtMs;
|
||||
private long expiresAtMs;
|
||||
@ -27,6 +44,9 @@ public class Net_GetCallIceConfig_Response extends Net_Response {
|
||||
public String getTurnPassword() { return turnPassword; }
|
||||
public void setTurnPassword(String turnPassword) { this.turnPassword = turnPassword; }
|
||||
|
||||
public List<TurnServerConfig> getTurnServers() { return turnServers; }
|
||||
public void setTurnServers(List<TurnServerConfig> turnServers) { this.turnServers = turnServers; }
|
||||
|
||||
public boolean isTurnEnabled() { return turnEnabled; }
|
||||
public void setTurnEnabled(boolean turnEnabled) { this.turnEnabled = turnEnabled; }
|
||||
|
||||
@ -39,4 +59,3 @@ public class Net_GetCallIceConfig_Response extends Net_Response {
|
||||
public int getTtlSec() { return ttlSec; }
|
||||
public void setTtlSec(int ttlSec) { this.ttlSec = ttlSec; }
|
||||
}
|
||||
|
||||
|
||||
113
Логика_установки_соединения_через_сервер.md
Normal file
113
Логика_установки_соединения_через_сервер.md
Normal file
@ -0,0 +1,113 @@
|
||||
# Логика установки соединения через сервер
|
||||
|
||||
Ниже описан фактический flow звонка в текущей реализации SHiNE с опорой на сообщения, которые проходят через сервер.
|
||||
|
||||
## 1) Основные операции и типы сообщений
|
||||
|
||||
Клиентские WS-операции:
|
||||
- `CallInviteBroadcast` — широковещательный входящий вызов пользователю.
|
||||
- `CallSignalToSession` — точечный сигнал в конкретную сессию.
|
||||
|
||||
Серверные события клиенту:
|
||||
- `IncomingCallInvite` — уведомление о входящем вызове.
|
||||
- `IncomingCallSignal` — сигнал по активному `callId`.
|
||||
|
||||
Коды `type` в `IncomingCallSignal`:
|
||||
- `100` — `INVITE`
|
||||
- `110` — `RINGING`
|
||||
- `120` — `ACCEPT`
|
||||
- `130` — `DECLINE_BUSY`
|
||||
- `140` — `TIMEOUT`
|
||||
- `150` — `HANGUP`
|
||||
- `200` — `OFFER`
|
||||
- `210` — `ANSWER`
|
||||
- `220` — `ICE`
|
||||
|
||||
---
|
||||
|
||||
## 2) Старт исходящего звонка
|
||||
|
||||
1. Инициатор создаёт `callId` и отправляет:
|
||||
- `CallInviteBroadcast(toLogin, callId, type=100)`.
|
||||
2. Сервер находит все активные WS-сессии целевого логина и шлёт им `IncomingCallInvite`.
|
||||
3. На устройствах callee появляется экран входящего вызова.
|
||||
|
||||
---
|
||||
|
||||
## 3) Ранние статусы до поднятия трубки
|
||||
|
||||
Каждое устройство callee может отправить инициатору:
|
||||
- `RINGING (110)` — «звонок идёт».
|
||||
|
||||
Для исходящего звонка инициатор фиксирует выбранную `remoteSessionId`:
|
||||
- первый валидный `RINGING`/`ACCEPT` выбирает сессию,
|
||||
- сигналы с тем же `callId`, но от других сессий этого же пользователя, игнорируются.
|
||||
|
||||
Это защищает от гонок мультидевайса.
|
||||
|
||||
---
|
||||
|
||||
## 4) Принятие звонка на одном устройстве callee
|
||||
|
||||
1. На выбранном устройстве callee пользователь нажимает «Поднять»:
|
||||
- отправляется `ACCEPT (120)` в выбранную сессию инициатора.
|
||||
2. Сервер дополнительно рассылает на **другие** сессии этого же callee:
|
||||
- `HANGUP (150)` с `data=accepted_on_other_device`.
|
||||
3. Остальные устройства callee закрывают экран входящего вызова.
|
||||
|
||||
Итог: активным остаётся один путь «инициатор ↔ выбранная сессия callee».
|
||||
|
||||
---
|
||||
|
||||
## 5) Обмен SDP (OFFER/ANSWER)
|
||||
|
||||
После `ACCEPT`:
|
||||
|
||||
1. Инициатор формирует `RTCPeerConnection`, `createOffer()`, отправляет:
|
||||
- `OFFER (200)`.
|
||||
2. Calee применяет `setRemoteDescription(offer)`, делает `createAnswer()`, отправляет:
|
||||
- `ANSWER (210)`.
|
||||
3. Инициатор применяет `setRemoteDescription(answer)`.
|
||||
|
||||
Защиты:
|
||||
- повторный `ACCEPT`/повторный старт `offer` игнорируется;
|
||||
- `ANSWER` обрабатывается только для исходящего звонка;
|
||||
- `ANSWER` без локального `offer` или дубликат в `stable` игнорируется.
|
||||
|
||||
---
|
||||
|
||||
## 6) Обмен ICE-кандидатами
|
||||
|
||||
`ICE (220)` может приходить раньше SDP. Поэтому:
|
||||
|
||||
- если `pc` ещё не создан — ICE кладётся в очередь;
|
||||
- если `pc` есть, но `remoteDescription` ещё нет — ICE тоже в очередь;
|
||||
- после установки `remoteDescription` очередь применяется (`addIceCandidate`).
|
||||
|
||||
Это устраняет race «remote description was null».
|
||||
|
||||
---
|
||||
|
||||
## 7) Завершение и ошибки
|
||||
|
||||
Нормальное завершение:
|
||||
- `HANGUP (150)` от одной стороны → вторая завершает звонок.
|
||||
|
||||
Неуспех установки:
|
||||
- сторона, у которой setup не удался, шлёт `HANGUP (150)` с `data=setup_failed:...`,
|
||||
- вторая сторона сразу закрывает экран ожидания.
|
||||
|
||||
Также пишутся `CallDeliveryReport`:
|
||||
- `call_connected`, `incoming_failed`, `outgoing_failed`, `unknown_error` и расширенная диагностика ICE/SDP.
|
||||
|
||||
---
|
||||
|
||||
## 8) Почему схема устойчива сейчас
|
||||
|
||||
Текущая устойчивость обеспечивается тремя правилами:
|
||||
|
||||
1. **Одна выбранная сессия callee** для исходящего звонка.
|
||||
2. **Принятие на одном устройстве закрывает входящий на остальных** через сервер.
|
||||
3. **ICE буферизуется до готовности SDP/PC**, а не ломает handshake.
|
||||
|
||||
Именно комбинация этих трёх пунктов закрывает основные причины «иногда дозванивается, иногда нет» при мультидевайсе.
|
||||
Loading…
Reference in New Issue
Block a user