merge: drygmira/main into main
This commit is contained in:
commit
2830f75f65
@ -4,6 +4,12 @@ plugins {
|
||||
id 'com.github.johnrengelman.shadow' version '8.1.1'
|
||||
}
|
||||
|
||||
allprojects {
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
options.encoding = 'UTF-8'
|
||||
}
|
||||
}
|
||||
|
||||
group = 'shine'
|
||||
version = '1.1_codex'
|
||||
|
||||
|
||||
197
scripts/shine-ui-gateway.mjs
Normal file
197
scripts/shine-ui-gateway.mjs
Normal file
@ -0,0 +1,197 @@
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
const uiRoot = path.resolve(projectRoot, 'shine-UI');
|
||||
|
||||
const listenPort = Number(process.env.SHINE_UI_PORT || 8088);
|
||||
const backendHost = process.env.SHINE_BACKEND_HOST || '127.0.0.1';
|
||||
const backendPort = Number(process.env.SHINE_BACKEND_PORT || 7071);
|
||||
const backendWsPath = process.env.SHINE_BACKEND_WS_PATH || '/ws';
|
||||
|
||||
const textContentTypes = new Set([
|
||||
'.html',
|
||||
'.css',
|
||||
'.js',
|
||||
'.mjs',
|
||||
'.json',
|
||||
'.txt',
|
||||
'.svg',
|
||||
'.xml',
|
||||
'.webmanifest',
|
||||
]);
|
||||
|
||||
const mimeByExt = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.svg': 'image/svg+xml; charset=utf-8',
|
||||
'.xml': 'application/xml; charset=utf-8',
|
||||
'.webmanifest': 'application/manifest+json; charset=utf-8',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
};
|
||||
|
||||
function sanitizePathname(pathname) {
|
||||
const withoutQuery = String(pathname || '').split('?')[0];
|
||||
const decoded = decodeURIComponent(withoutQuery);
|
||||
const normalized = path.posix.normalize(decoded);
|
||||
if (normalized.includes('..')) return null;
|
||||
return normalized.startsWith('/') ? normalized : `/${normalized}`;
|
||||
}
|
||||
|
||||
function toLocalFilePath(pathname) {
|
||||
const safePath = sanitizePathname(pathname);
|
||||
if (!safePath) return null;
|
||||
const target = safePath === '/' ? '/index.html' : safePath;
|
||||
return path.resolve(uiRoot, `.${target}`);
|
||||
}
|
||||
|
||||
function isInsideUiRoot(filePath) {
|
||||
const rel = path.relative(uiRoot, filePath);
|
||||
return rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
async function tryReadFile(filePath) {
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
if (!stat.isFile()) return null;
|
||||
const data = await fs.readFile(filePath);
|
||||
return { data, stat };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCorsAndCacheHeaders(res) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET,OPTIONS');
|
||||
}
|
||||
|
||||
async function handleHttp(req, res) {
|
||||
if (req.method === 'OPTIONS') {
|
||||
writeCorsAndCacheHeaders(res);
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const rawUrl = req.url || '/';
|
||||
const pathname = rawUrl.split('?')[0] || '/';
|
||||
if (pathname.startsWith('/ws')) {
|
||||
writeCorsAndCacheHeaders(res);
|
||||
res.writeHead(426, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify({ ok: false, error: 'upgrade_required', message: 'Use WebSocket upgrade for /ws' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const directPath = toLocalFilePath(pathname);
|
||||
if (!directPath || !isInsideUiRoot(directPath)) {
|
||||
writeCorsAndCacheHeaders(res);
|
||||
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('Bad path');
|
||||
return;
|
||||
}
|
||||
|
||||
let file = await tryReadFile(directPath);
|
||||
if (!file) {
|
||||
const fallback = path.resolve(uiRoot, 'index.html');
|
||||
file = await tryReadFile(fallback);
|
||||
if (!file) {
|
||||
writeCorsAndCacheHeaders(res);
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('index.html not found');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const ext = path.extname(directPath).toLowerCase();
|
||||
const contentType = mimeByExt[ext] || 'application/octet-stream';
|
||||
writeCorsAndCacheHeaders(res);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': file.stat.size,
|
||||
});
|
||||
res.end(file.data);
|
||||
}
|
||||
|
||||
function buildUpstreamUpgradeRequest(req) {
|
||||
const sourceUrl = new URL(req.url || '/ws', `http://${req.headers.host || 'localhost'}`);
|
||||
const targetPath = `${backendWsPath}${sourceUrl.search || ''}`;
|
||||
|
||||
const headers = { ...req.headers };
|
||||
headers.host = `${backendHost}:${backendPort}`;
|
||||
headers.connection = 'Upgrade';
|
||||
headers.upgrade = 'websocket';
|
||||
|
||||
let raw = `GET ${targetPath} HTTP/1.1\r\n`;
|
||||
Object.entries(headers).forEach(([name, value]) => {
|
||||
if (value == null) return;
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((single) => {
|
||||
raw += `${name}: ${single}\r\n`;
|
||||
});
|
||||
return;
|
||||
}
|
||||
raw += `${name}: ${value}\r\n`;
|
||||
});
|
||||
raw += '\r\n';
|
||||
return raw;
|
||||
}
|
||||
|
||||
function handleUpgrade(req, socket, head) {
|
||||
const pathname = String(req.url || '').split('?')[0] || '';
|
||||
if (!pathname.startsWith('/ws')) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const upstream = net.connect(backendPort, backendHost, () => {
|
||||
const upstreamRequest = buildUpstreamUpgradeRequest(req);
|
||||
upstream.write(upstreamRequest);
|
||||
if (head?.length) {
|
||||
upstream.write(head);
|
||||
}
|
||||
socket.pipe(upstream).pipe(socket);
|
||||
});
|
||||
|
||||
const closeBoth = () => {
|
||||
if (!socket.destroyed) socket.destroy();
|
||||
if (!upstream.destroyed) upstream.destroy();
|
||||
};
|
||||
|
||||
upstream.on('error', closeBoth);
|
||||
socket.on('error', closeBoth);
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
handleHttp(req, res).catch(() => {
|
||||
writeCorsAndCacheHeaders(res);
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('Internal gateway error');
|
||||
});
|
||||
});
|
||||
|
||||
server.on('upgrade', handleUpgrade);
|
||||
|
||||
server.listen(listenPort, () => {
|
||||
console.log(`[shine-ui-gateway] uiRoot=${uiRoot}`);
|
||||
console.log(`[shine-ui-gateway] listening=http://localhost:${listenPort}`);
|
||||
console.log(`[shine-ui-gateway] ws proxy=ws://${backendHost}:${backendPort}${backendWsPath}`);
|
||||
});
|
||||
@ -6,7 +6,7 @@
|
||||
<link rel="manifest" href="./manifest.webmanifest" />
|
||||
<title>Shine UI Demo</title>
|
||||
<script>
|
||||
window.__SHINE_BUILD_HASH__ = '20260407120000';
|
||||
window.__SHINE_BUILD_HASH__ = '20260413151200';
|
||||
</script>
|
||||
<script>
|
||||
(function attachStylesWithBuildHash() {
|
||||
|
||||
@ -41,6 +41,7 @@ import * as contactSearchView from './pages/contact-search-view.js';
|
||||
import * as chatView from './pages/chat-view.js';
|
||||
import * as channelsList from './pages/channels-list.js';
|
||||
import * as channelView from './pages/channel-view.js';
|
||||
import * as channelThreadView from './pages/channel-thread-view.js';
|
||||
import * as addChannelView from './pages/add-channel-view.js';
|
||||
import * as networkView from './pages/network-view.js';
|
||||
import * as notificationsView from './pages/notifications-view.js';
|
||||
@ -72,6 +73,7 @@ const routes = {
|
||||
'chat-view': chatView,
|
||||
'channels-list': channelsList,
|
||||
'channel-view': channelView,
|
||||
'channel-thread-view': channelThreadView,
|
||||
'add-channel-view': addChannelView,
|
||||
'network-view': networkView,
|
||||
'notifications-view': notificationsView,
|
||||
@ -141,6 +143,45 @@ window.addEventListener('unhandledrejection', (event) => {
|
||||
});
|
||||
});
|
||||
|
||||
function renderPageFailureFallback(pageId, error) {
|
||||
captureClientError({
|
||||
kind: 'page_render_failure',
|
||||
message: error?.message || 'Page render failed',
|
||||
stack: error?.stack || '',
|
||||
context: {
|
||||
pageId,
|
||||
routeHash: window.location.hash || '',
|
||||
},
|
||||
});
|
||||
|
||||
screenEl.innerHTML = '';
|
||||
const wrap = document.createElement('section');
|
||||
wrap.className = 'stack';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack channels-status';
|
||||
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = 'Не удалось отрисовать экран';
|
||||
|
||||
const details = document.createElement('p');
|
||||
details.className = 'meta-muted';
|
||||
details.textContent = `Экран: ${pageId || 'неизвестно'}. Попробуйте повторить.`;
|
||||
|
||||
const retry = document.createElement('button');
|
||||
retry.type = 'button';
|
||||
retry.className = 'primary-btn';
|
||||
retry.textContent = 'Повторить';
|
||||
retry.addEventListener('click', () => renderApp());
|
||||
|
||||
card.append(title, details, retry);
|
||||
wrap.append(card);
|
||||
screenEl.append(wrap);
|
||||
|
||||
screenEl.classList.toggle('no-app-chrome', false);
|
||||
toolbarEl.innerHTML = '';
|
||||
}
|
||||
|
||||
function renderApp() {
|
||||
const route = getRoute();
|
||||
const pageId = route.pageId || (state.session.isAuthorized ? 'profile-view' : 'start-view');
|
||||
@ -162,8 +203,13 @@ function renderApp() {
|
||||
currentCleanup = null;
|
||||
}
|
||||
|
||||
try {
|
||||
screenEl.innerHTML = '';
|
||||
const screen = page.render({ route, navigate });
|
||||
if (!(screen instanceof Node)) {
|
||||
throw new Error('Page render returned invalid node');
|
||||
}
|
||||
|
||||
screenEl.append(screen);
|
||||
currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
|
||||
|
||||
@ -171,10 +217,13 @@ function renderApp() {
|
||||
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
||||
|
||||
toolbarEl.innerHTML = '';
|
||||
|
||||
if (showAppChrome) {
|
||||
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[renderApp] controlled fallback', error);
|
||||
renderPageFailureFallback(pageId, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function tryAutoLogin() {
|
||||
|
||||
@ -1,38 +1,153 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import {
|
||||
channelNameErrorText,
|
||||
normalizeChannelDescription,
|
||||
normalizeChannelDisplayName,
|
||||
validateChannelDisplayName,
|
||||
} from '../services/channel-name-rules.js';
|
||||
|
||||
export const pageMeta = { id: 'add-channel-view', title: 'Добавить канал' };
|
||||
export const pageMeta = { id: 'add-channel-view', title: 'Создать канал' };
|
||||
|
||||
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
|
||||
|
||||
function persistCreateSuccessFlash(message) {
|
||||
try {
|
||||
sessionStorage.setItem(CREATE_CHANNEL_FLASH_KEY, String(message || '').trim());
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
function validateDescription(value) {
|
||||
const normalized = normalizeChannelDescription(value);
|
||||
const bytes = new TextEncoder().encode(normalized).length;
|
||||
if (bytes > 200) {
|
||||
return { ok: false, normalized, bytes, error: 'Описание слишком длинное: максимум 200 байт UTF-8.' };
|
||||
}
|
||||
return { ok: true, normalized, bytes, error: '' };
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
screen.className = 'stack channels-screen channels-screen--add';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Добавить канал',
|
||||
leftAction: { label: '←', onClick: () => navigate('channels-list') },
|
||||
})
|
||||
title: 'Создать канал',
|
||||
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
||||
}),
|
||||
);
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.className = 'card stack';
|
||||
form.innerHTML = `
|
||||
<label for="channel-name">Имя канала</label>
|
||||
<input id="channel-name" class="input" maxlength="64" placeholder="Например: Новости команды" required />
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
|
||||
<strong class="channel-head-title">Создание канала</strong>
|
||||
<p class="channel-head-meta">Можно использовать кириллицу, латиницу, цифры, пробел, _ и -.</p>
|
||||
<p class="channel-head-meta">Длина названия: от 3 до 32 символов. Название уникально во всей системе.</p>
|
||||
|
||||
<label for="channel-name">Название канала</label>
|
||||
<input id="channel-name" class="input" maxlength="64" placeholder="Например: Поток силы" required />
|
||||
<div id="channel-name-error" class="meta-muted inline-error"></div>
|
||||
|
||||
<label for="channel-description">Описание канала (необязательно)</label>
|
||||
<textarea id="channel-description" class="input" rows="4" maxlength="400" placeholder="Коротко о канале, до 200 байт UTF-8"></textarea>
|
||||
<div class="meta-muted" id="channel-description-counter">0 / 200 байт</div>
|
||||
<div id="channel-description-error" class="meta-muted inline-error"></div>
|
||||
|
||||
<div id="channel-create-error" class="meta-muted inline-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button type="button" class="secondary-btn" id="cancel-create-channel">Отмена</button>
|
||||
<button type="submit" class="primary-btn">Создать</button>
|
||||
<button type="submit" class="primary-btn" id="submit-create-channel">Создать</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
const nameEl = form.querySelector('#channel-name');
|
||||
const descriptionEl = form.querySelector('#channel-description');
|
||||
const nameErrorEl = form.querySelector('#channel-name-error');
|
||||
const descriptionErrorEl = form.querySelector('#channel-description-error');
|
||||
const descriptionCounterEl = form.querySelector('#channel-description-counter');
|
||||
const errorEl = form.querySelector('#channel-create-error');
|
||||
const submitEl = form.querySelector('#submit-create-channel');
|
||||
const cancelEl = form.querySelector('#cancel-create-channel');
|
||||
|
||||
let submitInFlight = false;
|
||||
|
||||
const setBusy = (busy) => {
|
||||
submitInFlight = !!busy;
|
||||
submitEl.disabled = submitInFlight;
|
||||
cancelEl.disabled = submitInFlight;
|
||||
nameEl.disabled = submitInFlight;
|
||||
descriptionEl.disabled = submitInFlight;
|
||||
submitEl.textContent = submitInFlight ? 'Создаём...' : 'Создать';
|
||||
};
|
||||
|
||||
const updateValidation = () => {
|
||||
const nameCheck = validateChannelDisplayName(nameEl.value);
|
||||
const descriptionCheck = validateDescription(descriptionEl.value);
|
||||
|
||||
nameErrorEl.textContent = nameCheck.ok ? '' : channelNameErrorText(nameCheck.code);
|
||||
descriptionErrorEl.textContent = descriptionCheck.error;
|
||||
|
||||
const descLength = Number(descriptionCheck.bytes || 0);
|
||||
descriptionCounterEl.textContent = `${descLength} / 200 байт`;
|
||||
|
||||
const ok = nameCheck.ok && descriptionCheck.ok;
|
||||
submitEl.disabled = submitInFlight || !ok;
|
||||
|
||||
return {
|
||||
ok,
|
||||
name: nameCheck.normalized,
|
||||
description: descriptionCheck.normalized,
|
||||
};
|
||||
};
|
||||
|
||||
nameEl.addEventListener('input', updateValidation);
|
||||
descriptionEl.addEventListener('input', updateValidation);
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
navigate('channels-list');
|
||||
if (submitInFlight) return;
|
||||
|
||||
const login = state.session.login;
|
||||
const storagePwd = state.session.storagePwdInMemory;
|
||||
if (!login || !storagePwd) {
|
||||
errorEl.textContent = 'Сессия недействительна. Выполните вход заново.';
|
||||
return;
|
||||
}
|
||||
|
||||
const check = updateValidation();
|
||||
if (!check.ok) return;
|
||||
|
||||
setBusy(true);
|
||||
errorEl.textContent = '';
|
||||
|
||||
try {
|
||||
const created = await authService.addBlockCreateChannel({
|
||||
login,
|
||||
storagePwd,
|
||||
channelName: normalizeChannelDisplayName(check.name),
|
||||
channelDescription: normalizeChannelDescription(check.description),
|
||||
});
|
||||
|
||||
form.querySelector('#cancel-create-channel').addEventListener('click', () => {
|
||||
const baseMessage = `Канал "${normalizeChannelDisplayName(check.name)}" создан.`;
|
||||
const successMessage = created?.usedLegacyDescriptionFallback && created?.savedDescriptionViaUserParam
|
||||
? `${baseMessage} Описание сохранено через блок параметра.`
|
||||
: baseMessage;
|
||||
persistCreateSuccessFlash(successMessage);
|
||||
navigate('channels-list');
|
||||
} catch (error) {
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось создать канал.');
|
||||
setBusy(false);
|
||||
updateValidation();
|
||||
}
|
||||
});
|
||||
|
||||
cancelEl.addEventListener('click', () => navigate('channels-list'));
|
||||
|
||||
screen.append(form);
|
||||
nameEl.focus();
|
||||
updateValidation();
|
||||
return screen;
|
||||
}
|
||||
|
||||
548
shine-UI/js/pages/channel-thread-view.js
Normal file
548
shine-UI/js/pages/channel-thread-view.js
Normal file
@ -0,0 +1,548 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js';
|
||||
import { captureClientError } from '../services/client-error-reporter.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import {
|
||||
animatePress,
|
||||
createSkeletonCard,
|
||||
showToast,
|
||||
softHaptic,
|
||||
} from '../services/channels-ux.js';
|
||||
|
||||
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
|
||||
|
||||
const pendingReactionActions = new Set();
|
||||
const pendingThreadScroll = new Map();
|
||||
|
||||
function logThreadRuntimeError(stage, error, context = {}) {
|
||||
const message = String(error?.message || error || 'thread runtime error');
|
||||
console.error(`[channel-thread-view:${stage}]`, error, context);
|
||||
captureClientError({
|
||||
kind: 'channels_thread_runtime',
|
||||
message,
|
||||
stack: error?.stack || '',
|
||||
context: { stage, ...context },
|
||||
});
|
||||
}
|
||||
|
||||
function encodeRoutePart(value = '') {
|
||||
return encodeURIComponent(String(value));
|
||||
}
|
||||
|
||||
function normalizeRouteHash(hash) {
|
||||
const normalized = String(hash || '').trim().toLowerCase();
|
||||
return normalized || '0';
|
||||
}
|
||||
|
||||
function normalizeMessageHash(hash) {
|
||||
const normalized = String(hash || '').trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{64}$/.test(normalized)) return '';
|
||||
if (/^0+$/.test(normalized)) return '';
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function toSafeInt(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function makeReactionActionKey(messageRef) {
|
||||
const login = String(state.session.login || '').trim().toLowerCase();
|
||||
const blockchainName = String(messageRef?.blockchainName || '').trim();
|
||||
const blockNumber = Number(messageRef?.blockNumber);
|
||||
const blockHash = normalizeMessageHash(messageRef?.blockHash);
|
||||
if (!login || !blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return '';
|
||||
return `${login}|${blockchainName}|${blockNumber}|${blockHash}`;
|
||||
}
|
||||
|
||||
function messageRefKey(messageRef) {
|
||||
const blockchainName = String(messageRef?.blockchainName || '').trim();
|
||||
const blockNumber = Number(messageRef?.blockNumber);
|
||||
const blockHash = normalizeMessageHash(messageRef?.blockHash);
|
||||
if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return '';
|
||||
return `${blockchainName}:${blockNumber}:${blockHash}`;
|
||||
}
|
||||
|
||||
function parseThreadSelector(route) {
|
||||
const params = route?.params || {};
|
||||
const blockNumber = toSafeInt(params.messageBlockNumber);
|
||||
if (!params.messageBlockchainName || blockNumber == null) return null;
|
||||
|
||||
return {
|
||||
message: {
|
||||
blockchainName: String(params.messageBlockchainName),
|
||||
blockNumber,
|
||||
blockHash: normalizeRouteHash(params.messageBlockHash),
|
||||
},
|
||||
channel: {
|
||||
ownerBlockchainName: String(params.channelOwnerBlockchainName || ''),
|
||||
rootBlockNumber: toSafeInt(params.channelRootBlockNumber),
|
||||
rootBlockHash: normalizeRouteHash(params.channelRootBlockHash),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function allFeedSummaries() {
|
||||
const feed = state.channelsFeed || {};
|
||||
return [
|
||||
...(feed.ownedChannels || []),
|
||||
...(feed.followedUsersChannels || []),
|
||||
...(feed.followedChannels || []),
|
||||
];
|
||||
}
|
||||
|
||||
function resolveChannelDisplayName(channelSelector) {
|
||||
if (!channelSelector?.ownerBlockchainName || channelSelector?.rootBlockNumber == null) return '';
|
||||
const ownerBch = String(channelSelector.ownerBlockchainName);
|
||||
const rootNo = Number(channelSelector.rootBlockNumber);
|
||||
const rootHash = normalizeRouteHash(channelSelector.rootBlockHash);
|
||||
|
||||
const found = allFeedSummaries().find((summary) => (
|
||||
String(summary?.channel?.ownerBlockchainName || '') === ownerBch
|
||||
&& Number(summary?.channel?.channelRoot?.blockNumber) === rootNo
|
||||
&& normalizeRouteHash(summary?.channel?.channelRoot?.blockHash) === rootHash
|
||||
));
|
||||
if (!found) return '';
|
||||
return `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`;
|
||||
}
|
||||
|
||||
function buildBackRoute(selector) {
|
||||
const channel = selector?.channel;
|
||||
if (channel?.ownerBlockchainName && channel.rootBlockNumber != null) {
|
||||
return [
|
||||
'channel-view',
|
||||
encodeRoutePart(channel.ownerBlockchainName),
|
||||
channel.rootBlockNumber,
|
||||
channel.rootBlockHash,
|
||||
].join('/');
|
||||
}
|
||||
return 'channels-list';
|
||||
}
|
||||
|
||||
function buildTargetFromNode(node) {
|
||||
const blockchainName = String(node?.authorBlockchainName || '').trim();
|
||||
const blockNumber = Number(node?.messageRef?.blockNumber);
|
||||
const blockHash = normalizeMessageHash(node?.messageRef?.blockHash);
|
||||
if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return null;
|
||||
return { blockchainName, blockNumber, blockHash };
|
||||
}
|
||||
|
||||
function firstNonEmptyText(...candidates) {
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== 'string') continue;
|
||||
const trimmed = candidate.trim();
|
||||
if (trimmed.length > 0) return candidate;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function latestVersionText(versions) {
|
||||
if (!Array.isArray(versions)) return '';
|
||||
for (let i = versions.length - 1; i >= 0; i -= 1) {
|
||||
const version = versions[i];
|
||||
const value = firstNonEmptyText(version?.text, version?.message, version?.body);
|
||||
if (value) return value;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function resolveNodeText(node) {
|
||||
return firstNonEmptyText(
|
||||
node?.text,
|
||||
node?.message,
|
||||
node?.body,
|
||||
latestVersionText(node?.versions),
|
||||
);
|
||||
}
|
||||
|
||||
function openReplyModal({ onSubmit }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="thread-reply-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">Ответ</h3>
|
||||
<textarea id="thread-reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea>
|
||||
<div class="meta-muted inline-error" id="thread-reply-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="thread-reply-cancel" type="button">Отмена</button>
|
||||
<button class="primary-btn" id="thread-reply-submit" type="button">Отправить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const textEl = root.querySelector('#thread-reply-text');
|
||||
const errorEl = root.querySelector('#thread-reply-error');
|
||||
const submitEl = root.querySelector('#thread-reply-submit');
|
||||
let inFlight = false;
|
||||
|
||||
const setBusy = (busy) => {
|
||||
inFlight = !!busy;
|
||||
submitEl.disabled = inFlight;
|
||||
if (textEl) textEl.disabled = inFlight;
|
||||
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
root.querySelector('#thread-reply-cancel')?.addEventListener('click', close);
|
||||
root.querySelector('#thread-reply-submit')?.addEventListener('click', async () => {
|
||||
if (inFlight) return;
|
||||
|
||||
const text = String(textEl?.value || '').trim();
|
||||
if (!text) {
|
||||
errorEl.textContent = 'Введите текст ответа.';
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
errorEl.textContent = '';
|
||||
|
||||
try {
|
||||
await onSubmit(text);
|
||||
close();
|
||||
} catch (error) {
|
||||
setBusy(false);
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось отправить ответ.');
|
||||
}
|
||||
});
|
||||
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card stack thread-node-card';
|
||||
|
||||
const author = node?.authorLogin || 'автор';
|
||||
const text = resolveNodeText(node) || '(пусто)';
|
||||
const likes = Number(node?.likesCount || 0);
|
||||
const replies = Number(node?.repliesCount || 0);
|
||||
const versions = Number(node?.versionsTotal || 1);
|
||||
|
||||
const headingEl = document.createElement('strong');
|
||||
headingEl.className = 'thread-node-heading';
|
||||
headingEl.textContent = heading;
|
||||
|
||||
const meta = document.createElement('p');
|
||||
meta.className = 'thread-node-meta';
|
||||
meta.innerHTML = `
|
||||
<span class="author-line-login">${author}</span>
|
||||
<span class="author-line-num">· #${localNumber}</span>
|
||||
`;
|
||||
|
||||
const body = document.createElement('p');
|
||||
body.className = 'thread-node-body';
|
||||
body.textContent = text;
|
||||
|
||||
const stats = document.createElement('p');
|
||||
stats.className = 'thread-node-stats';
|
||||
stats.textContent = `Лайки: ${likes}, ответы: ${replies}, версий: ${versions}`;
|
||||
|
||||
card.append(headingEl, meta, body, stats);
|
||||
|
||||
const target = buildTargetFromNode(node);
|
||||
if (!target || !handlers) return card;
|
||||
|
||||
const refKey = messageRefKey(target);
|
||||
if (refKey) card.dataset.messageKey = refKey;
|
||||
|
||||
setMessageReactionState(target, node?.likedByMe === true ? 'liked' : 'unliked');
|
||||
|
||||
const actionKey = makeReactionActionKey(target);
|
||||
const isPending = actionKey ? pendingReactionActions.has(actionKey) : false;
|
||||
|
||||
const isLiked = getMessageReactionState(target) === 'liked';
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'thread-node-actions';
|
||||
|
||||
const likeButton = document.createElement('button');
|
||||
likeButton.type = 'button';
|
||||
likeButton.className = 'secondary-btn thread-like-btn';
|
||||
if (isLiked) likeButton.classList.add('is-liked');
|
||||
likeButton.textContent = isPending ? 'Выполняется...' : (isLiked ? 'Убрать лайк' : 'Лайк');
|
||||
likeButton.disabled = isPending;
|
||||
likeButton.addEventListener('click', async (event) => {
|
||||
animatePress(event.currentTarget);
|
||||
if (isPending) return;
|
||||
likeButton.disabled = true;
|
||||
likeButton.textContent = 'Выполняется...';
|
||||
try {
|
||||
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
|
||||
} catch (error) {
|
||||
logThreadRuntimeError('like_click', error, {
|
||||
action: isLiked ? 'unlike' : 'like',
|
||||
targetBlockchainName: target?.blockchainName || '',
|
||||
targetBlockNumber: target?.blockNumber,
|
||||
});
|
||||
handlers?.onActionError?.(error, isLiked ? 'unlike' : 'like');
|
||||
}
|
||||
});
|
||||
|
||||
const replyButton = document.createElement('button');
|
||||
replyButton.type = 'button';
|
||||
replyButton.className = 'secondary-btn thread-reply-btn';
|
||||
replyButton.textContent = 'Ответить';
|
||||
replyButton.addEventListener('click', (event) => {
|
||||
animatePress(event.currentTarget);
|
||||
openReplyModal({
|
||||
onSubmit: async (textValue) => handlers.onReply(target, textValue),
|
||||
});
|
||||
});
|
||||
|
||||
actions.append(likeButton, replyButton);
|
||||
card.append(actions);
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderDescendants(items, handlers, nextNumber, depth = 0) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'stack';
|
||||
|
||||
const normalized = Array.isArray(items) ? items : [];
|
||||
normalized.forEach((branch, index) => {
|
||||
try {
|
||||
const nodeNumber = nextNumber();
|
||||
const row = renderNodeCard(branch?.node, `Ответ ${index + 1}`, handlers, nodeNumber);
|
||||
row.classList.add('thread-node-level');
|
||||
row.style.setProperty('--depth', String(Math.min(depth, 4)));
|
||||
wrap.append(row);
|
||||
|
||||
if (Array.isArray(branch?.children) && branch.children.length) {
|
||||
wrap.append(renderDescendants(branch.children, handlers, nextNumber, depth + 1));
|
||||
}
|
||||
} catch (error) {
|
||||
logThreadRuntimeError('render_descendants_branch', error, { depth, index });
|
||||
}
|
||||
});
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function applyPendingScroll(screen, routeKey) {
|
||||
const target = pendingThreadScroll.get(routeKey);
|
||||
if (!target) return;
|
||||
|
||||
const doScroll = () => {
|
||||
if (target === '__LAST_REPLY__') {
|
||||
const cards = screen.querySelectorAll('.thread-block--replies [data-message-key]');
|
||||
const last = cards[cards.length - 1];
|
||||
if (last) {
|
||||
last.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
pendingThreadScroll.delete(routeKey);
|
||||
return;
|
||||
}
|
||||
|
||||
const node = screen.querySelector(`[data-message-key="${target}"]`);
|
||||
if (node) {
|
||||
node.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
pendingThreadScroll.delete(routeKey);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(doScroll, 20);
|
||||
}
|
||||
|
||||
function renderSkeleton(screen) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'stack';
|
||||
wrap.append(createSkeletonCard(), createSkeletonCard(), createSkeletonCard());
|
||||
screen.append(wrap);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function render({ navigate, route }) {
|
||||
const selector = parseThreadSelector(route);
|
||||
const backRoute = buildBackRoute(selector);
|
||||
const channelDisplayName = resolveChannelDisplayName(selector?.channel);
|
||||
const routeKey = `${selector?.message?.blockchainName || ''}:${selector?.message?.blockNumber || ''}:${selector?.message?.blockHash || ''}`;
|
||||
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--thread';
|
||||
const appScreen = document.getElementById('app-screen');
|
||||
appScreen?.classList.add('channels-scroll-clean');
|
||||
|
||||
const userIndicator = document.createElement('div');
|
||||
userIndicator.className = 'card channels-user-chip';
|
||||
userIndicator.textContent = `Вы вошли как @${state.session.login || 'неизвестно'}`;
|
||||
|
||||
const channelIndicator = document.createElement('div');
|
||||
channelIndicator.className = 'card channels-user-chip';
|
||||
channelIndicator.textContent = `Канал: ${channelDisplayName || selector?.channel?.ownerBlockchainName || 'неизвестно'}`;
|
||||
|
||||
const statusBox = document.createElement('div');
|
||||
statusBox.className = 'card status-line is-unavailable channels-status';
|
||||
statusBox.style.display = 'none';
|
||||
|
||||
const rerender = () => {
|
||||
try {
|
||||
const current = document.querySelector('section.channels-screen--thread');
|
||||
if (!current) return;
|
||||
const next = render({ navigate, route });
|
||||
current.replaceWith(next);
|
||||
} catch (error) {
|
||||
logThreadRuntimeError('rerender', error, { routeHash: window.location.hash });
|
||||
}
|
||||
};
|
||||
|
||||
const showStatus = (message) => {
|
||||
if (!message) {
|
||||
statusBox.style.display = 'none';
|
||||
statusBox.textContent = '';
|
||||
return;
|
||||
}
|
||||
statusBox.textContent = message;
|
||||
statusBox.style.display = '';
|
||||
};
|
||||
|
||||
const requireSigningSession = () => {
|
||||
const login = state.session.login;
|
||||
const storagePwd = state.session.storagePwdInMemory;
|
||||
if (!login || !storagePwd) throw new Error('Сессия недействительна. Выполните вход заново.');
|
||||
return { login, storagePwd };
|
||||
};
|
||||
|
||||
const handlers = {
|
||||
onToggleLike: async (target, action) => {
|
||||
const actionKey = makeReactionActionKey(target);
|
||||
if (!actionKey) throw new Error('Некорректная ссылка на сообщение для реакции.');
|
||||
if (pendingReactionActions.has(actionKey)) return;
|
||||
|
||||
const previousReaction = getMessageReactionState(target);
|
||||
const nextReaction = action === 'unlike' ? 'unliked' : 'liked';
|
||||
|
||||
pendingReactionActions.add(actionKey);
|
||||
try {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
if (action === 'unlike') {
|
||||
await authService.addBlockUnlike({ login, storagePwd, message: target });
|
||||
} else {
|
||||
await authService.addBlockLike({ login, storagePwd, message: target });
|
||||
}
|
||||
|
||||
setMessageReactionState(target, nextReaction);
|
||||
softHaptic(10);
|
||||
rerender();
|
||||
} catch (error) {
|
||||
setMessageReactionState(target, previousReaction || 'unliked');
|
||||
rerender();
|
||||
throw error;
|
||||
} finally {
|
||||
pendingReactionActions.delete(actionKey);
|
||||
}
|
||||
},
|
||||
onReply: async (target, textValue) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
await authService.addBlockReply({ login, storagePwd, message: target, text: textValue });
|
||||
pendingThreadScroll.set(routeKey, '__LAST_REPLY__');
|
||||
softHaptic(15);
|
||||
showToast('Ответ отправлен');
|
||||
showStatus('');
|
||||
rerender();
|
||||
},
|
||||
onActionError: (error, action) => {
|
||||
const fallback = action === 'unlike'
|
||||
? 'Не удалось убрать лайк.'
|
||||
: 'Не удалось поставить лайк.';
|
||||
showStatus(toUserMessage(error, fallback));
|
||||
},
|
||||
};
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Тред',
|
||||
leftAction: { label: '<', onClick: () => navigate(backRoute) },
|
||||
}),
|
||||
);
|
||||
screen.append(userIndicator, channelIndicator, statusBox);
|
||||
|
||||
if (!selector) {
|
||||
const invalid = document.createElement('div');
|
||||
invalid.className = 'card meta-muted';
|
||||
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
|
||||
screen.append(invalid);
|
||||
return screen;
|
||||
}
|
||||
|
||||
const skeleton = renderSkeleton(screen);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const payload = await authService.getMessageThread(selector.message, 20, 2, 50, state.session.login);
|
||||
skeleton.remove();
|
||||
|
||||
const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : [];
|
||||
const focus = payload?.focus || null;
|
||||
const descendants = Array.isArray(payload?.descendants) ? payload.descendants : [];
|
||||
|
||||
const summary = document.createElement('div');
|
||||
summary.className = 'card thread-summary';
|
||||
summary.textContent = `Предки: ${ancestors.length}, ответы: ${descendants.length}`;
|
||||
screen.append(summary);
|
||||
|
||||
let seq = 0;
|
||||
const nextNumber = () => {
|
||||
seq += 1;
|
||||
return seq;
|
||||
};
|
||||
|
||||
if (ancestors.length) {
|
||||
const ancestorsWrap = document.createElement('div');
|
||||
ancestorsWrap.className = 'stack thread-block thread-block--ancestors';
|
||||
const title = document.createElement('h3');
|
||||
title.className = 'section-title';
|
||||
title.textContent = 'Предыдущие сообщения';
|
||||
ancestorsWrap.append(title);
|
||||
ancestors.forEach((node, index) => {
|
||||
ancestorsWrap.append(renderNodeCard(node, `Предок ${index + 1}`, handlers, nextNumber()));
|
||||
});
|
||||
screen.append(ancestorsWrap);
|
||||
}
|
||||
|
||||
if (focus) {
|
||||
const focusWrap = document.createElement('div');
|
||||
focusWrap.className = 'stack thread-block thread-block--focus';
|
||||
const title = document.createElement('h3');
|
||||
title.className = 'section-title';
|
||||
title.textContent = 'Текущее сообщение';
|
||||
focusWrap.append(title, renderNodeCard(focus, 'Выбранное сообщение', handlers, nextNumber()));
|
||||
screen.append(focusWrap);
|
||||
}
|
||||
|
||||
const descendantsWrap = document.createElement('div');
|
||||
descendantsWrap.className = 'stack thread-block thread-block--replies';
|
||||
const descendantsTitle = document.createElement('h3');
|
||||
descendantsTitle.className = 'section-title';
|
||||
descendantsTitle.textContent = 'Ответы';
|
||||
descendantsWrap.append(descendantsTitle);
|
||||
|
||||
if (descendants.length) {
|
||||
descendantsWrap.append(renderDescendants(descendants, handlers, nextNumber));
|
||||
} else {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.textContent = 'Ответов пока нет.';
|
||||
descendantsWrap.append(empty);
|
||||
}
|
||||
|
||||
screen.append(descendantsWrap);
|
||||
applyPendingScroll(screen, routeKey);
|
||||
} catch (error) {
|
||||
skeleton.remove();
|
||||
const failed = document.createElement('div');
|
||||
failed.className = 'card meta-muted';
|
||||
failed.textContent = `Не удалось загрузить тред: ${toUserMessage(error, 'неизвестная ошибка')}`;
|
||||
screen.append(failed);
|
||||
}
|
||||
})();
|
||||
|
||||
screen.cleanup = () => {
|
||||
appScreen?.classList.remove('channels-scroll-clean');
|
||||
};
|
||||
|
||||
return screen;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -6,6 +6,7 @@ import {
|
||||
setAuthError,
|
||||
state,
|
||||
} from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
|
||||
export const pageMeta = { id: 'login-password-view', title: 'Войти по логину', showAppChrome: false };
|
||||
|
||||
@ -32,7 +33,11 @@ export function render({ navigate }) {
|
||||
|
||||
const hint = document.createElement('p');
|
||||
hint.className = 'meta-muted';
|
||||
hint.textContent = 'Root/dev/bch ключи вычисляются из пароля через SHA-256, storagePwd каждый вход приходит с сервера.';
|
||||
hint.textContent = 'Введите логин и пароль. На следующем шаге сохраните ключи на устройстве.';
|
||||
|
||||
const status = document.createElement('p');
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.style.display = 'none';
|
||||
|
||||
form.innerHTML = `
|
||||
<label class="stack"><span class="field-label">Логин</span></label>
|
||||
@ -40,7 +45,7 @@ export function render({ navigate }) {
|
||||
`;
|
||||
form.children[0].append(loginInput);
|
||||
form.children[1].append(passwordInput);
|
||||
form.append(hint);
|
||||
form.append(hint, status);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
@ -56,11 +61,13 @@ export function render({ navigate }) {
|
||||
enterButton.type = 'button';
|
||||
enterButton.textContent = 'Войти';
|
||||
enterButton.addEventListener('click', async () => {
|
||||
status.style.display = 'none';
|
||||
state.loginDraft.login = loginInput.value.trim();
|
||||
state.loginDraft.password = passwordInput.value;
|
||||
|
||||
if (!state.loginDraft.login || !state.loginDraft.password) {
|
||||
window.alert('Введите логин и пароль');
|
||||
status.textContent = 'Введите логин и пароль.';
|
||||
status.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
@ -81,8 +88,10 @@ export function render({ navigate }) {
|
||||
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
|
||||
navigate('registration-keys-view');
|
||||
} catch (error) {
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
const message = toUserMessage(error, 'Не удалось выполнить вход.');
|
||||
setAuthError(message);
|
||||
status.textContent = message;
|
||||
status.style.display = '';
|
||||
} finally {
|
||||
setAuthBusy(false);
|
||||
enterButton.disabled = false;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { authService, clearAuthMessages, state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
|
||||
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
|
||||
|
||||
@ -28,6 +29,10 @@ export function render({ navigate }) {
|
||||
statusText.className = 'meta-muted';
|
||||
statusText.textContent = 'Проверка логина: не выполнена';
|
||||
|
||||
const formError = document.createElement('p');
|
||||
formError.className = 'status-line is-unavailable';
|
||||
formError.style.display = 'none';
|
||||
|
||||
const checkButton = document.createElement('button');
|
||||
checkButton.className = 'ghost-btn';
|
||||
checkButton.type = 'button';
|
||||
@ -37,6 +42,7 @@ export function render({ navigate }) {
|
||||
const login = loginInput.value.trim();
|
||||
if (!login) {
|
||||
statusText.textContent = 'Введите логин';
|
||||
formError.style.display = 'none';
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -47,9 +53,10 @@ export function render({ navigate }) {
|
||||
const isFree = await authService.ensureLoginFree(login);
|
||||
statusText.textContent = isFree ? 'Логин свободен ✅' : 'Логин уже занят ❌';
|
||||
statusText.className = isFree ? 'is-available' : 'is-unavailable';
|
||||
formError.style.display = 'none';
|
||||
return isFree;
|
||||
} catch (error) {
|
||||
statusText.textContent = error.message;
|
||||
statusText.textContent = toUserMessage(error, 'Не удалось проверить логин');
|
||||
statusText.className = 'is-unavailable';
|
||||
return false;
|
||||
} finally {
|
||||
@ -66,7 +73,7 @@ export function render({ navigate }) {
|
||||
`;
|
||||
form.children[0].append(loginInput);
|
||||
form.children[1].append(passwordInput);
|
||||
form.append(checkButton, statusText);
|
||||
form.append(checkButton, statusText, formError);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
@ -82,9 +89,9 @@ export function render({ navigate }) {
|
||||
nextButton.type = 'button';
|
||||
nextButton.textContent = 'Далее';
|
||||
nextButton.addEventListener('click', async () => {
|
||||
formError.style.display = 'none';
|
||||
const isFree = await runAvailabilityCheck();
|
||||
if (!isFree) {
|
||||
window.alert('Выберите свободный логин');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -92,7 +99,8 @@ export function render({ navigate }) {
|
||||
state.registrationDraft.password = passwordInput.value;
|
||||
|
||||
if (!state.registrationDraft.password) {
|
||||
window.alert('Введите пароль');
|
||||
formError.textContent = 'Введите пароль.';
|
||||
formError.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
setAuthInfo,
|
||||
state,
|
||||
} from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
|
||||
export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false };
|
||||
|
||||
@ -31,6 +32,14 @@ export function render({ navigate }) {
|
||||
question.className = 'auth-copy';
|
||||
question.textContent = 'Какие ключи сохранить в зашифрованном контейнере IndexedDB?';
|
||||
|
||||
const nextStep = document.createElement('p');
|
||||
nextStep.className = 'meta-muted';
|
||||
nextStep.textContent = 'После сохранения откроется профиль. Для проверки откройте вкладку «Каналы».';
|
||||
|
||||
const status = document.createElement('p');
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.style.display = 'none';
|
||||
|
||||
const rootToggle = document.createElement('input');
|
||||
rootToggle.type = 'checkbox';
|
||||
rootToggle.checked = state.keyStorage.saveRoot;
|
||||
@ -46,17 +55,17 @@ export function render({ navigate }) {
|
||||
|
||||
const rootRow = document.createElement('label');
|
||||
rootRow.className = 'checkbox-row';
|
||||
rootRow.append(rootToggle, document.createTextNode('root key'));
|
||||
rootRow.append(rootToggle, document.createTextNode('Ключ root'));
|
||||
|
||||
const blockchainRow = document.createElement('label');
|
||||
blockchainRow.className = 'checkbox-row';
|
||||
blockchainRow.append(blockchainToggle, document.createTextNode('blockchain.key'));
|
||||
blockchainRow.append(blockchainToggle, document.createTextNode('Ключ blockchain'));
|
||||
|
||||
const deviceRow = document.createElement('label');
|
||||
deviceRow.className = 'checkbox-row';
|
||||
deviceRow.append(deviceToggle, document.createTextNode('device key (всегда)'));
|
||||
deviceRow.append(deviceToggle, document.createTextNode('Ключ device (всегда)'));
|
||||
|
||||
card.append(title, question, rootRow, blockchainRow, deviceRow);
|
||||
card.append(title, question, nextStep, rootRow, blockchainRow, deviceRow, status);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
@ -72,6 +81,7 @@ export function render({ navigate }) {
|
||||
okButton.type = 'button';
|
||||
okButton.textContent = 'OK';
|
||||
okButton.addEventListener('click', async () => {
|
||||
status.style.display = 'none';
|
||||
try {
|
||||
if (!state.registrationDraft.pendingKeyBundle || !state.registrationDraft.pendingSessionMaterial) {
|
||||
throw new Error('Сначала завершите шаг регистрации на предыдущем экране');
|
||||
@ -117,11 +127,15 @@ export function render({ navigate }) {
|
||||
state.registrationDraft.pendingSessionMaterial = null;
|
||||
|
||||
await refreshSessions();
|
||||
setAuthInfo(isLoginFlow ? 'Ключи сохранены, вход завершён.' : 'Ключи сохранены, регистрация завершена.');
|
||||
setAuthInfo(isLoginFlow
|
||||
? `Ключи сохранены. Вы вошли как @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`
|
||||
: `Ключи сохранены. Регистрация завершена для @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`);
|
||||
navigate('profile-view');
|
||||
} catch (error) {
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
const message = toUserMessage(error, 'Не удалось сохранить ключи на устройстве.');
|
||||
setAuthError(message);
|
||||
status.textContent = message;
|
||||
status.style.display = '';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import {
|
||||
authService,
|
||||
refreshRegistrationBalance,
|
||||
@ -6,9 +6,25 @@ import {
|
||||
setAuthInfo,
|
||||
state,
|
||||
} from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
|
||||
export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false };
|
||||
|
||||
const MIN_REGISTER_BALANCE_SOL = 0.01;
|
||||
|
||||
function parseBalanceSol(value) {
|
||||
const parsed = Number.parseFloat(String(value || '').replace(',', '.'));
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function getCryptoRuntimeState() {
|
||||
const hasCrypto = Boolean(globalThis.crypto);
|
||||
const hasGetRandomValues = Boolean(globalThis.crypto && typeof globalThis.crypto.getRandomValues === 'function');
|
||||
const hasSubtle = Boolean(globalThis.crypto && (globalThis.crypto.subtle || globalThis.crypto.webkitSubtle));
|
||||
const secureContext = window.isSecureContext === true;
|
||||
return { hasCrypto, hasGetRandomValues, hasSubtle, secureContext };
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
@ -16,6 +32,10 @@ export function render({ navigate }) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
|
||||
const status = document.createElement('p');
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.style.display = 'none';
|
||||
|
||||
const walletValue = document.createElement('input');
|
||||
walletValue.className = 'input';
|
||||
walletValue.type = 'text';
|
||||
@ -39,7 +59,9 @@ export function render({ navigate }) {
|
||||
copyButton.textContent = 'Скопировать номер';
|
||||
}, 1500);
|
||||
} catch {
|
||||
window.alert('Не удалось скопировать номер кошелька.');
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.textContent = 'Не удалось скопировать номер кошелька.';
|
||||
status.style.display = '';
|
||||
}
|
||||
});
|
||||
|
||||
@ -73,6 +95,24 @@ export function render({ navigate }) {
|
||||
submitButton.type = 'button';
|
||||
submitButton.textContent = 'Зарегистрироваться';
|
||||
submitButton.addEventListener('click', async () => {
|
||||
status.style.display = 'none';
|
||||
|
||||
const balanceSol = parseBalanceSol(state.registrationPayment.balanceSOL);
|
||||
if (balanceSol < MIN_REGISTER_BALANCE_SOL) {
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.textContent = `Недостаточный баланс для регистрации: ${state.registrationPayment.balanceSOL} SOL. Нужно минимум ${MIN_REGISTER_BALANCE_SOL.toFixed(2)} SOL.`;
|
||||
status.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const cryptoState = getCryptoRuntimeState();
|
||||
if (!cryptoState.hasCrypto || !cryptoState.hasGetRandomValues || !cryptoState.hasSubtle) {
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.textContent = 'Криптография браузера недоступна. Откройте приложение через HTTPS tunnel или localhost и повторите регистрацию.';
|
||||
status.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = 'Регистрация...';
|
||||
@ -85,12 +125,14 @@ export function render({ navigate }) {
|
||||
state.registrationDraft.pendingKeyBundle = result.keyBundle;
|
||||
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
|
||||
|
||||
setAuthInfo(`Отлично, вы зарегистрировались: ${result.login}`);
|
||||
window.alert('Отлично, вы зарегистрировались');
|
||||
setAuthInfo(`Регистрация завершена. Вы вошли как @${result.login}. Далее откройте вкладку «Каналы».`);
|
||||
navigate('registration-keys-view');
|
||||
} catch (error) {
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
const message = toUserMessage(error, 'Не удалось завершить регистрацию.');
|
||||
setAuthError(message);
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.textContent = message;
|
||||
status.style.display = '';
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = 'Зарегистрироваться';
|
||||
@ -106,7 +148,7 @@ export function render({ navigate }) {
|
||||
`;
|
||||
card.children[1].append(walletRow);
|
||||
card.children[2].append(balanceRow);
|
||||
card.append(topupButton, submitButton);
|
||||
card.append(topupButton, submitButton, status);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { clearStartHint, state } from '../state.js';
|
||||
import { clearStartHint } from '../state.js';
|
||||
|
||||
export const pageMeta = { id: 'start-view', title: 'Старт', showAppChrome: false };
|
||||
|
||||
export function render({ navigate }) {
|
||||
clearStartHint();
|
||||
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'auth-screen stack';
|
||||
|
||||
@ -37,17 +39,6 @@ export function render({ navigate }) {
|
||||
settingsButton.addEventListener('click', () => navigate('entry-settings-view'));
|
||||
|
||||
actions.append(loginButton, registerButton, settingsButton);
|
||||
|
||||
screen.append(logo, title);
|
||||
|
||||
if (state.startHint) {
|
||||
const notice = document.createElement('div');
|
||||
notice.className = 'card auth-status-card';
|
||||
notice.textContent = state.startHint;
|
||||
screen.append(notice);
|
||||
clearStartHint();
|
||||
}
|
||||
|
||||
screen.append(actions);
|
||||
screen.append(logo, title, actions);
|
||||
return screen;
|
||||
}
|
||||
|
||||
@ -19,16 +19,51 @@ export function getRoute() {
|
||||
return { pageId: '', params: {} };
|
||||
}
|
||||
|
||||
const [pageId, dynamicId] = raw.split('/');
|
||||
const segments = raw.split('/').filter(Boolean);
|
||||
const pageId = segments[0] || '';
|
||||
const dynamicId = segments[1] || '';
|
||||
|
||||
const decodePart = (value) => {
|
||||
try {
|
||||
return decodeURIComponent(value || '');
|
||||
} catch {
|
||||
return value || '';
|
||||
}
|
||||
};
|
||||
|
||||
if (pageId === 'chat-view') {
|
||||
return { pageId, params: { chatId: dynamicId || '' } };
|
||||
}
|
||||
|
||||
if (pageId === 'channel-view') {
|
||||
if (segments.length >= 4) {
|
||||
return {
|
||||
pageId,
|
||||
params: {
|
||||
ownerBlockchainName: decodePart(segments[1]),
|
||||
channelRootBlockNumber: segments[2] || '',
|
||||
channelRootBlockHash: segments[3] || '',
|
||||
channelId: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
return { pageId, params: { channelId: dynamicId || '' } };
|
||||
}
|
||||
|
||||
if (pageId === 'channel-thread-view') {
|
||||
return {
|
||||
pageId,
|
||||
params: {
|
||||
messageBlockchainName: decodePart(segments[1]),
|
||||
messageBlockNumber: segments[2] || '',
|
||||
messageBlockHash: segments[3] || '',
|
||||
channelOwnerBlockchainName: decodePart(segments[4]),
|
||||
channelRootBlockNumber: segments[5] || '',
|
||||
channelRootBlockHash: segments[6] || '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (pageId === 'device-session-view') {
|
||||
return { pageId, params: { sessionId: dynamicId || '' } };
|
||||
}
|
||||
@ -57,6 +92,6 @@ export function resolveToolbarActive(pageId) {
|
||||
return 'profile-view';
|
||||
}
|
||||
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
|
||||
if (pageId === 'channel-view' || pageId === 'add-channel-view') return 'channels-list';
|
||||
if (pageId === 'channel-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view') return 'channels-list';
|
||||
return 'profile-view';
|
||||
}
|
||||
|
||||
@ -12,6 +12,12 @@ import {
|
||||
signBase64,
|
||||
utf8Bytes,
|
||||
} from './crypto-utils.js';
|
||||
import {
|
||||
channelNameErrorText,
|
||||
normalizeChannelDisplayName,
|
||||
toCanonicalChannelSlug,
|
||||
validateChannelDisplayName,
|
||||
} from './channel-name-rules.js';
|
||||
import {
|
||||
loadEncryptedUserSecrets,
|
||||
loadSessionMaterial,
|
||||
@ -20,20 +26,51 @@ import {
|
||||
} from './key-vault.js';
|
||||
|
||||
const BCH_SUFFIX = '001';
|
||||
const ZERO64 = '0'.repeat(64);
|
||||
|
||||
const MSG_TYPE_TECH = 0;
|
||||
const MSG_TYPE_TEXT = 1;
|
||||
const MSG_TYPE_REACTION = 2;
|
||||
const MSG_TYPE_CONNECTION = 3;
|
||||
|
||||
const MSG_SUBTYPE_TECH_CREATE_CHANNEL = 1;
|
||||
const MSG_SUBTYPE_TEXT_POST = 10;
|
||||
const MSG_SUBTYPE_TEXT_REPLY = 20;
|
||||
const MSG_SUBTYPE_REACTION_LIKE = 1;
|
||||
const MSG_SUBTYPE_REACTION_UNLIKE = 2;
|
||||
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
|
||||
const MSG_SUBTYPE_CONNECTION_UNFOLLOW = 31;
|
||||
const CREATE_CHANNEL_BODY_VERSION = 2;
|
||||
|
||||
function normalizeServerUrl(url) {
|
||||
const value = (url || '').trim();
|
||||
if (!value) return 'wss://shineup.me/ws';
|
||||
if (value.startsWith('ws://') || value.startsWith('wss://')) return value;
|
||||
if (value.startsWith('ws://') || value.startsWith('wss://')) {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
if (value.startsWith('https://') || value.startsWith('http://')) {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return `${value.replace(/^http/, 'ws').replace(/\/$/, '')}/ws`;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function opError(op, response) {
|
||||
const message = response?.payload?.message || response?.message || 'Неизвестная ошибка сервера';
|
||||
const code = response?.payload?.code || response?.code || 'UNKNOWN';
|
||||
const payload = response?.payload || {};
|
||||
const message = payload?.message || response?.message || payload?.error || response?.error || 'Unknown server error';
|
||||
const code = String(payload?.code || response?.code || payload?.error || response?.error || 'UNKNOWN').toUpperCase();
|
||||
const error = new Error(`${op}: ${message} (${code})`);
|
||||
error.op = op;
|
||||
error.code = code;
|
||||
@ -41,6 +78,27 @@ function opError(op, response) {
|
||||
return error;
|
||||
}
|
||||
|
||||
function isLegacyCreateChannelFormatError(error) {
|
||||
const code = String(error?.code || '').trim().toUpperCase();
|
||||
const text = String(error?.message || '').toLowerCase();
|
||||
if (code === 'BAD_BLOCK_FORMAT') return true;
|
||||
return (
|
||||
text.includes('unknown body type/version') ||
|
||||
text.includes('unknown tech body type/version/subtype') ||
|
||||
text.includes('bad_block_format')
|
||||
);
|
||||
}
|
||||
|
||||
function channelDescriptionParamKeyFromSelector(selector) {
|
||||
const owner = String(selector?.ownerBlockchainName || '').trim();
|
||||
const rootNo = Number(selector?.channelRootBlockNumber);
|
||||
const rootHash = String(selector?.channelRootBlockHash || '').trim().toLowerCase();
|
||||
if (!owner || !Number.isFinite(rootNo) || rootNo < 0 || !/^[0-9a-f]{64}$/.test(rootHash)) {
|
||||
return '';
|
||||
}
|
||||
return `channel_desc:${owner}:${rootNo}:${rootHash}`;
|
||||
}
|
||||
|
||||
function makeClientInfo() {
|
||||
const ua = navigator.userAgent || 'unknown';
|
||||
return ua.slice(0, 50);
|
||||
@ -51,11 +109,21 @@ function hexToBytes(hex) {
|
||||
if (!clean || clean.length % 2 !== 0) throw new Error('Некорректный hex');
|
||||
const out = new Uint8Array(clean.length / 2);
|
||||
for (let i = 0; i < out.length; i += 1) {
|
||||
out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
||||
const byte = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
||||
if (Number.isNaN(byte)) throw new Error('Некорректный hex');
|
||||
out[i] = byte;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeHex32(value, fallback = ZERO64) {
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
if (!raw) return fallback;
|
||||
if (/^0+$/.test(raw)) return ZERO64;
|
||||
if (!/^[0-9a-f]{64}$/.test(raw)) throw new Error('Bad hash32 format');
|
||||
return raw;
|
||||
}
|
||||
|
||||
function concatBytes(...chunks) {
|
||||
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
@ -81,6 +149,12 @@ function int16Bytes(value) {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function int8Byte(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n < 0 || n > 255) throw new Error('Bad uint8 value');
|
||||
return new Uint8Array([n & 0xff]);
|
||||
}
|
||||
|
||||
function int64Bytes(value) {
|
||||
const bytes = new Uint8Array(8);
|
||||
const view = new DataView(bytes.buffer);
|
||||
@ -107,10 +181,223 @@ function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thi
|
||||
);
|
||||
}
|
||||
|
||||
function makeReactionLikeBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHex }) {
|
||||
const cleanBch = String(toBlockchainName || '').trim();
|
||||
if (!cleanBch) throw new Error('toBlockchainName is required for like');
|
||||
|
||||
const blockNumber = Number(toBlockNumber);
|
||||
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
|
||||
throw new Error('Invalid toBlockNumber for like');
|
||||
}
|
||||
|
||||
const bchBytes = utf8Bytes(cleanBch);
|
||||
if (bchBytes.length < 1 || bchBytes.length > 255) {
|
||||
throw new Error('toBlockchainName must be 1..255 bytes');
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
int8Byte(bchBytes.length),
|
||||
bchBytes,
|
||||
int32Bytes(blockNumber),
|
||||
hexToBytes(normalizeHex32(toBlockHashHex))
|
||||
);
|
||||
}
|
||||
|
||||
function makeTextReplyBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHex, text }) {
|
||||
const cleanBch = String(toBlockchainName || '').trim();
|
||||
if (!cleanBch) throw new Error('toBlockchainName is required for reply');
|
||||
|
||||
const blockNumber = Number(toBlockNumber);
|
||||
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
|
||||
throw new Error('Invalid toBlockNumber for reply');
|
||||
}
|
||||
|
||||
const message = String(text || '').trim();
|
||||
if (!message) throw new Error('Reply text is required');
|
||||
|
||||
const bchBytes = utf8Bytes(cleanBch);
|
||||
if (bchBytes.length < 1 || bchBytes.length > 255) {
|
||||
throw new Error('toBlockchainName must be 1..255 bytes');
|
||||
}
|
||||
|
||||
const textBytes = utf8Bytes(message);
|
||||
if (textBytes.length < 1 || textBytes.length > 65535) {
|
||||
throw new Error('Reply text must be 1..65535 UTF-8 bytes');
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
int8Byte(bchBytes.length),
|
||||
bchBytes,
|
||||
int32Bytes(blockNumber),
|
||||
hexToBytes(normalizeHex32(toBlockHashHex)),
|
||||
int16Bytes(textBytes.length),
|
||||
textBytes
|
||||
);
|
||||
}
|
||||
|
||||
function makeConnectionBodyBytes({
|
||||
lineCode = 0,
|
||||
prevLineNumber = -1,
|
||||
prevLineHashHex = ZERO64,
|
||||
thisLineNumber = -1,
|
||||
toBlockchainName,
|
||||
toBlockNumber,
|
||||
toBlockHashHex,
|
||||
}) {
|
||||
const cleanBch = String(toBlockchainName || '').trim();
|
||||
if (!cleanBch) throw new Error('toBlockchainName is required for connection');
|
||||
|
||||
const blockNumber = Number(toBlockNumber);
|
||||
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
|
||||
throw new Error('Invalid toBlockNumber for connection');
|
||||
}
|
||||
|
||||
const bchBytes = utf8Bytes(cleanBch);
|
||||
if (bchBytes.length < 1 || bchBytes.length > 255) {
|
||||
throw new Error('toBlockchainName must be 1..255 bytes');
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
int32Bytes(lineCode),
|
||||
int32Bytes(prevLineNumber),
|
||||
hexToBytes(normalizeHex32(prevLineHashHex)),
|
||||
int32Bytes(thisLineNumber),
|
||||
int8Byte(bchBytes.length),
|
||||
bchBytes,
|
||||
int32Bytes(blockNumber),
|
||||
hexToBytes(normalizeHex32(toBlockHashHex))
|
||||
);
|
||||
}
|
||||
|
||||
function makeCreateChannelBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, channelName }) {
|
||||
const check = validateChannelDisplayName(channelName);
|
||||
if (!check.ok) throw new Error(channelNameErrorText(check.code));
|
||||
const cleanName = check.normalized;
|
||||
|
||||
const nameBytes = utf8Bytes(cleanName);
|
||||
if (nameBytes.length < 1 || nameBytes.length > 255) {
|
||||
throw new Error('Channel name must be 1..255 bytes');
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
int32Bytes(lineCode),
|
||||
int32Bytes(prevLineNumber),
|
||||
hexToBytes(normalizeHex32(prevLineHashHex)),
|
||||
int32Bytes(thisLineNumber),
|
||||
int8Byte(nameBytes.length),
|
||||
nameBytes
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeChannelDescription(value) {
|
||||
const text = String(value == null ? '' : value).trim().replace(/\s+/g, ' ');
|
||||
const bytes = utf8Bytes(text);
|
||||
if (bytes.length > 200) {
|
||||
throw new Error('Описание канала слишком длинное: максимум 200 символов.');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function makeCreateChannelBodyV2Bytes({
|
||||
lineCode,
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
channelName,
|
||||
channelDescription = '',
|
||||
}) {
|
||||
const check = validateChannelDisplayName(channelName);
|
||||
if (!check.ok) throw new Error(channelNameErrorText(check.code));
|
||||
const cleanName = check.normalized;
|
||||
const cleanDescription = normalizeChannelDescription(channelDescription);
|
||||
|
||||
const nameBytes = utf8Bytes(cleanName);
|
||||
if (nameBytes.length < 1 || nameBytes.length > 255) {
|
||||
throw new Error('Channel name must be 1..255 bytes');
|
||||
}
|
||||
|
||||
const descriptionBytes = utf8Bytes(cleanDescription);
|
||||
if (descriptionBytes.length > 200) {
|
||||
throw new Error('Описание канала слишком длинное: максимум 200 символов.');
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
int32Bytes(lineCode),
|
||||
int32Bytes(prevLineNumber),
|
||||
hexToBytes(normalizeHex32(prevLineHashHex)),
|
||||
int32Bytes(thisLineNumber),
|
||||
int8Byte(nameBytes.length),
|
||||
nameBytes,
|
||||
int16Bytes(descriptionBytes.length),
|
||||
descriptionBytes,
|
||||
);
|
||||
}
|
||||
|
||||
function makeTextPostBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, text }) {
|
||||
const message = String(text || '').trim();
|
||||
if (!message) throw new Error('Message text is required');
|
||||
|
||||
const textBytes = utf8Bytes(message);
|
||||
if (textBytes.length < 1 || textBytes.length > 65535) {
|
||||
throw new Error('Message text must be 1..65535 UTF-8 bytes');
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
int32Bytes(lineCode),
|
||||
int32Bytes(prevLineNumber),
|
||||
hexToBytes(normalizeHex32(prevLineHashHex)),
|
||||
int32Bytes(thisLineNumber),
|
||||
int16Bytes(textBytes.length),
|
||||
textBytes
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMessageRefTarget(target, actionName = 'action') {
|
||||
const cleanBch = String(target?.blockchainName || '').trim();
|
||||
const cleanBlockNumber = Number(target?.blockNumber);
|
||||
const cleanBlockHash = String(target?.blockHash || '').trim().toLowerCase();
|
||||
|
||||
if (!cleanBch) {
|
||||
throw new Error(`Missing message target blockchain for ${actionName}`);
|
||||
}
|
||||
if (!Number.isFinite(cleanBlockNumber) || cleanBlockNumber < 0) {
|
||||
throw new Error(`Invalid message target block number for ${actionName}`);
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/.test(cleanBlockHash) || /^0+$/.test(cleanBlockHash)) {
|
||||
throw new Error(`Invalid message target hash for ${actionName}`);
|
||||
}
|
||||
|
||||
return {
|
||||
blockchainName: cleanBch,
|
||||
blockNumber: cleanBlockNumber,
|
||||
blockHash: cleanBlockHash,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBlockPreimage({ prevBlockHashHex, blockNumber, msgType, msgSubType, msgVersion = 1, bodyBytes }) {
|
||||
const prevHashBytes = hexToBytes(normalizeHex32(prevBlockHashHex));
|
||||
const body = bodyBytes || new Uint8Array(0);
|
||||
const blockSize = 2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + body.length;
|
||||
|
||||
return concatBytes(
|
||||
int16Bytes(0),
|
||||
prevHashBytes,
|
||||
int32Bytes(blockSize),
|
||||
int32Bytes(blockNumber),
|
||||
int64Bytes(Math.floor(Date.now() / 1000)),
|
||||
int16Bytes(msgType),
|
||||
int16Bytes(msgSubType),
|
||||
int16Bytes(msgVersion),
|
||||
body
|
||||
);
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
constructor(serverUrl) {
|
||||
this.serverUrl = normalizeServerUrl(serverUrl);
|
||||
this.ws = new WsJsonClient(this.serverUrl);
|
||||
this.headerHashCache = new Map();
|
||||
this.writeLocks = new Map();
|
||||
}
|
||||
|
||||
async reconnect(serverUrl) {
|
||||
@ -119,6 +406,20 @@ export class AuthService {
|
||||
this.ws.close();
|
||||
this.serverUrl = normalized;
|
||||
this.ws = new WsJsonClient(this.serverUrl);
|
||||
this.headerHashCache = new Map();
|
||||
this.writeLocks.clear();
|
||||
}
|
||||
|
||||
runWriteLocked(lockKey, runAction) {
|
||||
const key = String(lockKey || '').trim() || 'write';
|
||||
if (this.writeLocks.has(key)) return this.writeLocks.get(key);
|
||||
|
||||
const task = (async () => runAction())().finally(() => {
|
||||
this.writeLocks.delete(key);
|
||||
});
|
||||
|
||||
this.writeLocks.set(key, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
async getUser(login) {
|
||||
@ -293,18 +594,483 @@ export class AuthService {
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async getChannelMessages(channel, limit = 200, sort = 'asc') {
|
||||
const response = await this.ws.request('GetChannelMessages', { channel, limit, sort });
|
||||
async getChannelMessages(channel, limit = 200, sort = 'asc', login = '') {
|
||||
const payload = { channel, limit, sort };
|
||||
const cleanLogin = String(login || '').trim();
|
||||
if (cleanLogin) payload.login = cleanLogin;
|
||||
const response = await this.ws.request('GetChannelMessages', payload);
|
||||
if (response.status !== 200) throw opError('GetChannelMessages', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50) {
|
||||
const response = await this.ws.request('GetMessageThread', { message, depthUp, depthDown, limitChildrenPerNode });
|
||||
async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50, login = '') {
|
||||
const payload = { message, depthUp, depthDown, limitChildrenPerNode };
|
||||
const cleanLogin = String(login || '').trim();
|
||||
if (cleanLogin) payload.login = cleanLogin;
|
||||
const response = await this.ws.request('GetMessageThread', payload);
|
||||
if (response.status !== 200) throw opError('GetMessageThread', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes }) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Missing login for AddBlock');
|
||||
if (!storagePwd) throw new Error('Missing storagePwd for AddBlock signing');
|
||||
|
||||
const user = await this.getUser(cleanLogin);
|
||||
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
||||
const freshNum = Number(user?.serverLastGlobalNumber);
|
||||
const freshHash = normalizeHex32(user?.serverLastGlobalHash, ZERO64);
|
||||
const freshCursor = {
|
||||
serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1,
|
||||
serverLastGlobalHash: freshHash,
|
||||
};
|
||||
|
||||
const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
||||
const blockchainPrivatePkcs8 = savedKeys?.blockchainKey;
|
||||
if (!blockchainPrivatePkcs8) {
|
||||
throw new Error('Missing saved blockchain private key on device');
|
||||
}
|
||||
|
||||
const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8);
|
||||
|
||||
const tryAdd = async (cursor) => {
|
||||
const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1;
|
||||
const prevBlockHash = normalizeHex32(cursor?.serverLastGlobalHash, ZERO64);
|
||||
const preimage = buildBlockPreimage({
|
||||
prevBlockHashHex: prevBlockHash,
|
||||
blockNumber,
|
||||
msgType,
|
||||
msgSubType,
|
||||
msgVersion,
|
||||
bodyBytes,
|
||||
});
|
||||
|
||||
const hash32 = await sha256Bytes(preimage);
|
||||
const signatureBytes = await signBytes(privateKey, hash32);
|
||||
const fullBlock = concatBytes(preimage, int16Bytes(0x0100), signatureBytes);
|
||||
|
||||
return this.ws.request('AddBlock', {
|
||||
blockchainName,
|
||||
blockNumber,
|
||||
prevBlockHash,
|
||||
blockBytesB64: bytesToBase64(fullBlock),
|
||||
});
|
||||
};
|
||||
|
||||
let cursor = freshCursor;
|
||||
let response = await tryAdd(cursor);
|
||||
if (response.status !== 200) {
|
||||
const knownNum = Number(response?.payload?.serverLastGlobalNumber);
|
||||
const knownHash = String(response?.payload?.serverLastGlobalHash || '');
|
||||
if (Number.isFinite(knownNum) && /^[0-9a-fA-F]{64}$/.test(knownHash)) {
|
||||
cursor = { serverLastGlobalNumber: knownNum, serverLastGlobalHash: knownHash.toLowerCase() };
|
||||
response = await tryAdd(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status !== 200) throw opError('AddBlock', response);
|
||||
|
||||
const payload = response.payload || {};
|
||||
const acceptedNum = Number(payload?.serverLastGlobalNumber);
|
||||
const acceptedHash = normalizeHex32(payload?.serverLastGlobalHash, ZERO64);
|
||||
if (Number.isFinite(acceptedNum) && acceptedNum === 0 && acceptedHash !== ZERO64) {
|
||||
this.headerHashCache.set(blockchainName, acceptedHash);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async ensureChainInitializedForLineOps(login, storagePwd) {
|
||||
const current = await this.getUser(login);
|
||||
const lastNum = Number(current?.serverLastGlobalNumber);
|
||||
if (Number.isFinite(lastNum) && lastNum >= 0) return current;
|
||||
if (!(Number.isFinite(lastNum) && lastNum === -1)) return current;
|
||||
|
||||
// Bootstrap an empty chain with a minimal USER_PARAM block so line-based
|
||||
// channel operations have a valid anchor at block #0.
|
||||
await this.addBlockUserParam({
|
||||
login,
|
||||
storagePwd,
|
||||
param: 'shine',
|
||||
value: 'yes',
|
||||
});
|
||||
|
||||
return this.getUser(login);
|
||||
}
|
||||
|
||||
async addBlockLike({ login, message, storagePwd }) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const target = normalizeMessageRefTarget(message, 'like');
|
||||
const key = `like:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}`;
|
||||
|
||||
return this.runWriteLocked(key, async () => {
|
||||
const bodyBytes = makeReactionLikeBodyBytes({
|
||||
toBlockchainName: target.blockchainName,
|
||||
toBlockNumber: target.blockNumber,
|
||||
toBlockHashHex: target.blockHash,
|
||||
});
|
||||
|
||||
return this.addBlockSigned({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
msgType: MSG_TYPE_REACTION,
|
||||
msgSubType: MSG_SUBTYPE_REACTION_LIKE,
|
||||
msgVersion: 1,
|
||||
bodyBytes,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async addBlockUnlike({ login, message, storagePwd }) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const target = normalizeMessageRefTarget(message, 'unlike');
|
||||
const key = `unlike:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}`;
|
||||
|
||||
return this.runWriteLocked(key, async () => {
|
||||
const bodyBytes = makeReactionLikeBodyBytes({
|
||||
toBlockchainName: target.blockchainName,
|
||||
toBlockNumber: target.blockNumber,
|
||||
toBlockHashHex: target.blockHash,
|
||||
});
|
||||
|
||||
return this.addBlockSigned({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
msgType: MSG_TYPE_REACTION,
|
||||
msgSubType: MSG_SUBTYPE_REACTION_UNLIKE,
|
||||
msgVersion: 1,
|
||||
bodyBytes,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async addBlockReply({ login, message, text, storagePwd }) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanText = String(text || '').trim();
|
||||
const target = normalizeMessageRefTarget(message, 'reply');
|
||||
const key = `reply:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}`;
|
||||
|
||||
return this.runWriteLocked(key, async () => {
|
||||
const bodyBytes = makeTextReplyBodyBytes({
|
||||
toBlockchainName: target.blockchainName,
|
||||
toBlockNumber: target.blockNumber,
|
||||
toBlockHashHex: target.blockHash,
|
||||
text: cleanText,
|
||||
});
|
||||
|
||||
return this.addBlockSigned({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
msgType: MSG_TYPE_TEXT,
|
||||
msgSubType: MSG_SUBTYPE_TEXT_REPLY,
|
||||
msgVersion: 1,
|
||||
bodyBytes,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async addBlockFollowUser({ login, targetLogin, storagePwd, unfollow = false }) {
|
||||
const cleanTargetLogin = String(targetLogin || '').trim().replace(/^@+/, '');
|
||||
if (!cleanTargetLogin) throw new Error('Target login is required');
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const key = `${unfollow ? 'unfollow-user' : 'follow-user'}:${cleanLogin}:${cleanTargetLogin.toLowerCase()}`;
|
||||
|
||||
return this.runWriteLocked(key, async () => {
|
||||
const targetUser = await this.getUser(cleanTargetLogin);
|
||||
if (!targetUser?.exists) throw new Error('Target user not found');
|
||||
const targetHeaderHash = await this.resolveHeaderHashForBlockchain(targetUser.blockchainName);
|
||||
|
||||
return this.addBlockFollowChannel({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
targetBlockchainName: targetUser.blockchainName,
|
||||
targetBlockNumber: 0,
|
||||
targetBlockHashHex: targetHeaderHash,
|
||||
unfollow,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async addBlockFollowChannel({
|
||||
login,
|
||||
storagePwd,
|
||||
targetBlockchainName,
|
||||
targetBlockNumber,
|
||||
targetBlockHashHex,
|
||||
unfollow = false,
|
||||
}) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanTargetBch = String(targetBlockchainName || '').trim();
|
||||
const cleanTargetBlockNumber = Number(targetBlockNumber);
|
||||
if (!cleanTargetBch) throw new Error('Target blockchain is required');
|
||||
if (!Number.isFinite(cleanTargetBlockNumber) || cleanTargetBlockNumber < 0) {
|
||||
throw new Error('Invalid target block number');
|
||||
}
|
||||
const seedHash = normalizeHex32(targetBlockHashHex, ZERO64);
|
||||
const key = `${unfollow ? 'unfollow-channel' : 'follow-channel'}:${cleanLogin}:${cleanTargetBch}:${cleanTargetBlockNumber}:${seedHash}`;
|
||||
|
||||
return this.runWriteLocked(key, async () => {
|
||||
let targetHashHex = seedHash;
|
||||
if (targetHashHex === ZERO64) {
|
||||
targetHashHex = cleanTargetBlockNumber === 0
|
||||
? await this.resolveHeaderHashForBlockchain(cleanTargetBch)
|
||||
: await this.getBlockHashByNumber(cleanTargetBch, cleanTargetBlockNumber);
|
||||
}
|
||||
|
||||
const bodyBytes = makeConnectionBodyBytes({
|
||||
lineCode: 0,
|
||||
prevLineNumber: -1,
|
||||
prevLineHashHex: ZERO64,
|
||||
thisLineNumber: -1,
|
||||
toBlockchainName: cleanTargetBch,
|
||||
toBlockNumber: cleanTargetBlockNumber,
|
||||
toBlockHashHex: targetHashHex,
|
||||
});
|
||||
|
||||
return this.addBlockSigned({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
msgType: MSG_TYPE_CONNECTION,
|
||||
msgSubType: unfollow ? MSG_SUBTYPE_CONNECTION_UNFOLLOW : MSG_SUBTYPE_CONNECTION_FOLLOW,
|
||||
msgVersion: 1,
|
||||
bodyBytes,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getBlockHashByNumber(blockchainName, blockNumber) {
|
||||
const cleanBlockNumber = Number(blockNumber);
|
||||
try {
|
||||
const payload = await this.getMessageThread(
|
||||
{
|
||||
blockchainName: String(blockchainName || '').trim(),
|
||||
blockNumber: cleanBlockNumber,
|
||||
blockHash: ZERO64,
|
||||
},
|
||||
0,
|
||||
0,
|
||||
1
|
||||
);
|
||||
const hash = payload?.focus?.messageRef?.blockHash;
|
||||
return normalizeHex32(hash, ZERO64);
|
||||
} catch (error) {
|
||||
if (cleanBlockNumber === 0 && Number(error?.status) === 404) {
|
||||
return ZERO64;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async resolveHeaderHashForBlockchain(blockchainName) {
|
||||
const cleanBch = String(blockchainName || '').trim();
|
||||
if (!cleanBch) throw new Error('Missing blockchainName');
|
||||
|
||||
if (this.headerHashCache.has(cleanBch)) {
|
||||
const cached = normalizeHex32(this.headerHashCache.get(cleanBch), ZERO64);
|
||||
if (cached !== ZERO64) return cached;
|
||||
this.headerHashCache.delete(cleanBch);
|
||||
}
|
||||
|
||||
const headerHash = await this.getBlockHashByNumber(cleanBch, 0);
|
||||
if (headerHash !== ZERO64) {
|
||||
this.headerHashCache.set(cleanBch, headerHash);
|
||||
} else {
|
||||
this.headerHashCache.delete(cleanBch);
|
||||
}
|
||||
return headerHash;
|
||||
}
|
||||
|
||||
async listOwnChannelsForBlockchain(login, blockchainName) {
|
||||
const feed = await this.listSubscriptionsFeed(login, 500);
|
||||
const own = feed?.ownedChannels || [];
|
||||
return own
|
||||
.filter((item) => String(item?.channel?.ownerBlockchainName || '') === blockchainName)
|
||||
.map((item) => ({
|
||||
rootBlockNumber: Number(item?.channel?.channelRoot?.blockNumber),
|
||||
rootBlockHash: normalizeHex32(item?.channel?.channelRoot?.blockHash, ZERO64),
|
||||
channelName: String(item?.channel?.channelName || ''),
|
||||
}))
|
||||
.filter((item) => Number.isFinite(item.rootBlockNumber) && item.rootBlockNumber >= 0);
|
||||
}
|
||||
|
||||
async addBlockCreateChannel({ login, channelName, channelDescription = '', storagePwd }) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Missing login');
|
||||
|
||||
const check = validateChannelDisplayName(channelName);
|
||||
if (!check.ok) throw new Error(channelNameErrorText(check.code));
|
||||
const cleanChannelName = normalizeChannelDisplayName(check.normalized);
|
||||
const cleanChannelDescription = normalizeChannelDescription(channelDescription);
|
||||
const channelSlug = toCanonicalChannelSlug(cleanChannelName);
|
||||
|
||||
const key = `create-channel:${cleanLogin}:${channelSlug || cleanChannelName.toLowerCase()}`;
|
||||
return this.runWriteLocked(key, async () => {
|
||||
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
|
||||
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
||||
const userLastGlobalNumber = Number(user?.serverLastGlobalNumber);
|
||||
const userLastGlobalHash = normalizeHex32(user?.serverLastGlobalHash, ZERO64);
|
||||
|
||||
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, blockchainName);
|
||||
const createdChannels = ownChannels
|
||||
.filter((item) => item.rootBlockNumber > 0)
|
||||
.sort((a, b) => a.rootBlockNumber - b.rootBlockNumber);
|
||||
|
||||
let prevLineNumber = 0;
|
||||
let prevLineHashHex = (
|
||||
Number.isFinite(userLastGlobalNumber) &&
|
||||
userLastGlobalNumber === 0 &&
|
||||
userLastGlobalHash !== ZERO64
|
||||
)
|
||||
? userLastGlobalHash
|
||||
: await this.resolveHeaderHashForBlockchain(blockchainName);
|
||||
let thisLineNumber = 1;
|
||||
|
||||
if (createdChannels.length > 0) {
|
||||
const last = createdChannels[createdChannels.length - 1];
|
||||
prevLineNumber = last.rootBlockNumber;
|
||||
prevLineHashHex = normalizeHex32(last.rootBlockHash, ZERO64);
|
||||
thisLineNumber = createdChannels.length + 1;
|
||||
}
|
||||
|
||||
const submitCreate = async (useV2) => {
|
||||
const bodyBytes = useV2
|
||||
? makeCreateChannelBodyV2Bytes({
|
||||
lineCode: 0,
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
channelName: cleanChannelName,
|
||||
channelDescription: cleanChannelDescription,
|
||||
})
|
||||
: makeCreateChannelBodyBytes({
|
||||
lineCode: 0,
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
channelName: cleanChannelName,
|
||||
});
|
||||
|
||||
return this.addBlockSigned({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
msgType: MSG_TYPE_TECH,
|
||||
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
|
||||
msgVersion: useV2 ? CREATE_CHANNEL_BODY_VERSION : 1,
|
||||
bodyBytes,
|
||||
});
|
||||
};
|
||||
|
||||
let payload;
|
||||
let usedLegacyDescriptionFallback = false;
|
||||
let savedDescriptionViaUserParam = false;
|
||||
try {
|
||||
payload = await submitCreate(true);
|
||||
} catch (error) {
|
||||
if (!isLegacyCreateChannelFormatError(error)) throw error;
|
||||
payload = await submitCreate(false);
|
||||
usedLegacyDescriptionFallback = true;
|
||||
}
|
||||
|
||||
const selector = {
|
||||
ownerBlockchainName: blockchainName,
|
||||
channelRootBlockNumber: Number(payload?.serverLastGlobalNumber),
|
||||
channelRootBlockHash: normalizeHex32(payload?.serverLastGlobalHash, ZERO64),
|
||||
};
|
||||
|
||||
if (usedLegacyDescriptionFallback && cleanChannelDescription) {
|
||||
const param = channelDescriptionParamKeyFromSelector(selector);
|
||||
if (!param) {
|
||||
throw new Error('Не удалось сохранить описание канала: некорректный идентификатор канала.');
|
||||
}
|
||||
await this.addBlockUserParam({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
param,
|
||||
value: JSON.stringify({ v: cleanChannelDescription }),
|
||||
});
|
||||
savedDescriptionViaUserParam = true;
|
||||
}
|
||||
|
||||
return {
|
||||
...payload,
|
||||
usedLegacyDescriptionFallback,
|
||||
savedDescriptionViaUserParam,
|
||||
channel: {
|
||||
...selector,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async addBlockTextPost({ login, channel, text, storagePwd }) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Missing login');
|
||||
const cleanText = String(text || '').trim();
|
||||
const selector = channel || {};
|
||||
const owner = String(selector?.ownerBlockchainName || '').trim();
|
||||
const root = Number(selector?.channelRootBlockNumber);
|
||||
const key = `text-post:${cleanLogin}:${owner}:${root}:${cleanText}`;
|
||||
|
||||
return this.runWriteLocked(key, async () => {
|
||||
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
|
||||
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
||||
const userLastGlobalNumber = Number(user?.serverLastGlobalNumber);
|
||||
const userLastGlobalHash = normalizeHex32(user?.serverLastGlobalHash, ZERO64);
|
||||
|
||||
const ownerBlockchainName = owner;
|
||||
const lineCode = root;
|
||||
if (!ownerBlockchainName || !Number.isFinite(lineCode) || lineCode < 0) {
|
||||
throw new Error('Invalid channel selector');
|
||||
}
|
||||
if (ownerBlockchainName !== blockchainName) {
|
||||
throw new Error('Posting is allowed only to your own channels');
|
||||
}
|
||||
|
||||
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
|
||||
if (lineCode === 0) {
|
||||
rootHashHex = (
|
||||
Number.isFinite(userLastGlobalNumber) &&
|
||||
userLastGlobalNumber === 0 &&
|
||||
userLastGlobalHash !== ZERO64
|
||||
)
|
||||
? userLastGlobalHash
|
||||
: await this.resolveHeaderHashForBlockchain(blockchainName);
|
||||
} else if (rootHashHex === ZERO64) {
|
||||
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, blockchainName);
|
||||
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
|
||||
if (!rootChannel) throw new Error('Channel root not found');
|
||||
rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
|
||||
}
|
||||
|
||||
const bodyBytes = makeTextPostBodyBytes({
|
||||
lineCode,
|
||||
prevLineNumber: lineCode,
|
||||
prevLineHashHex: rootHashHex,
|
||||
thisLineNumber: 0,
|
||||
text: cleanText,
|
||||
});
|
||||
|
||||
const payload = await this.addBlockSigned({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
msgType: MSG_TYPE_TEXT,
|
||||
msgSubType: MSG_SUBTYPE_TEXT_POST,
|
||||
msgVersion: 1,
|
||||
bodyBytes,
|
||||
});
|
||||
|
||||
return {
|
||||
...payload,
|
||||
channel: {
|
||||
ownerBlockchainName,
|
||||
channelRootBlockNumber: lineCode,
|
||||
channelRootBlockHash: rootHashHex,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
onEvent(op, handler) {
|
||||
return this.ws.onEvent(op, handler);
|
||||
|
||||
90
shine-UI/js/services/channel-name-rules.js
Normal file
90
shine-UI/js/services/channel-name-rules.js
Normal file
@ -0,0 +1,90 @@
|
||||
const MIN_LEN = 3;
|
||||
const MAX_LEN = 32;
|
||||
const ALLOWED_CHARS_RE = /^[\p{Script=Latin}\p{Script=Cyrillic}0-9 _-]+$/u;
|
||||
|
||||
export function normalizeChannelDisplayName(value) {
|
||||
if (value == null) return '';
|
||||
return String(value).trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
export function normalizeChannelDescription(value) {
|
||||
if (value == null) return '';
|
||||
return String(value).trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
export function toCanonicalChannelSlug(value) {
|
||||
const normalized = normalizeChannelDisplayName(value);
|
||||
if (!normalized) return '';
|
||||
|
||||
const lowered = normalized.toLowerCase().replace(/\u0451/g, '\u0435');
|
||||
let out = '';
|
||||
let pendingSeparator = false;
|
||||
|
||||
for (const ch of lowered) {
|
||||
if (ch === ' ' || ch === '_' || ch === '-') {
|
||||
pendingSeparator = out.length > 0;
|
||||
continue;
|
||||
}
|
||||
if (!/[\p{Script=Latin}\p{Script=Cyrillic}0-9]/u.test(ch)) {
|
||||
return '';
|
||||
}
|
||||
if (pendingSeparator && out.length > 0) out += '-';
|
||||
out += ch;
|
||||
pendingSeparator = false;
|
||||
}
|
||||
|
||||
return out.replace(/-+$/g, '');
|
||||
}
|
||||
|
||||
export function validateChannelDisplayName(value) {
|
||||
const normalized = normalizeChannelDisplayName(value);
|
||||
if (!normalized) {
|
||||
return { ok: false, code: 'blank', normalized: '', slug: '' };
|
||||
}
|
||||
|
||||
const length = Array.from(normalized).length;
|
||||
if (length < MIN_LEN) {
|
||||
return { ok: false, code: 'too_short', normalized, slug: '' };
|
||||
}
|
||||
if (length > MAX_LEN) {
|
||||
return { ok: false, code: 'too_long', normalized, slug: '' };
|
||||
}
|
||||
if (!ALLOWED_CHARS_RE.test(normalized)) {
|
||||
return { ok: false, code: 'bad_chars', normalized, slug: '' };
|
||||
}
|
||||
if (normalized === '0') {
|
||||
return { ok: false, code: 'reserved', normalized, slug: '' };
|
||||
}
|
||||
|
||||
const slug = toCanonicalChannelSlug(normalized);
|
||||
if (!slug) {
|
||||
return { ok: false, code: 'bad_chars', normalized, slug: '' };
|
||||
}
|
||||
|
||||
return { ok: true, code: '', normalized, slug };
|
||||
}
|
||||
|
||||
export function channelNameErrorText(code) {
|
||||
switch (String(code || '').trim()) {
|
||||
case 'blank':
|
||||
return 'Введите название канала.';
|
||||
case 'too_short':
|
||||
return 'Название слишком короткое: минимум 3 символа.';
|
||||
case 'too_long':
|
||||
return 'Название слишком длинное: максимум 32 символа.';
|
||||
case 'bad_chars':
|
||||
return 'Разрешены кириллица, латиница, цифры, пробел, _ и -.';
|
||||
case 'reserved':
|
||||
return 'Название "0" зарезервировано.';
|
||||
default:
|
||||
return 'Некорректное название канала.';
|
||||
}
|
||||
}
|
||||
|
||||
export function channelDescriptionErrorText(value) {
|
||||
const normalized = normalizeChannelDescription(value);
|
||||
if (new TextEncoder().encode(normalized).length > 200) {
|
||||
return 'Описание слишком длинное: максимум 200 байт UTF-8.';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
170
shine-UI/js/services/channels-ux.js
Normal file
170
shine-UI/js/services/channels-ux.js
Normal file
@ -0,0 +1,170 @@
|
||||
const TOAST_HOST_ID = 'shine-toast-host';
|
||||
|
||||
const rtf = (() => {
|
||||
try {
|
||||
return new Intl.RelativeTimeFormat('ru', { numeric: 'auto' });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
function toNumber(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function pickUnit(seconds) {
|
||||
const abs = Math.abs(seconds);
|
||||
if (abs < 60) return ['second', Math.round(seconds)];
|
||||
const minutes = seconds / 60;
|
||||
if (Math.abs(minutes) < 60) return ['minute', Math.round(minutes)];
|
||||
const hours = minutes / 60;
|
||||
if (Math.abs(hours) < 24) return ['hour', Math.round(hours)];
|
||||
const days = hours / 24;
|
||||
if (Math.abs(days) < 30) return ['day', Math.round(days)];
|
||||
const months = days / 30;
|
||||
if (Math.abs(months) < 12) return ['month', Math.round(months)];
|
||||
const years = months / 12;
|
||||
return ['year', Math.round(years)];
|
||||
}
|
||||
|
||||
export function formatRelativeTime(timestampMs) {
|
||||
const ts = toNumber(timestampMs);
|
||||
if (!ts) return '—';
|
||||
|
||||
const now = Date.now();
|
||||
const diffSeconds = (ts - now) / 1000;
|
||||
const ageSeconds = now >= ts ? (now - ts) / 1000 : 0;
|
||||
const ageHours = ageSeconds / 3600;
|
||||
|
||||
if (ageHours <= 10) {
|
||||
const [unit, value] = pickUnit(diffSeconds);
|
||||
if (rtf) return rtf.format(value, unit);
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
const suffix = value <= 0 ? 'назад' : 'через';
|
||||
const labels = {
|
||||
second: 'сек',
|
||||
minute: 'мин',
|
||||
hour: 'ч',
|
||||
day: 'д',
|
||||
month: 'мес',
|
||||
year: 'г',
|
||||
};
|
||||
return `${suffix} ${absValue} ${labels[unit] || ''}`.trim();
|
||||
}
|
||||
|
||||
try {
|
||||
const dt = new Date(ts);
|
||||
const nowDt = new Date(now);
|
||||
const formatter = new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
...(dt.getFullYear() !== nowDt.getFullYear() ? { year: 'numeric' } : {}),
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return formatter.format(dt);
|
||||
} catch {
|
||||
return new Date(ts).toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
function ensureToastHost() {
|
||||
let host = document.getElementById(TOAST_HOST_ID);
|
||||
if (host) return host;
|
||||
|
||||
host = document.createElement('div');
|
||||
host.id = TOAST_HOST_ID;
|
||||
host.className = 'toast-host';
|
||||
document.body.append(host);
|
||||
return host;
|
||||
}
|
||||
|
||||
export function showToast(message, { kind = 'success', timeoutMs = 2500 } = {}) {
|
||||
const text = String(message || '').trim();
|
||||
if (!text) return;
|
||||
|
||||
const host = ensureToastHost();
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast--${kind}`;
|
||||
toast.textContent = text;
|
||||
host.append(toast);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.add('is-visible');
|
||||
});
|
||||
|
||||
const hide = () => {
|
||||
toast.classList.remove('is-visible');
|
||||
toast.classList.add('is-hiding');
|
||||
setTimeout(() => toast.remove(), 220);
|
||||
};
|
||||
|
||||
setTimeout(hide, Math.max(1200, Number(timeoutMs) || 2500));
|
||||
}
|
||||
|
||||
export function softHaptic(duration = 15) {
|
||||
try {
|
||||
if (navigator?.vibrate) navigator.vibrate(Math.max(5, Math.min(30, Number(duration) || 15)));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function animatePress(el) {
|
||||
if (!el) return;
|
||||
el.classList.remove('is-springing');
|
||||
// force reflow
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
el.offsetWidth;
|
||||
el.classList.add('is-springing');
|
||||
}
|
||||
|
||||
const CHANNEL_NOTIF_KEY = 'shine-channels-notify-v1';
|
||||
|
||||
export function readChannelNotificationsState() {
|
||||
try {
|
||||
const raw = localStorage.getItem(CHANNEL_NOTIF_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function writeChannelNotificationsState(nextState) {
|
||||
try {
|
||||
localStorage.setItem(CHANNEL_NOTIF_KEY, JSON.stringify(nextState || {}));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function makeAuthorLabel(login, localNumber) {
|
||||
const cleanLogin = String(login || 'автор');
|
||||
const n = Number(localNumber);
|
||||
if (!Number.isFinite(n) || n < 1) return cleanLogin;
|
||||
return `${cleanLogin} · #${n}`;
|
||||
}
|
||||
|
||||
export function createSkeletonCard(className = '') {
|
||||
const card = document.createElement('div');
|
||||
card.className = `card skeleton-card ${className}`.trim();
|
||||
card.innerHTML = `
|
||||
<div class="skeleton-line w-40"></div>
|
||||
<div class="skeleton-line w-90"></div>
|
||||
<div class="skeleton-line w-70"></div>
|
||||
`;
|
||||
return card;
|
||||
}
|
||||
|
||||
export function normalizeChannelDescription(value) {
|
||||
const text = String(value == null ? '' : value).trim().replace(/\s+/g, ' ');
|
||||
if (!text) return '';
|
||||
const chars = Array.from(text);
|
||||
if (chars.length <= 200) return text;
|
||||
return chars.slice(0, 200).join('');
|
||||
}
|
||||
@ -1,4 +1,21 @@
|
||||
const encoder = new TextEncoder();
|
||||
const WEB_CRYPTO_REQUIRED_MESSAGE = 'Регистрация и подпись блоков требуют WebCrypto (crypto.subtle). Откройте приложение через HTTPS или localhost в современном браузере и повторите попытку.';
|
||||
|
||||
function getCryptoApi() {
|
||||
const api = globalThis.crypto;
|
||||
if (!api || typeof api.getRandomValues !== 'function') {
|
||||
throw new Error(WEB_CRYPTO_REQUIRED_MESSAGE);
|
||||
}
|
||||
return api;
|
||||
}
|
||||
|
||||
function getSubtleApi() {
|
||||
const api = getCryptoApi();
|
||||
if (!api.subtle) {
|
||||
throw new Error(WEB_CRYPTO_REQUIRED_MESSAGE);
|
||||
}
|
||||
return api.subtle;
|
||||
}
|
||||
|
||||
|
||||
function base64UrlToBase64(value) {
|
||||
@ -8,7 +25,7 @@ function base64UrlToBase64(value) {
|
||||
}
|
||||
|
||||
export function randomBase64(byteLen = 32) {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(byteLen));
|
||||
const bytes = getCryptoApi().getRandomValues(new Uint8Array(byteLen));
|
||||
return bytesToBase64(bytes);
|
||||
}
|
||||
|
||||
@ -35,7 +52,7 @@ export function utf8Bytes(value) {
|
||||
}
|
||||
|
||||
export async function sha256Bytes(bytes) {
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||
const digest = await getSubtleApi().digest('SHA-256', bytes);
|
||||
return new Uint8Array(digest);
|
||||
}
|
||||
|
||||
@ -65,8 +82,9 @@ function ed25519Pkcs8FromSeed(seed32) {
|
||||
export async function deriveEd25519FromPassword(password, suffix) {
|
||||
const seed = await derivePasswordSeed(password, suffix);
|
||||
const pkcs8 = ed25519Pkcs8FromSeed(seed);
|
||||
const privateKey = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']);
|
||||
const jwk = await crypto.subtle.exportKey('jwk', privateKey);
|
||||
const subtle = getSubtleApi();
|
||||
const privateKey = await subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']);
|
||||
const jwk = await subtle.exportKey('jwk', privateKey);
|
||||
if (!jwk.x) throw new Error('Не удалось получить публичный ключ Ed25519');
|
||||
|
||||
return {
|
||||
@ -77,7 +95,8 @@ export async function deriveEd25519FromPassword(password, suffix) {
|
||||
}
|
||||
|
||||
export async function deriveAesKeyFromStoragePwd(storagePwd, saltBytes) {
|
||||
const baseKey = await crypto.subtle.importKey(
|
||||
const subtle = getSubtleApi();
|
||||
const baseKey = await subtle.importKey(
|
||||
'raw',
|
||||
utf8Bytes(storagePwd),
|
||||
{ name: 'PBKDF2' },
|
||||
@ -85,7 +104,7 @@ export async function deriveAesKeyFromStoragePwd(storagePwd, saltBytes) {
|
||||
['deriveKey'],
|
||||
);
|
||||
|
||||
return crypto.subtle.deriveKey(
|
||||
return subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: saltBytes,
|
||||
@ -103,11 +122,13 @@ export async function deriveAesKeyFromStoragePwd(storagePwd, saltBytes) {
|
||||
}
|
||||
|
||||
export async function encryptJsonWithStoragePwd(value, storagePwd) {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const cryptoApi = getCryptoApi();
|
||||
const subtle = getSubtleApi();
|
||||
const salt = cryptoApi.getRandomValues(new Uint8Array(16));
|
||||
const iv = cryptoApi.getRandomValues(new Uint8Array(12));
|
||||
const key = await deriveAesKeyFromStoragePwd(storagePwd, salt);
|
||||
const plainBytes = utf8Bytes(JSON.stringify(value));
|
||||
const cipher = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plainBytes);
|
||||
const cipher = await subtle.encrypt({ name: 'AES-GCM', iv }, key, plainBytes);
|
||||
|
||||
return {
|
||||
saltB64: bytesToBase64(salt),
|
||||
@ -121,35 +142,35 @@ export async function decryptJsonWithStoragePwd(envelope, storagePwd) {
|
||||
const iv = base64ToBytes(envelope.ivB64);
|
||||
const cipher = base64ToBytes(envelope.cipherB64);
|
||||
const key = await deriveAesKeyFromStoragePwd(storagePwd, salt);
|
||||
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, cipher);
|
||||
const plain = await getSubtleApi().decrypt({ name: 'AES-GCM', iv }, key, cipher);
|
||||
const text = new TextDecoder().decode(plain);
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
export async function generateEd25519Pair() {
|
||||
return crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
|
||||
return getSubtleApi().generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
|
||||
}
|
||||
|
||||
export async function exportEd25519PublicKeyB64(publicKey) {
|
||||
const raw = await crypto.subtle.exportKey('raw', publicKey);
|
||||
const raw = await getSubtleApi().exportKey('raw', publicKey);
|
||||
return bytesToBase64(new Uint8Array(raw));
|
||||
}
|
||||
|
||||
export async function exportPkcs8B64(privateKey) {
|
||||
const raw = await crypto.subtle.exportKey('pkcs8', privateKey);
|
||||
const raw = await getSubtleApi().exportKey('pkcs8', privateKey);
|
||||
return bytesToBase64(new Uint8Array(raw));
|
||||
}
|
||||
|
||||
export async function importPkcs8Ed25519(pkcs8B64) {
|
||||
return crypto.subtle.importKey('pkcs8', base64ToBytes(pkcs8B64), { name: 'Ed25519' }, false, ['sign']);
|
||||
return getSubtleApi().importKey('pkcs8', base64ToBytes(pkcs8B64), { name: 'Ed25519' }, false, ['sign']);
|
||||
}
|
||||
|
||||
export async function signBase64(privateKey, text) {
|
||||
const signature = await crypto.subtle.sign({ name: 'Ed25519' }, privateKey, utf8Bytes(text));
|
||||
const signature = await getSubtleApi().sign({ name: 'Ed25519' }, privateKey, utf8Bytes(text));
|
||||
return bytesToBase64(new Uint8Array(signature));
|
||||
}
|
||||
|
||||
export async function signBytes(privateKey, bytes) {
|
||||
const signature = await crypto.subtle.sign({ name: 'Ed25519' }, privateKey, bytes);
|
||||
const signature = await getSubtleApi().sign({ name: 'Ed25519' }, privateKey, bytes);
|
||||
return new Uint8Array(signature);
|
||||
}
|
||||
|
||||
112
shine-UI/js/services/ui-error-texts.js
Normal file
112
shine-UI/js/services/ui-error-texts.js
Normal file
@ -0,0 +1,112 @@
|
||||
function extractCode(message = '') {
|
||||
const match = String(message || '').match(/\(([A-Z0-9_]+)\)\s*$/i);
|
||||
return match ? String(match[1]).toUpperCase() : '';
|
||||
}
|
||||
|
||||
function normalizeText(error) {
|
||||
return String(error?.message || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function toUserMessage(error, fallback = 'Действие не выполнено. Попробуйте еще раз.') {
|
||||
const raw = String(error?.message || '').trim();
|
||||
const text = normalizeText(error);
|
||||
const code = String(error?.code || extractCode(raw) || '').toUpperCase();
|
||||
|
||||
if (
|
||||
text.includes('webcrypto') ||
|
||||
text.includes('crypto.subtle') ||
|
||||
text.includes("reading 'digest'") ||
|
||||
text.includes('not supported on insecure origins')
|
||||
) {
|
||||
return 'Криптография браузера недоступна. Откройте приложение через HTTPS или localhost.';
|
||||
}
|
||||
|
||||
if (
|
||||
text.includes('mixed content') ||
|
||||
text.includes('insecure websocket connection') ||
|
||||
(text.includes('https') && text.includes('ws://'))
|
||||
) {
|
||||
return 'Подключение заблокировано: страница открыта по HTTPS, а сервер указан как ws://. Используйте wss://.';
|
||||
}
|
||||
|
||||
if (
|
||||
text.includes('не удалось подключиться') ||
|
||||
text.includes('failed to connect websocket') ||
|
||||
text.includes('websocket закрыто') ||
|
||||
text.includes('соединение websocket закрыто')
|
||||
) {
|
||||
return 'Сервер недоступен. Проверьте, что backend запущен, и повторите попытку.';
|
||||
}
|
||||
|
||||
if (text.includes('таймаут') || text.includes('timeout waiting')) {
|
||||
return 'Сервер отвечает слишком долго. Повторите попытку через несколько секунд.';
|
||||
}
|
||||
|
||||
if (
|
||||
code === 'USER_NOT_FOUND' ||
|
||||
text.includes('user not found') ||
|
||||
text.includes('пользователь не найден')
|
||||
) {
|
||||
return 'Пользователь не найден. Проверьте логин.';
|
||||
}
|
||||
|
||||
if (
|
||||
code === 'BAD_CHANNEL_NAME' ||
|
||||
text.includes('channel name must match') ||
|
||||
text.includes('channelname contains unsupported') ||
|
||||
text.includes('channelname length must be 3..32') ||
|
||||
text.includes('bad_channel_name')
|
||||
) {
|
||||
return 'Некорректное название канала. Разрешены кириллица, латиница, цифры, пробел, _ и - (3..32 символа).';
|
||||
}
|
||||
|
||||
if (text.includes('channel name is required') || text.includes('введите имя канала')) {
|
||||
return 'Введите имя канала.';
|
||||
}
|
||||
|
||||
if (code === 'CHANNEL_NAME_ALREADY_EXISTS' || text.includes('channel_name_already_exists')) {
|
||||
return 'Такое название уже занято. Попробуйте немного изменить его.';
|
||||
}
|
||||
|
||||
if (
|
||||
code === 'PREV_LINE_BLOCK_NOT_FOUND' ||
|
||||
code === 'LINE_ERR_NO_PREV' ||
|
||||
text.includes('prev_line_block_not_found') ||
|
||||
text.includes('line_err_no_prev')
|
||||
) {
|
||||
return 'Базовый блок линии не найден. Обновите страницу и повторите действие.';
|
||||
}
|
||||
|
||||
if (
|
||||
code === 'BAD_PREV_LINE_HASH' ||
|
||||
code === 'LINE_ERR_PREV_HASH_MISMATCH' ||
|
||||
text.includes('bad_prev_line_hash') ||
|
||||
text.includes('line_err_prev_hash_mismatch') ||
|
||||
text.includes('prevlinehash')
|
||||
) {
|
||||
return 'Конфликт состояния канала. Обновите страницу и повторите действие.';
|
||||
}
|
||||
|
||||
if (code === 'LINE_ERR_PARTIAL_FIELDS' || text.includes('line_err_partial_fields')) {
|
||||
return 'Некорректные данные канала. Обновите страницу и повторите действие.';
|
||||
}
|
||||
|
||||
if (code === 'NOT_AUTHENTICATED' || text.includes('session is not ready for signing')) {
|
||||
return 'Сессия недействительна. Выполните вход заново.';
|
||||
}
|
||||
|
||||
if (
|
||||
code === 'SESSION_NOT_FOUND' ||
|
||||
code === 'SESSION_KEY_NOT_ACTUAL' ||
|
||||
code === 'SESSION_OF_ANOTHER_USER'
|
||||
) {
|
||||
return 'Сессия устарела. Войдите заново и повторите действие.';
|
||||
}
|
||||
|
||||
if (code === 'UNSUPPORTED_KEY_ALGORITHM' || text.includes('unsupported key algorithm')) {
|
||||
return 'Ключ устройства не поддерживается сервером. Очистите локальные ключи и войдите заново.';
|
||||
}
|
||||
|
||||
if (!raw) return fallback;
|
||||
return raw;
|
||||
}
|
||||
@ -5,11 +5,51 @@ const DEFAULT_TIMEOUT_MS = 12000;
|
||||
function buildWsUrl(raw) {
|
||||
const value = (raw || '').trim();
|
||||
if (!value) return 'wss://shineup.me/ws';
|
||||
if (value.startsWith('ws://') || value.startsWith('wss://')) return value;
|
||||
if (value.startsWith('http://')) return `ws://${value.slice('http://'.length)}`;
|
||||
if (value.startsWith('https://')) return `wss://${value.slice('https://'.length)}`;
|
||||
if (value.startsWith('/')) {
|
||||
const secure = window.location.protocol === 'https:';
|
||||
const scheme = secure ? 'wss' : 'ws';
|
||||
return `${scheme}://${window.location.host}${value}`;
|
||||
}
|
||||
if (value.startsWith('ws://') || value.startsWith('wss://')) {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
if (value.startsWith('http://') || value.startsWith('https://')) {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return value.startsWith('https://')
|
||||
? `wss://${value.slice('https://'.length)}`
|
||||
: `ws://${value.slice('http://'.length)}`;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname = '') {
|
||||
const host = String(hostname || '').toLowerCase();
|
||||
return host === 'localhost' || host === '127.0.0.1' || host === '[::1]';
|
||||
}
|
||||
|
||||
function isMixedContentWs(url) {
|
||||
try {
|
||||
const pageIsHttps = window.location.protocol === 'https:';
|
||||
if (!pageIsHttps) return false;
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== 'ws:') return false;
|
||||
return !isLoopbackHost(parsed.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createRequestId(op) {
|
||||
return `${op}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
@ -27,6 +67,15 @@ export class WsJsonClient {
|
||||
async open() {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
|
||||
if (this.openPromise) return this.openPromise;
|
||||
if (isMixedContentWs(this.url)) {
|
||||
const error = new Error('Страница открыта по HTTPS, а сервер указан как ws://. Используйте wss:// адрес для Shine сервера.');
|
||||
captureClientError({
|
||||
kind: 'ws_mixed_content_blocked',
|
||||
message: error.message,
|
||||
context: { url: this.url, pageProtocol: window.location.protocol },
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.openPromise = new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(this.url);
|
||||
@ -54,7 +103,6 @@ export class WsJsonClient {
|
||||
});
|
||||
}).finally(() => {
|
||||
this.openPromise = null;
|
||||
this.eventListeners = new Map();
|
||||
});
|
||||
|
||||
return this.openPromise;
|
||||
|
||||
@ -4,6 +4,7 @@ import { clearClientAuthData } from './services/key-vault.js';
|
||||
|
||||
const clone = (value) => JSON.parse(JSON.stringify(value));
|
||||
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
|
||||
const REACTIONS_STORAGE_KEY = 'shine-ui-message-reactions-v2';
|
||||
const INVALID_SESSION_CODES = new Set([
|
||||
'NOT_AUTHENTICATED',
|
||||
'SESSION_NOT_FOUND',
|
||||
@ -13,18 +14,63 @@ const INVALID_SESSION_CODES = new Set([
|
||||
|
||||
function readLocalWsOverrideUrl() {
|
||||
try {
|
||||
const value = new URLSearchParams(window.location.search).get('localWsPort');
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
const explicitWsUrl = String(params.get('wsUrl') || '').trim();
|
||||
if (explicitWsUrl) {
|
||||
if (explicitWsUrl.startsWith('ws://') || explicitWsUrl.startsWith('wss://')) {
|
||||
try {
|
||||
const parsed = new URL(explicitWsUrl);
|
||||
if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return explicitWsUrl;
|
||||
}
|
||||
}
|
||||
if (explicitWsUrl.startsWith('http://') || explicitWsUrl.startsWith('https://')) {
|
||||
try {
|
||||
const parsed = new URL(explicitWsUrl);
|
||||
parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return `${explicitWsUrl.replace(/^http/, 'ws').replace(/\/$/, '')}/ws`;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const value = params.get('localWsPort');
|
||||
const asNum = Number(value);
|
||||
if (!Number.isFinite(asNum)) return '';
|
||||
const port = Math.trunc(asNum);
|
||||
if (port <= 0 || port > 65535) return '';
|
||||
return `ws://localhost:${port}/ws`;
|
||||
const isHttpsPage = window.location.protocol === 'https:';
|
||||
const forceInsecureLocal = params.get('allowInsecureLocalWs') === '1';
|
||||
const scheme = (isHttpsPage && !forceInsecureLocal) ? 'wss' : 'ws';
|
||||
return `${scheme}://localhost:${port}/ws`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const LOCAL_WS_OVERRIDE_URL = readLocalWsOverrideUrl();
|
||||
function inferTunnelWsUrl() {
|
||||
try {
|
||||
const host = String(window.location.host || '').toLowerCase();
|
||||
const isTunnelHost = (
|
||||
host.endsWith('.ngrok-free.dev') ||
|
||||
host.endsWith('.ngrok.io') ||
|
||||
host.endsWith('.trycloudflare.com')
|
||||
);
|
||||
if (!isTunnelHost) return '';
|
||||
const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
return `${scheme}://${window.location.host}/ws`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const LOCAL_WS_OVERRIDE_URL = readLocalWsOverrideUrl() || inferTunnelWsUrl();
|
||||
const DEFAULT_SHINE_SERVER = 'wss://shineup.me/ws';
|
||||
|
||||
function loadStoredSession() {
|
||||
@ -37,6 +83,26 @@ function loadStoredSession() {
|
||||
}
|
||||
}
|
||||
|
||||
function loadStoredReactions() {
|
||||
try {
|
||||
const raw = localStorage.getItem(REACTIONS_STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
||||
return parsed;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function persistStoredReactions(reactions) {
|
||||
try {
|
||||
localStorage.setItem(REACTIONS_STORAGE_KEY, JSON.stringify(reactions || {}));
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
function persistSession(session) {
|
||||
try {
|
||||
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
|
||||
@ -55,6 +121,7 @@ function clearStoredSession() {
|
||||
|
||||
function createInitialState({ withStoredSession = true } = {}) {
|
||||
const storedSession = withStoredSession ? loadStoredSession() : null;
|
||||
const storedReactions = loadStoredReactions();
|
||||
const initialShineServer = LOCAL_WS_OVERRIDE_URL || DEFAULT_SHINE_SERVER;
|
||||
|
||||
return {
|
||||
@ -120,6 +187,7 @@ function createInitialState({ withStoredSession = true } = {}) {
|
||||
channelsFeed: null,
|
||||
channelsIndex: {},
|
||||
localChannelPosts: {},
|
||||
messageReactions: storedReactions,
|
||||
};
|
||||
}
|
||||
|
||||
@ -247,6 +315,10 @@ function resetStateForSignedOut() {
|
||||
state.deviceConnect = next.deviceConnect;
|
||||
state.authUi = next.authUi;
|
||||
state.sessions = next.sessions;
|
||||
state.channelsFeed = next.channelsFeed;
|
||||
state.channelsIndex = next.channelsIndex;
|
||||
state.localChannelPosts = next.localChannelPosts;
|
||||
state.messageReactions = next.messageReactions;
|
||||
}
|
||||
|
||||
export async function terminateCurrentSession({ infoMessage = '' } = {}) {
|
||||
@ -295,3 +367,31 @@ export function addLocalChannelPost(channelId, post) {
|
||||
body: text,
|
||||
});
|
||||
}
|
||||
|
||||
function makeMessageReactionKey(messageRef, login = state.session.login) {
|
||||
const bch = String(messageRef?.blockchainName || '').trim();
|
||||
const blockNumber = Number(messageRef?.blockNumber);
|
||||
const blockHash = String(messageRef?.blockHash || '').trim().toLowerCase();
|
||||
const cleanLogin = String(login || '').trim().toLowerCase();
|
||||
if (!cleanLogin || !bch || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return '';
|
||||
return `${cleanLogin}|${bch}|${blockNumber}|${blockHash}`;
|
||||
}
|
||||
|
||||
export function getMessageReactionState(messageRef) {
|
||||
const key = makeMessageReactionKey(messageRef);
|
||||
if (!key) return '';
|
||||
return state.messageReactions[key] || '';
|
||||
}
|
||||
|
||||
export function setMessageReactionState(messageRef, nextState) {
|
||||
const key = makeMessageReactionKey(messageRef);
|
||||
if (!key) return;
|
||||
const normalized = String(nextState || '').trim().toLowerCase();
|
||||
if (normalized === 'liked' || normalized === 'unliked') {
|
||||
state.messageReactions[key] = normalized;
|
||||
persistStoredReactions(state.messageReactions);
|
||||
return;
|
||||
}
|
||||
delete state.messageReactions[key];
|
||||
persistStoredReactions(state.messageReactions);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -7,9 +7,11 @@ body {
|
||||
width: min(100vw, 430px);
|
||||
height: 100dvh;
|
||||
position: relative;
|
||||
background: linear-gradient(165deg, rgba(16, 22, 36, 0.96), rgba(11, 16, 27, 0.99));
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background:
|
||||
radial-gradient(circle at 16% -8%, rgba(211, 168, 76, 0.16), transparent 38%),
|
||||
linear-gradient(165deg, rgba(10, 21, 44, 0.98), rgba(5, 11, 24, 0.99));
|
||||
border-left: 1px solid rgba(211, 170, 86, 0.2);
|
||||
border-right: 1px solid rgba(211, 170, 86, 0.2);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
@ -36,7 +38,7 @@ body {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
|
||||
background: linear-gradient(180deg, rgba(10, 14, 23, 0) 0%, rgba(10, 14, 23, 0.95) 42%);
|
||||
background: linear-gradient(180deg, rgba(7, 12, 23, 0) 0%, rgba(6, 11, 22, 0.96) 44%);
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
:root {
|
||||
--bg-0: #080b12;
|
||||
--bg-1: #101624;
|
||||
--bg-2: #171f32;
|
||||
--card: #1a2436;
|
||||
--card-soft: #202d45;
|
||||
--line: #2a3854;
|
||||
--text: #ebf1ff;
|
||||
--text-muted: #99a8cb;
|
||||
--accent: #53d8fb;
|
||||
--accent-soft: rgba(83, 216, 251, 0.17);
|
||||
--bg-0: #050c1a;
|
||||
--bg-1: #0a1630;
|
||||
--bg-2: #132346;
|
||||
--card: #162646;
|
||||
--card-soft: #1a2f55;
|
||||
--line: #2f4777;
|
||||
--text: #edf2ff;
|
||||
--text-muted: #9eb0d8;
|
||||
--accent: #d9b56f;
|
||||
--accent-soft: rgba(217, 181, 111, 0.18);
|
||||
--danger: #ff718f;
|
||||
--ok: #84f4a1;
|
||||
--radius-lg: 18px;
|
||||
@ -27,7 +27,10 @@ body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
background: radial-gradient(circle at 22% -10%, #1f355e 0%, var(--bg-0) 45%) fixed;
|
||||
background:
|
||||
radial-gradient(circle at 12% -8%, rgba(214, 176, 90, 0.24), transparent 35%),
|
||||
radial-gradient(circle at 84% 4%, rgba(43, 78, 148, 0.42), transparent 38%),
|
||||
linear-gradient(180deg, #050b18, #030812 70%) fixed;
|
||||
color: var(--text);
|
||||
font-family: var(--font-main);
|
||||
}
|
||||
|
||||
@ -3,8 +3,7 @@ package blockchain;
|
||||
import blockchain.body.*;
|
||||
|
||||
/**
|
||||
* Парсер body выбирает класс по header: type/subType/version,
|
||||
* потому что bodyBytes больше НЕ содержат type/subType/version.
|
||||
* Parser for body record by header type/subType/version.
|
||||
*/
|
||||
public final class BodyRecordParser {
|
||||
|
||||
@ -15,25 +14,26 @@ public final class BodyRecordParser {
|
||||
|
||||
int t = type & 0xFFFF;
|
||||
int v = version & 0xFFFF;
|
||||
int st = subType & 0xFFFF;
|
||||
|
||||
// TECH supports Header v1 and CreateChannel v1/v2.
|
||||
if (t == (CreateChannelBody.TYPE & 0xFFFF)) {
|
||||
if (st == (HeaderBody.SUBTYPE_COMPAT & 0xFFFF) && v == (HeaderBody.VER & 0xFFFF)) {
|
||||
return new HeaderBody(subType, version, bodyBytes).check();
|
||||
}
|
||||
if (st == (CreateChannelBody.SUBTYPE & 0xFFFF)
|
||||
&& (v == (CreateChannelBody.VER & 0xFFFF) || v == (CreateChannelBody.VER2 & 0xFFFF))) {
|
||||
return new CreateChannelBody(subType, version, bodyBytes).check();
|
||||
}
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Unknown TECH body type/version/subType: type=%d ver=%d subType=%d", t, v, st)
|
||||
);
|
||||
}
|
||||
|
||||
int key = (t << 16) | v;
|
||||
|
||||
BodyRecord r = switch (key) {
|
||||
case HeaderBody.KEY -> {
|
||||
int st = subType & 0xFFFF;
|
||||
if (st == (HeaderBody.SUBTYPE_COMPAT & 0xFFFF)) {
|
||||
yield new HeaderBody(subType, version, bodyBytes);
|
||||
}
|
||||
if (st == (CreateChannelBody.SUBTYPE & 0xFFFF)) {
|
||||
yield new CreateChannelBody(subType, version, bodyBytes);
|
||||
}
|
||||
throw new IllegalArgumentException("Unknown TECH subType for type=0 ver=1: subType=" + st);
|
||||
}
|
||||
|
||||
// TEXT type=1 ver=1: выбираем класс по subType
|
||||
case TextBody.KEY -> {
|
||||
int st = subType & 0xFFFF;
|
||||
|
||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
yield new TextLineBody(subType, version, bodyBytes);
|
||||
@ -53,7 +53,7 @@ public final class BodyRecordParser {
|
||||
|
||||
default -> throw new IllegalArgumentException(String.format(
|
||||
"Unknown body type/version from header: type=%d ver=%d subType=%d",
|
||||
t, v, (subType & 0xFFFF)
|
||||
t, v, st
|
||||
));
|
||||
};
|
||||
|
||||
|
||||
@ -68,6 +68,8 @@ public final class MsgSubType {
|
||||
|
||||
/** Лайк (LIKE). */
|
||||
public static final short REACTION_LIKE = 1;
|
||||
/** Снятие лайка (UNLIKE). */
|
||||
public static final short REACTION_UNLIKE = 2;
|
||||
|
||||
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||
|
||||
|
||||
@ -9,48 +9,51 @@ import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* CreateChannelBody — TECH сообщение создания канала.
|
||||
* TECH body for create channel.
|
||||
*
|
||||
* type=0, ver=1 (в заголовке блока)
|
||||
* subType=MsgSubType.TECH_CREATE_CHANNEL (=1)
|
||||
*
|
||||
* Это сообщение идёт по ТЕХ-ЛИНИИ (hasLine):
|
||||
* - prevLineNumber/hash указывают на предыдущее TECH-сообщение (HEADER или прошлый CREATE_CHANNEL)
|
||||
* - thisLineNumber: 1,2,3... (тех-нумерация)
|
||||
*
|
||||
* bodyBytes (BigEndian), новый формат line-prefix:
|
||||
* [4] lineCode (для TECH линии обычно 0)
|
||||
* v1 body bytes:
|
||||
* [4] lineCode
|
||||
* [4] prevLineNumber
|
||||
* [32] prevLineHash32
|
||||
* [4] thisLineNumber
|
||||
* [1] channelNameLen (uint8)
|
||||
* [N] channelName UTF-8 (^[A-Za-z0-9_]+$)
|
||||
* [1] channelNameLen
|
||||
* [N] channelName UTF-8
|
||||
*
|
||||
* Важно:
|
||||
* - канал "0" зарезервирован (создаётся по умолчанию от HEADER), создавать его нельзя.
|
||||
* v2 body bytes:
|
||||
* [4] lineCode
|
||||
* [4] prevLineNumber
|
||||
* [32] prevLineHash32
|
||||
* [4] thisLineNumber
|
||||
* [1] channelNameLen
|
||||
* [N] channelName UTF-8
|
||||
* [2] channelDescriptionLen
|
||||
* [M] channelDescription UTF-8 (0..200 bytes)
|
||||
*/
|
||||
public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
||||
|
||||
public static final short TYPE = 0;
|
||||
public static final short VER = 1;
|
||||
public static final short VER2 = 2;
|
||||
|
||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||
public static final int KEY_V2 = ((TYPE & 0xFFFF) << 16) | (VER2 & 0xFFFF);
|
||||
|
||||
public static final short SUBTYPE = MsgSubType.TECH_CREATE_CHANNEL;
|
||||
|
||||
private static final byte[] ZERO32 = new byte[32];
|
||||
private static final int MAX_NAME_LENGTH = 32;
|
||||
private static final int MAX_DESCRIPTION_UTF8_LEN = 200;
|
||||
|
||||
public final short subType; // из header
|
||||
public final short version; // из header
|
||||
public final short subType;
|
||||
public final short version;
|
||||
|
||||
// line
|
||||
public final int lineCode;
|
||||
public final int prevLineNumber;
|
||||
public final byte[] prevLineHash32; // 32
|
||||
public final byte[] prevLineHash32;
|
||||
public final int thisLineNumber;
|
||||
|
||||
// payload
|
||||
public final String channelName;
|
||||
public final String channelDescription;
|
||||
|
||||
public CreateChannelBody(short subType, short version, byte[] bodyBytes) {
|
||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||
@ -58,14 +61,14 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
||||
this.subType = subType;
|
||||
this.version = version;
|
||||
|
||||
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("CreateChannelBody version must be 1, got=" + (this.version & 0xFFFF));
|
||||
int ver = this.version & 0xFFFF;
|
||||
if (ver != (VER & 0xFFFF) && ver != (VER2 & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("CreateChannelBody version must be 1 or 2, got=" + ver);
|
||||
}
|
||||
if ((this.subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1), got=" + (this.subType & 0xFFFF));
|
||||
}
|
||||
|
||||
// минимум: lineCode(4) + line(4+32+4) + nameLen(1) + name(1)
|
||||
if (bodyBytes.length < 4 + (4 + 32 + 4) + 1 + 1) {
|
||||
throw new IllegalArgumentException("CreateChannelBody too short");
|
||||
}
|
||||
@ -73,7 +76,6 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
this.lineCode = bb.getInt();
|
||||
|
||||
this.prevLineNumber = bb.getInt();
|
||||
|
||||
this.prevLineHash32 = new byte[32];
|
||||
@ -83,16 +85,44 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
||||
|
||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
||||
if (nameLen <= 0) throw new IllegalArgumentException("channelNameLen is 0");
|
||||
if (bb.remaining() != nameLen) {
|
||||
if (bb.remaining() < nameLen) {
|
||||
throw new IllegalArgumentException("CreateChannelBody tail mismatch: remaining=" + bb.remaining() + " nameLen=" + nameLen);
|
||||
}
|
||||
|
||||
byte[] nameBytes = new byte[nameLen];
|
||||
bb.get(nameBytes);
|
||||
|
||||
this.channelName = new String(nameBytes, StandardCharsets.UTF_8);
|
||||
|
||||
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
||||
if (ver == (VER2 & 0xFFFF)) {
|
||||
if (bb.remaining() < 2) {
|
||||
throw new IllegalArgumentException("CreateChannelBody v2 missing channelDescriptionLen");
|
||||
}
|
||||
|
||||
int descriptionLen = Short.toUnsignedInt(bb.getShort());
|
||||
if (descriptionLen > MAX_DESCRIPTION_UTF8_LEN) {
|
||||
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
|
||||
}
|
||||
if (bb.remaining() != descriptionLen) {
|
||||
throw new IllegalArgumentException("CreateChannelBody v2 tail mismatch: remaining=" + bb.remaining() + " descriptionLen=" + descriptionLen);
|
||||
}
|
||||
|
||||
if (descriptionLen == 0) {
|
||||
this.channelDescription = "";
|
||||
} else {
|
||||
byte[] descriptionBytes = new byte[descriptionLen];
|
||||
bb.get(descriptionBytes);
|
||||
this.channelDescription = normalizeDescription(new String(descriptionBytes, StandardCharsets.UTF_8));
|
||||
}
|
||||
if (bb.remaining() != 0) {
|
||||
throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.channelDescription = "";
|
||||
if (bb.remaining() != 0) {
|
||||
throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
||||
}
|
||||
}
|
||||
|
||||
public CreateChannelBody(int lineCode,
|
||||
@ -100,11 +130,30 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
||||
byte[] prevLineHash32,
|
||||
int thisLineNumber,
|
||||
String channelName) {
|
||||
this(lineCode, prevLineNumber, prevLineHash32, thisLineNumber, channelName, "", VER);
|
||||
}
|
||||
|
||||
public CreateChannelBody(int lineCode,
|
||||
int prevLineNumber,
|
||||
byte[] prevLineHash32,
|
||||
int thisLineNumber,
|
||||
String channelName,
|
||||
String channelDescription) {
|
||||
this(lineCode, prevLineNumber, prevLineHash32, thisLineNumber, channelName, channelDescription, VER2);
|
||||
}
|
||||
|
||||
private CreateChannelBody(int lineCode,
|
||||
int prevLineNumber,
|
||||
byte[] prevLineHash32,
|
||||
int thisLineNumber,
|
||||
String channelName,
|
||||
String channelDescription,
|
||||
short version) {
|
||||
Objects.requireNonNull(channelName, "channelName == null");
|
||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||
|
||||
this.subType = SUBTYPE;
|
||||
this.version = VER;
|
||||
this.version = version;
|
||||
|
||||
this.lineCode = lineCode;
|
||||
this.prevLineNumber = prevLineNumber;
|
||||
@ -112,46 +161,73 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
||||
this.thisLineNumber = thisLineNumber;
|
||||
|
||||
this.channelName = channelName;
|
||||
this.channelDescription = channelDescription == null ? "" : channelDescription;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CreateChannelBody check() {
|
||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||
|
||||
if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF))
|
||||
if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1)");
|
||||
}
|
||||
|
||||
if (channelName == null || channelName.isBlank())
|
||||
String normalizedName = normalizeDisplayName(channelName);
|
||||
if (normalizedName.isEmpty()) {
|
||||
throw new IllegalArgumentException("channelName is blank");
|
||||
}
|
||||
|
||||
if (!channelName.matches("^[A-Za-z0-9_]+$"))
|
||||
throw new IllegalArgumentException("channelName must match ^[A-Za-z0-9_]+$");
|
||||
int cpLen = normalizedName.codePointCount(0, normalizedName.length());
|
||||
if (cpLen > MAX_NAME_LENGTH) {
|
||||
throw new IllegalArgumentException("channelName length must be <=32");
|
||||
}
|
||||
|
||||
if ("0".equals(channelName))
|
||||
throw new IllegalArgumentException("channelName \"0\" is reserved");
|
||||
String normalizedDescription = normalizeDescription(channelDescription);
|
||||
byte[] descUtf8 = normalizedDescription.getBytes(StandardCharsets.UTF_8);
|
||||
if (descUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
|
||||
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
|
||||
}
|
||||
|
||||
// tech-line: prev обязателен (минимум HEADER=0)
|
||||
if (prevLineNumber < 0)
|
||||
if (prevLineNumber < 0) {
|
||||
throw new IllegalArgumentException("prevLineNumber must be >=0 for CreateChannelBody");
|
||||
if (prevLineHash32 == null || prevLineHash32.length != 32)
|
||||
}
|
||||
if (prevLineHash32 == null || prevLineHash32.length != 32) {
|
||||
throw new IllegalArgumentException("prevLineHash32 invalid");
|
||||
if (thisLineNumber <= 0)
|
||||
}
|
||||
if (thisLineNumber <= 0) {
|
||||
throw new IllegalArgumentException("thisLineNumber must be >=1 for CreateChannelBody");
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
private static String normalizeDisplayName(String value) {
|
||||
if (value == null) return "";
|
||||
return value.trim().replaceAll("\\s+", " ");
|
||||
}
|
||||
|
||||
private static String normalizeDescription(String value) {
|
||||
if (value == null) return "";
|
||||
return value.trim().replaceAll("\\s+", " ");
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] toBytes() {
|
||||
byte[] nameUtf8 = channelName.getBytes(StandardCharsets.UTF_8);
|
||||
if (nameUtf8.length == 0 || nameUtf8.length > 255)
|
||||
byte[] nameUtf8 = normalizeDisplayName(channelName).getBytes(StandardCharsets.UTF_8);
|
||||
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
||||
throw new IllegalArgumentException("channelName utf8 len must be 1..255");
|
||||
}
|
||||
|
||||
int cap = 4 + (4 + 32 + 4) + 1 + nameUtf8.length;
|
||||
boolean isV2 = (version & 0xFFFF) == (VER2 & 0xFFFF);
|
||||
byte[] descriptionUtf8 = normalizeDescription(channelDescription).getBytes(StandardCharsets.UTF_8);
|
||||
if (descriptionUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
|
||||
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
|
||||
}
|
||||
|
||||
int cap = 4 + (4 + 32 + 4) + 1 + nameUtf8.length + (isV2 ? 2 + descriptionUtf8.length : 0);
|
||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
bb.putInt(lineCode);
|
||||
|
||||
bb.putInt(prevLineNumber);
|
||||
bb.put(prevLineHash32 == null ? ZERO32 : Arrays.copyOf(prevLineHash32, 32));
|
||||
bb.putInt(thisLineNumber);
|
||||
@ -159,12 +235,27 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
||||
bb.put((byte) nameUtf8.length);
|
||||
bb.put(nameUtf8);
|
||||
|
||||
if (isV2) {
|
||||
bb.putShort((short) (descriptionUtf8.length & 0xFFFF));
|
||||
if (descriptionUtf8.length > 0) {
|
||||
bb.put(descriptionUtf8);
|
||||
}
|
||||
}
|
||||
|
||||
return bb.array();
|
||||
}
|
||||
|
||||
/* ====================== BodyHasLine ====================== */
|
||||
@Override public int lineCode() { return lineCode; }
|
||||
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
|
||||
@Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); }
|
||||
@Override public int lineSeq() { return thisLineNumber; }
|
||||
@Override
|
||||
public int lineCode() { return lineCode; }
|
||||
|
||||
@Override
|
||||
public int prevLineBlockGlobalNumber() { return prevLineNumber; }
|
||||
|
||||
@Override
|
||||
public byte[] prevLineBlockHash32() {
|
||||
return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int lineSeq() { return thisLineNumber; }
|
||||
}
|
||||
@ -13,6 +13,7 @@ import java.util.Objects;
|
||||
*
|
||||
* subType (в заголовке блока):
|
||||
* 1 = LIKE
|
||||
* 2 = UNLIKE
|
||||
*
|
||||
* bodyBytes (BigEndian), новый формат:
|
||||
* [1] toBlockchainNameLen (uint8)
|
||||
@ -45,7 +46,7 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
|
||||
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("ReactionBody version must be 1, got=" + (this.version & 0xFFFF));
|
||||
}
|
||||
if ((this.subType & 0xFFFF) != (MsgSubType.REACTION_LIKE & 0xFFFF)) {
|
||||
if (!isSupportedSubType(this.subType)) {
|
||||
throw new IllegalArgumentException("Bad reaction subType: " + (this.subType & 0xFFFF));
|
||||
}
|
||||
|
||||
@ -88,7 +89,7 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
|
||||
|
||||
@Override
|
||||
public ReactionBody check() {
|
||||
if ((subType & 0xFFFF) != (MsgSubType.REACTION_LIKE & 0xFFFF))
|
||||
if (!isSupportedSubType(subType))
|
||||
throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF));
|
||||
|
||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
||||
@ -123,4 +124,10 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
|
||||
@Override public String toBchName() { return toBlockchainName; }
|
||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||
|
||||
private static boolean isSupportedSubType(short subType) {
|
||||
int st = subType & 0xFFFF;
|
||||
return st == (MsgSubType.REACTION_LIKE & 0xFFFF)
|
||||
|| st == (MsgSubType.REACTION_UNLIKE & 0xFFFF);
|
||||
}
|
||||
}
|
||||
@ -36,6 +36,7 @@ public final class DatabaseInitializer {
|
||||
/* ===================== REACTION (msg_type=2) ===================== */
|
||||
|
||||
public static final short REACTION_LIKE = 1;
|
||||
public static final short REACTION_UNLIKE = 2;
|
||||
|
||||
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||
public static final short CONNECTION_FRIEND = 10;
|
||||
@ -273,12 +274,12 @@ public final class DatabaseInitializer {
|
||||
rel_type INTEGER NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
to_bch_name TEXT NOT NULL,
|
||||
to_block_number INTEGER,
|
||||
to_block_hash BLOB,
|
||||
to_block_number INTEGER NOT NULL,
|
||||
to_block_hash BLOB NOT NULL,
|
||||
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login),
|
||||
|
||||
UNIQUE (login, rel_type, to_login)
|
||||
UNIQUE (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
|
||||
);
|
||||
""");
|
||||
|
||||
@ -297,6 +298,11 @@ public final class DatabaseInitializer {
|
||||
ON connections_state (login, to_login);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_connections_state_target
|
||||
ON connections_state (login, rel_type, to_bch_name, to_block_number);
|
||||
""");
|
||||
|
||||
// 8) message_stats
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS message_stats (
|
||||
@ -328,7 +334,70 @@ public final class DatabaseInitializer {
|
||||
ON message_stats (to_login);
|
||||
""");
|
||||
|
||||
// 9) direct_messages
|
||||
// 8.1) reactions_state (идемпотентный LIKE/UNLIKE per actor/target)
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS reactions_state (
|
||||
from_login TEXT NOT NULL,
|
||||
from_bch_name TEXT NOT NULL,
|
||||
reaction_type INTEGER NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
to_bch_name TEXT NOT NULL,
|
||||
to_block_number INTEGER NOT NULL,
|
||||
to_block_hash BLOB NOT NULL,
|
||||
last_sub_type INTEGER NOT NULL,
|
||||
|
||||
UNIQUE (
|
||||
from_login,
|
||||
from_bch_name,
|
||||
reaction_type,
|
||||
to_login,
|
||||
to_bch_name,
|
||||
to_block_number,
|
||||
to_block_hash
|
||||
)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_reactions_state_target
|
||||
ON reactions_state (to_bch_name, to_block_number, to_block_hash);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_reactions_state_actor
|
||||
ON reactions_state (from_login, from_bch_name, reaction_type);
|
||||
""");
|
||||
|
||||
// 9) channel_names_state (global normalized channel names)
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS channel_names_state (
|
||||
slug TEXT NOT NULL PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
channel_description TEXT NOT NULL DEFAULT '',
|
||||
owner_login TEXT NOT NULL,
|
||||
owner_bch_name TEXT NOT NULL,
|
||||
channel_root_block_number INTEGER NOT NULL,
|
||||
channel_root_block_hash BLOB NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_slug
|
||||
ON channel_names_state (slug);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_target
|
||||
ON channel_names_state (owner_bch_name, channel_root_block_number, channel_root_block_hash);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_names_state_owner
|
||||
ON channel_names_state (owner_login, owner_bch_name);
|
||||
""");
|
||||
|
||||
// 10) direct_messages
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS direct_messages (
|
||||
message_id TEXT NOT NULL PRIMARY KEY,
|
||||
@ -351,7 +420,7 @@ public final class DatabaseInitializer {
|
||||
ON direct_messages (from_login, created_at_ms);
|
||||
""");
|
||||
|
||||
// 10) user_push_tokens
|
||||
// 11) user_push_tokens
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS user_push_tokens (
|
||||
token_id TEXT NOT NULL PRIMARY KEY,
|
||||
|
||||
@ -1,52 +1,55 @@
|
||||
package shine.db;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* DatabaseTriggersInstaller — устанавливает триггеры, которые поддерживают бизнес-логику БД.
|
||||
* DatabaseTriggersInstaller — устанавливает триггеры, которые поддерживают бизнес-логику БД.
|
||||
*
|
||||
* Мы специально сделали триггеры максимально "совместимыми":
|
||||
* - НЕТ динамических сообщений в RAISE(...): только фиксированные строки.
|
||||
* (Некоторые SQLite-сборки / просмотрщики падают на "||" внутри RAISE.)
|
||||
* - НЕТ UPSERT "ON CONFLICT DO UPDATE" — вместо него:
|
||||
* Мы специально сделали триггеры максимально "совместимыми":
|
||||
* - НЕТ динамических сообщений в RAISE(...): только фиксированные строки.
|
||||
* (Некоторые SQLite-сборки / просмотрщики падают на "||" внутри RAISE.)
|
||||
* - НЕТ UPSERT "ON CONFLICT DO UPDATE" — вместо него:
|
||||
* INSERT OR IGNORE + UPDATE
|
||||
* (Старые SQLite не знают UPSERT.)
|
||||
* (Старые SQLite не знают UPSERT.)
|
||||
*
|
||||
* =============================================================================
|
||||
* ОПИСАНИЕ ТРИГГЕРОВ
|
||||
* РћРџРРЎРђРќРР• РўР РГГЕРОВ
|
||||
* =============================================================================
|
||||
*
|
||||
* [1] trg_blocks_line_integrity_bi (BEFORE INSERT ON blocks)
|
||||
* Контроль целостности "линий" (line_code / prev_line_number / prev_line_hash / this_line_number).
|
||||
* Контроль целостности "линий" (line_code / prev_line_number / prev_line_hash / this_line_number).
|
||||
*
|
||||
* Зачем это нужно:
|
||||
* - В каналах/ветках/действиях ты хочешь иметь "линейную" последовательность,
|
||||
* где каждый следующий блок явно ссылается на предыдущий блок линии
|
||||
* и подтверждает, что ссылка не подменена.
|
||||
* Зачем это нужно:
|
||||
* - В каналах/ветках/действиях ты хочешь иметь "линейную" последовательность,
|
||||
* где каждый следующий блок явно ссылается на предыдущий блок линии
|
||||
* и подтверждает, что ссылка не подменена.
|
||||
*
|
||||
* Когда срабатывает:
|
||||
* - ТОЛЬКО если при вставке передано ХОТЯ БЫ ОДНО из line-полей.
|
||||
* - Если line-поля не переданы — триггер вообще не работает (это важно).
|
||||
* Когда срабатывает:
|
||||
* - ТОЛЬКО если при вставке передано ХОТЯ БЫ ОДНО из line-полей.
|
||||
* - Если line-поля не переданы — триггер вообще не работает (это важно).
|
||||
*
|
||||
* Что проверяет:
|
||||
* A) line-поля допускаются только для msg_type:
|
||||
* Что проверяет:
|
||||
* A) line-поля допускаются только для msg_type:
|
||||
* 0 (TECH), 1 (TEXT), 3 (CONNECTION), 4 (USER_PARAM)
|
||||
* B) Если пришло хоть одно line-поле — обязаны прийти ВСЕ 4 (никаких "частичных")
|
||||
* C) prev-блок линии существует в той же цепочке bch_name
|
||||
* D) prev_hash совпадает с block_hash найденного prev-блока
|
||||
* E) line_code корректный:
|
||||
* - либо первый шаг после root: prev_line_number == line_code
|
||||
* - либо prev уже принадлежит этой линии: p.line_code == NEW.line_code
|
||||
* B) Если пришло хоть одно line-поле — обязаны прийти ВСЕ 4 (никаких "частичных")
|
||||
* C) prev-блок линии существует в той же цепочке bch_name
|
||||
* D) prev_hash совпадает с block_hash найденного prev-блока
|
||||
* E) line_code корректный:
|
||||
* - либо первый шаг после root: prev_line_number == line_code
|
||||
* - либо prev уже принадлежит этой линии: p.line_code == NEW.line_code
|
||||
* F) this_line_number:
|
||||
* - первый шаг после root:
|
||||
* - первый шаг после root:
|
||||
* TEXT: this_line_number = 0
|
||||
* TECH/CONNECTION/USER_PARAM: this_line_number = 1
|
||||
* - обычный шаг:
|
||||
* TEXT: допускаем same или +1 (чтобы "edit" мог не двигать шаг)
|
||||
* TECH/CONNECTION/USER_PARAM: строго prev.this + 1
|
||||
* - обычный шаг:
|
||||
* TEXT: допускаем same или +1 (чтобы "edit" мог не двигать шаг)
|
||||
* TECH/CONNECTION/USER_PARAM: строго prev.this + 1
|
||||
*
|
||||
* Какие ошибки кидает:
|
||||
* Какие ошибки кидает:
|
||||
* - LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE
|
||||
* - LINE_ERR_PARTIAL_FIELDS
|
||||
* - LINE_ERR_NO_PREV
|
||||
@ -56,28 +59,30 @@ import java.sql.Statement;
|
||||
* - LINE_ERR_THIS_LINE_BAD_STEP
|
||||
*
|
||||
* [2] trg_blocks_connection_state_ai (AFTER INSERT ON blocks WHEN msg_type=3)
|
||||
* Поддерживает таблицу connections_state как "текущее состояние" отношений:
|
||||
* - FRIEND/CONTACT/FOLLOW -> добавить/обновить состояние
|
||||
* - UNFRIEND/UNCONTACT/UNFOLLOW -> удалить соответствующее "позитивное" состояние
|
||||
* Поддерживает таблицу connections_state как "текущее состояние" отношений:
|
||||
* - FRIEND/CONTACT/FOLLOW -> добавить/обновить состояние
|
||||
* - UNFRIEND/UNCONTACT/UNFOLLOW -> удалить соответствующее "позитивное" состояние
|
||||
*
|
||||
* [3] trg_blocks_message_stats_like_ai (AFTER INSERT ON blocks WHEN msg_type=2 AND sub_type=LIKE)
|
||||
* Поддерживает likes_count в message_stats для цели (to_*).
|
||||
* Поддерживает likes_count в message_stats для цели (to_*).
|
||||
*
|
||||
* [4] trg_blocks_message_stats_reply_ai (AFTER INSERT ON blocks WHEN msg_type=1 AND sub_type=REPLY)
|
||||
* Поддерживает replies_count в message_stats.
|
||||
* Поддерживает replies_count в message_stats.
|
||||
*
|
||||
* [5] trg_blocks_edit_apply_ai (AFTER INSERT ON blocks WHEN msg_type=1 AND sub_type=EDIT)
|
||||
* Логика edit:
|
||||
* - помечает исходный блок edited_by_block_number = NEW.block_number
|
||||
* - увеличивает edits_count в message_stats
|
||||
* Логика edit:
|
||||
* - помечает исходный блок edited_by_block_number = NEW.block_number
|
||||
* - увеличивает edits_count в message_stats
|
||||
*/
|
||||
public final class DatabaseTriggersInstaller {
|
||||
|
||||
private DatabaseTriggersInstaller() {}
|
||||
|
||||
public static void createAllTriggers(Statement st) throws SQLException {
|
||||
// На всякий случай убираем старые "криво названные" триггеры,
|
||||
// если они когда-то попадали в БД.
|
||||
dropTriggersByPrefix(st, "trg_blocks_");
|
||||
|
||||
// На всякий случай убираем старые "криво названные" триггеры,
|
||||
// если они когда-то попадали в БД.
|
||||
st.executeUpdate("DROP TRIGGER IF EXISTS trg_block_lini_integriti_by;");
|
||||
st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_line_integrity_bi;");
|
||||
|
||||
@ -93,6 +98,24 @@ public final class DatabaseTriggersInstaller {
|
||||
createEditApplyTrigger(st);
|
||||
}
|
||||
|
||||
private static void dropTriggersByPrefix(Statement st, String prefix) throws SQLException {
|
||||
List<String> triggerNames = new ArrayList<>();
|
||||
String sql = "SELECT name FROM sqlite_master WHERE type='trigger' AND name LIKE '" + prefix + "%'";
|
||||
try (ResultSet rs = st.executeQuery(sql)) {
|
||||
while (rs.next()) {
|
||||
String name = rs.getString("name");
|
||||
if (name != null && !name.isBlank()) {
|
||||
triggerNames.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (String name : triggerNames) {
|
||||
String safeName = name.replace("\"", "\"\"");
|
||||
st.executeUpdate("DROP TRIGGER IF EXISTS \"" + safeName + "\"");
|
||||
}
|
||||
}
|
||||
|
||||
private static void createLineIntegrityTrigger(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE TRIGGER IF NOT EXISTS trg_blocks_line_integrity_bi
|
||||
@ -182,22 +205,40 @@ public final class DatabaseTriggersInstaller {
|
||||
WHEN NEW.msg_type = 3
|
||||
BEGIN
|
||||
-- FRIEND/CONTACT/FOLLOW:
|
||||
-- 1) если записи нет — создаём
|
||||
-- 1) если записи нет — создаём
|
||||
INSERT OR IGNORE INTO connections_state (
|
||||
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
|
||||
)
|
||||
SELECT
|
||||
NEW.login,
|
||||
NEW.msg_sub_type,
|
||||
COALESCE(
|
||||
NEW.to_login,
|
||||
CASE
|
||||
WHEN NEW.to_bch_name IS NOT NULL
|
||||
AND length(NEW.to_bch_name) > 4
|
||||
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
|
||||
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
|
||||
ELSE NULL
|
||||
END
|
||||
),
|
||||
NEW.to_bch_name,
|
||||
NEW.to_block_number,
|
||||
NEW.to_block_hash
|
||||
WHERE NEW.msg_sub_type IN (%d, %d, %d)
|
||||
AND NEW.to_login IS NOT NULL
|
||||
AND COALESCE(
|
||||
NEW.to_login,
|
||||
CASE
|
||||
WHEN NEW.to_bch_name IS NOT NULL
|
||||
AND length(NEW.to_bch_name) > 4
|
||||
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
|
||||
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
|
||||
ELSE NULL
|
||||
END
|
||||
) IS NOT NULL
|
||||
AND NEW.to_bch_name IS NOT NULL;
|
||||
|
||||
-- 2) если запись есть — обновляем актуальные to_*
|
||||
-- 2) если запись есть — обновляем актуальные to_*
|
||||
UPDATE connections_state
|
||||
SET
|
||||
to_bch_name = NEW.to_bch_name,
|
||||
@ -205,27 +246,64 @@ public final class DatabaseTriggersInstaller {
|
||||
to_block_hash = NEW.to_block_hash
|
||||
WHERE login = NEW.login
|
||||
AND rel_type = NEW.msg_sub_type
|
||||
AND to_login = NEW.to_login
|
||||
AND NEW.msg_sub_type IN (%d, %d, %d)
|
||||
AND NEW.to_login IS NOT NULL
|
||||
AND to_login = COALESCE(
|
||||
NEW.to_login,
|
||||
CASE
|
||||
WHEN NEW.to_bch_name IS NOT NULL
|
||||
AND length(NEW.to_bch_name) > 4
|
||||
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
|
||||
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
|
||||
ELSE NULL
|
||||
END
|
||||
)
|
||||
AND NEW.msg_sub_type IN (%d, %d)
|
||||
AND COALESCE(
|
||||
NEW.to_login,
|
||||
CASE
|
||||
WHEN NEW.to_bch_name IS NOT NULL
|
||||
AND length(NEW.to_bch_name) > 4
|
||||
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
|
||||
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
|
||||
ELSE NULL
|
||||
END
|
||||
) IS NOT NULL
|
||||
AND NEW.to_bch_name IS NOT NULL;
|
||||
|
||||
-- UNFRIEND/UNCONTACT/UNFOLLOW:
|
||||
-- удаляем соответствующее "позитивное" состояние
|
||||
-- удаляем соответствующее "позитивное" состояние
|
||||
DELETE FROM connections_state
|
||||
WHERE login = NEW.login
|
||||
AND to_login = NEW.to_login
|
||||
AND to_login = COALESCE(
|
||||
NEW.to_login,
|
||||
CASE
|
||||
WHEN NEW.to_bch_name IS NOT NULL
|
||||
AND length(NEW.to_bch_name) > 4
|
||||
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
|
||||
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
|
||||
ELSE NULL
|
||||
END
|
||||
)
|
||||
AND rel_type = CASE NEW.msg_sub_type
|
||||
WHEN %d THEN %d
|
||||
WHEN %d THEN %d
|
||||
WHEN %d THEN %d
|
||||
ELSE rel_type
|
||||
END
|
||||
AND COALESCE(
|
||||
NEW.to_login,
|
||||
CASE
|
||||
WHEN NEW.to_bch_name IS NOT NULL
|
||||
AND length(NEW.to_bch_name) > 4
|
||||
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
|
||||
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
|
||||
ELSE NULL
|
||||
END
|
||||
) IS NOT NULL
|
||||
AND NEW.msg_sub_type IN (%d, %d, %d);
|
||||
END;
|
||||
""".formatted(
|
||||
FRIEND, CONTACT, FOLLOW,
|
||||
FRIEND, CONTACT, FOLLOW,
|
||||
FRIEND, CONTACT,
|
||||
|
||||
UNFRIEND, FRIEND,
|
||||
UNCONTACT, CONTACT,
|
||||
@ -237,13 +315,14 @@ public final class DatabaseTriggersInstaller {
|
||||
|
||||
private static void createMessageStatsLikeTrigger(Statement st) throws SQLException {
|
||||
int LIKE = (int) DatabaseInitializer.REACTION_LIKE;
|
||||
int UNLIKE = (int) DatabaseInitializer.REACTION_UNLIKE;
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE TRIGGER IF NOT EXISTS trg_blocks_message_stats_like_ai
|
||||
AFTER INSERT ON blocks
|
||||
WHEN NEW.msg_type = 2 AND NEW.msg_sub_type = %d
|
||||
WHEN NEW.msg_type = 2 AND NEW.msg_sub_type IN (%d, %d)
|
||||
BEGIN
|
||||
-- создаём строку, если её не было
|
||||
-- ensure target stats row exists
|
||||
INSERT OR IGNORE INTO message_stats (
|
||||
to_login, to_bch_name, to_block_number, to_block_hash,
|
||||
likes_count, replies_count, edits_count
|
||||
@ -256,9 +335,48 @@ public final class DatabaseTriggersInstaller {
|
||||
AND NEW.to_block_number IS NOT NULL
|
||||
AND NEW.to_block_hash IS NOT NULL;
|
||||
|
||||
-- +1 like
|
||||
-- apply delta by state transition (none/unlike->like = +1, like->unlike = -1)
|
||||
UPDATE message_stats
|
||||
SET likes_count = likes_count + 1
|
||||
SET likes_count = MAX(
|
||||
0,
|
||||
likes_count + (
|
||||
CASE
|
||||
WHEN NEW.msg_sub_type = %d
|
||||
AND COALESCE((
|
||||
SELECT b.msg_sub_type
|
||||
FROM blocks b
|
||||
WHERE b.login = NEW.login
|
||||
AND b.bch_name = NEW.bch_name
|
||||
AND b.msg_type = 2
|
||||
AND b.to_login = NEW.to_login
|
||||
AND b.to_bch_name = NEW.to_bch_name
|
||||
AND b.to_block_number = NEW.to_block_number
|
||||
AND b.to_block_hash = NEW.to_block_hash
|
||||
AND b.block_number < NEW.block_number
|
||||
ORDER BY b.block_number DESC
|
||||
LIMIT 1
|
||||
), -1) <> %d
|
||||
THEN 1
|
||||
WHEN NEW.msg_sub_type = %d
|
||||
AND COALESCE((
|
||||
SELECT b.msg_sub_type
|
||||
FROM blocks b
|
||||
WHERE b.login = NEW.login
|
||||
AND b.bch_name = NEW.bch_name
|
||||
AND b.msg_type = 2
|
||||
AND b.to_login = NEW.to_login
|
||||
AND b.to_bch_name = NEW.to_bch_name
|
||||
AND b.to_block_number = NEW.to_block_number
|
||||
AND b.to_block_hash = NEW.to_block_hash
|
||||
AND b.block_number < NEW.block_number
|
||||
ORDER BY b.block_number DESC
|
||||
LIMIT 1
|
||||
), -1) = %d
|
||||
THEN -1
|
||||
ELSE 0
|
||||
END
|
||||
)
|
||||
)
|
||||
WHERE to_login = NEW.to_login
|
||||
AND to_bch_name = NEW.to_bch_name
|
||||
AND to_block_number = NEW.to_block_number
|
||||
@ -267,8 +385,43 @@ public final class DatabaseTriggersInstaller {
|
||||
AND NEW.to_bch_name IS NOT NULL
|
||||
AND NEW.to_block_number IS NOT NULL
|
||||
AND NEW.to_block_hash IS NOT NULL;
|
||||
|
||||
-- persist latest actor->target reaction state
|
||||
INSERT OR IGNORE INTO reactions_state (
|
||||
from_login, from_bch_name, reaction_type,
|
||||
to_login, to_bch_name, to_block_number, to_block_hash,
|
||||
last_sub_type
|
||||
)
|
||||
SELECT
|
||||
NEW.login, NEW.bch_name, %d,
|
||||
NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash,
|
||||
NEW.msg_sub_type
|
||||
WHERE NEW.to_login IS NOT NULL
|
||||
AND NEW.to_bch_name IS NOT NULL
|
||||
AND NEW.to_block_number IS NOT NULL
|
||||
AND NEW.to_block_hash IS NOT NULL;
|
||||
|
||||
UPDATE reactions_state
|
||||
SET last_sub_type = NEW.msg_sub_type
|
||||
WHERE from_login = NEW.login
|
||||
AND from_bch_name = NEW.bch_name
|
||||
AND reaction_type = %d
|
||||
AND to_login = NEW.to_login
|
||||
AND to_bch_name = NEW.to_bch_name
|
||||
AND to_block_number = NEW.to_block_number
|
||||
AND to_block_hash = NEW.to_block_hash
|
||||
AND NEW.to_login IS NOT NULL
|
||||
AND NEW.to_bch_name IS NOT NULL
|
||||
AND NEW.to_block_number IS NOT NULL
|
||||
AND NEW.to_block_hash IS NOT NULL;
|
||||
END;
|
||||
""".formatted(LIKE));
|
||||
""".formatted(
|
||||
LIKE, UNLIKE,
|
||||
LIKE, LIKE,
|
||||
UNLIKE, LIKE,
|
||||
LIKE,
|
||||
LIKE
|
||||
));
|
||||
}
|
||||
|
||||
private static void createMessageStatsReplyTrigger(Statement st) throws SQLException {
|
||||
@ -314,7 +467,7 @@ public final class DatabaseTriggersInstaller {
|
||||
AFTER INSERT ON blocks
|
||||
WHEN NEW.msg_type = 1 AND NEW.msg_sub_type IN (%d, %d)
|
||||
BEGIN
|
||||
-- 1) помечаем исходный блок, что его "перекрыл" этот edit
|
||||
-- 1) помечаем исходный блок, что его "перекрыл" этот edit
|
||||
UPDATE blocks
|
||||
SET edited_by_block_number = NEW.block_number
|
||||
WHERE login = NEW.login
|
||||
@ -322,7 +475,7 @@ public final class DatabaseTriggersInstaller {
|
||||
AND block_number = NEW.to_block_number
|
||||
AND NEW.to_block_number IS NOT NULL;
|
||||
|
||||
-- 2) создаём stats-строку если её не было
|
||||
-- 2) создаём stats-строку если её не было
|
||||
INSERT OR IGNORE INTO message_stats (
|
||||
to_login, to_bch_name, to_block_number, to_block_hash,
|
||||
likes_count, replies_count, edits_count
|
||||
@ -350,3 +503,4 @@ public final class DatabaseTriggersInstaller {
|
||||
""".formatted(EDIT_POST, EDIT_REPLY));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -34,6 +34,8 @@ public final class MsgSubType {
|
||||
|
||||
/** Лайк (LIKE). */
|
||||
public static final short REACTION_LIKE = 1;
|
||||
/** Снятие лайка (UNLIKE). */
|
||||
public static final short REACTION_UNLIKE = 2;
|
||||
|
||||
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||
/**
|
||||
|
||||
@ -7,6 +7,7 @@ import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
|
||||
@ -37,6 +38,7 @@ public final class SqliteDbController {
|
||||
}
|
||||
|
||||
this.jdbcUrl = "jdbc:sqlite:" + dbPath;
|
||||
ensureSchemaUpgrades();
|
||||
}
|
||||
|
||||
public static SqliteDbController getInstance() {
|
||||
@ -67,4 +69,223 @@ public final class SqliteDbController {
|
||||
public void close() {
|
||||
// no-op
|
||||
}
|
||||
|
||||
private void ensureSchemaUpgrades() {
|
||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
||||
Statement st = c.createStatement()) {
|
||||
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
st.execute("PRAGMA foreign_keys = OFF");
|
||||
|
||||
ensureReactionsStateTable(st);
|
||||
|
||||
if (!tableExists(c, "connections_state")) {
|
||||
createConnectionsStateTable(st);
|
||||
} else if (needsConnectionsStateUpgrade(c)) {
|
||||
rebuildConnectionsStateTable(st);
|
||||
}
|
||||
ensureChannelNamesStateTable(st);
|
||||
ensureChannelNamesDescriptionColumn(c, st);
|
||||
ensureConnectionsIndexes(st);
|
||||
ensureReactionsIndexes(st);
|
||||
ensureChannelNamesIndexes(st);
|
||||
|
||||
DatabaseTriggersInstaller.createAllTriggers(st);
|
||||
|
||||
st.execute("PRAGMA foreign_keys = ON");
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw new RuntimeException("DB schema upgrade failed", e);
|
||||
} finally {
|
||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("DB schema upgrade failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureReactionsStateTable(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS reactions_state (
|
||||
from_login TEXT NOT NULL,
|
||||
from_bch_name TEXT NOT NULL,
|
||||
reaction_type INTEGER NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
to_bch_name TEXT NOT NULL,
|
||||
to_block_number INTEGER NOT NULL,
|
||||
to_block_hash BLOB NOT NULL,
|
||||
last_sub_type INTEGER NOT NULL,
|
||||
UNIQUE (
|
||||
from_login,
|
||||
from_bch_name,
|
||||
reaction_type,
|
||||
to_login,
|
||||
to_bch_name,
|
||||
to_block_number,
|
||||
to_block_hash
|
||||
)
|
||||
);
|
||||
""");
|
||||
}
|
||||
|
||||
private static void createConnectionsStateTable(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS connections_state (
|
||||
login TEXT NOT NULL,
|
||||
rel_type INTEGER NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
to_bch_name TEXT NOT NULL,
|
||||
to_block_number INTEGER NOT NULL,
|
||||
to_block_hash BLOB NOT NULL,
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login),
|
||||
UNIQUE (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
|
||||
);
|
||||
""");
|
||||
}
|
||||
|
||||
private static void ensureConnectionsIndexes(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_connections_state_login
|
||||
ON connections_state (login);
|
||||
""");
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_connections_state_to_login
|
||||
ON connections_state (to_login);
|
||||
""");
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_connections_state_pair
|
||||
ON connections_state (login, to_login);
|
||||
""");
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_connections_state_target
|
||||
ON connections_state (login, rel_type, to_bch_name, to_block_number);
|
||||
""");
|
||||
}
|
||||
|
||||
private static void ensureReactionsIndexes(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_reactions_state_target
|
||||
ON reactions_state (to_bch_name, to_block_number, to_block_hash);
|
||||
""");
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_reactions_state_actor
|
||||
ON reactions_state (from_login, from_bch_name, reaction_type);
|
||||
""");
|
||||
}
|
||||
|
||||
private static void ensureChannelNamesStateTable(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS channel_names_state (
|
||||
slug TEXT NOT NULL PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
channel_description TEXT NOT NULL DEFAULT '',
|
||||
owner_login TEXT NOT NULL,
|
||||
owner_bch_name TEXT NOT NULL,
|
||||
channel_root_block_number INTEGER NOT NULL,
|
||||
channel_root_block_hash BLOB NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL
|
||||
);
|
||||
""");
|
||||
}
|
||||
|
||||
private static void ensureChannelNamesDescriptionColumn(Connection c, Statement st) throws SQLException {
|
||||
boolean hasDescription = false;
|
||||
try (Statement probe = c.createStatement();
|
||||
ResultSet rs = probe.executeQuery("PRAGMA table_info(channel_names_state)")) {
|
||||
while (rs.next()) {
|
||||
String name = rs.getString("name");
|
||||
if ("channel_description".equalsIgnoreCase(name)) {
|
||||
hasDescription = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasDescription) {
|
||||
st.executeUpdate("ALTER TABLE channel_names_state ADD COLUMN channel_description TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureChannelNamesIndexes(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_slug
|
||||
ON channel_names_state (slug);
|
||||
""");
|
||||
st.executeUpdate("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_target
|
||||
ON channel_names_state (owner_bch_name, channel_root_block_number, channel_root_block_hash);
|
||||
""");
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_names_state_owner
|
||||
ON channel_names_state (owner_login, owner_bch_name);
|
||||
""");
|
||||
}
|
||||
|
||||
private static void rebuildConnectionsStateTable(Statement st) throws SQLException {
|
||||
st.executeUpdate("DROP TABLE IF EXISTS connections_state_v2");
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE connections_state_v2 (
|
||||
login TEXT NOT NULL,
|
||||
rel_type INTEGER NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
to_bch_name TEXT NOT NULL,
|
||||
to_block_number INTEGER NOT NULL,
|
||||
to_block_hash BLOB NOT NULL,
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login),
|
||||
UNIQUE (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
INSERT OR IGNORE INTO connections_state_v2
|
||||
(login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
|
||||
SELECT
|
||||
login,
|
||||
rel_type,
|
||||
to_login,
|
||||
to_bch_name,
|
||||
COALESCE(to_block_number, 0),
|
||||
COALESCE(to_block_hash, zeroblob(32))
|
||||
FROM connections_state
|
||||
WHERE login IS NOT NULL
|
||||
AND to_login IS NOT NULL
|
||||
AND to_bch_name IS NOT NULL;
|
||||
""");
|
||||
|
||||
st.executeUpdate("DROP TABLE connections_state");
|
||||
st.executeUpdate("ALTER TABLE connections_state_v2 RENAME TO connections_state");
|
||||
}
|
||||
|
||||
private static boolean tableExists(Connection c, String tableName) throws SQLException {
|
||||
String sql = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1";
|
||||
try (var ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, tableName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean needsConnectionsStateUpgrade(Connection c) throws SQLException {
|
||||
boolean toBlockNumberNotNull = false;
|
||||
boolean toBlockHashNotNull = false;
|
||||
|
||||
try (Statement st = c.createStatement();
|
||||
ResultSet rs = st.executeQuery("PRAGMA table_info(connections_state)")) {
|
||||
while (rs.next()) {
|
||||
String name = rs.getString("name");
|
||||
int notNull = rs.getInt("notnull");
|
||||
if ("to_block_number".equalsIgnoreCase(name)) {
|
||||
toBlockNumberNotNull = notNull == 1;
|
||||
}
|
||||
if ("to_block_hash".equalsIgnoreCase(name)) {
|
||||
toBlockHashNotNull = notNull == 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return !toBlockNumberNotNull || !toBlockHashNotNull;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
package shine.db.channels;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class ChannelNameRules {
|
||||
private static final int MIN_DISPLAY_NAME_LENGTH = 3;
|
||||
private static final int MAX_DISPLAY_NAME_LENGTH = 32;
|
||||
private static final Pattern DISPLAY_ALLOWED_PATTERN =
|
||||
Pattern.compile("^[\\p{IsLatin}\\p{IsCyrillic}0-9 _-]+$");
|
||||
|
||||
private ChannelNameRules() {}
|
||||
|
||||
public static String normalizeDisplayName(String value) {
|
||||
if (value == null) return "";
|
||||
return value.trim().replaceAll("\\s+", " ");
|
||||
}
|
||||
|
||||
public static String requireValidDisplayNameForCreate(String rawName) {
|
||||
String normalized = normalizeDisplayName(rawName);
|
||||
if (normalized.isEmpty()) {
|
||||
throw new IllegalArgumentException("channelName is blank");
|
||||
}
|
||||
|
||||
int length = normalized.codePointCount(0, normalized.length());
|
||||
if (length < MIN_DISPLAY_NAME_LENGTH || length > MAX_DISPLAY_NAME_LENGTH) {
|
||||
throw new IllegalArgumentException("channelName length must be 3..32");
|
||||
}
|
||||
|
||||
if (!DISPLAY_ALLOWED_PATTERN.matcher(normalized).matches()) {
|
||||
throw new IllegalArgumentException("channelName contains unsupported characters");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static String toCanonicalSlug(String rawName) {
|
||||
String normalized = normalizeDisplayName(rawName);
|
||||
if (normalized.isEmpty()) {
|
||||
throw new IllegalArgumentException("channelName is blank");
|
||||
}
|
||||
|
||||
String lowered = normalized.toLowerCase(Locale.ROOT).replace('\u0451', '\u0435');
|
||||
StringBuilder slug = new StringBuilder(lowered.length());
|
||||
boolean pendingSeparator = false;
|
||||
|
||||
for (int i = 0; i < lowered.length(); ) {
|
||||
int cp = lowered.codePointAt(i);
|
||||
i += Character.charCount(cp);
|
||||
|
||||
if (cp == ' ' || cp == '_' || cp == '-') {
|
||||
pendingSeparator = slug.length() > 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isLatinOrCyrillicOrDigit(cp)) {
|
||||
throw new IllegalArgumentException("channelName contains unsupported characters");
|
||||
}
|
||||
|
||||
if (pendingSeparator && slug.length() > 0) {
|
||||
slug.append('-');
|
||||
}
|
||||
pendingSeparator = false;
|
||||
slug.appendCodePoint(cp);
|
||||
}
|
||||
|
||||
int len = slug.length();
|
||||
if (len > 0 && slug.charAt(len - 1) == '-') {
|
||||
slug.deleteCharAt(len - 1);
|
||||
}
|
||||
|
||||
if (slug.length() == 0) {
|
||||
throw new IllegalArgumentException("channelName canonical slug is empty");
|
||||
}
|
||||
|
||||
return slug.toString();
|
||||
}
|
||||
|
||||
private static boolean isLatinOrCyrillicOrDigit(int cp) {
|
||||
if (Character.isDigit(cp)) return true;
|
||||
Character.UnicodeScript script = Character.UnicodeScript.of(cp);
|
||||
return script == Character.UnicodeScript.LATIN || script == Character.UnicodeScript.CYRILLIC;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.entities.ChannelNameStateEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
|
||||
public final class ChannelNameStateDAO {
|
||||
private static volatile ChannelNameStateDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
|
||||
private ChannelNameStateDAO() {}
|
||||
|
||||
public static ChannelNameStateDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (ChannelNameStateDAO.class) {
|
||||
if (instance == null) instance = new ChannelNameStateDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public boolean existsBySlug(Connection c, String slug) throws SQLException {
|
||||
String sql = "SELECT 1 FROM channel_names_state WHERE slug = ? LIMIT 1";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, slug);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean existsBySlug(String slug) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return existsBySlug(c, slug);
|
||||
}
|
||||
}
|
||||
|
||||
public void clearAll(Connection c) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("DELETE FROM channel_names_state")) {
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public void insert(Connection c, ChannelNameStateEntry entry) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO channel_names_state (
|
||||
slug,
|
||||
display_name,
|
||||
channel_description,
|
||||
owner_login,
|
||||
owner_bch_name,
|
||||
channel_root_block_number,
|
||||
channel_root_block_hash,
|
||||
created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, entry.getSlug());
|
||||
ps.setString(2, entry.getDisplayName());
|
||||
ps.setString(3, entry.getChannelDescription() == null ? "" : entry.getChannelDescription());
|
||||
ps.setString(4, entry.getOwnerLogin());
|
||||
ps.setString(5, entry.getOwnerBlockchainName());
|
||||
ps.setInt(6, entry.getChannelRootBlockNumber());
|
||||
ps.setBytes(7, entry.getChannelRootBlockHash());
|
||||
ps.setLong(8, entry.getCreatedAtMs());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public void insertAll(Connection c, List<ChannelNameStateEntry> entries) throws SQLException {
|
||||
for (ChannelNameStateEntry entry : entries) {
|
||||
insert(c, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
package shine.db.entities;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
public class ChannelNameStateEntry {
|
||||
private String slug;
|
||||
private String displayName;
|
||||
private String channelDescription;
|
||||
private String ownerLogin;
|
||||
private String ownerBlockchainName;
|
||||
private int channelRootBlockNumber;
|
||||
private byte[] channelRootBlockHash;
|
||||
private long createdAtMs;
|
||||
|
||||
public String getSlug() {
|
||||
return slug;
|
||||
}
|
||||
|
||||
public void setSlug(String slug) {
|
||||
this.slug = slug;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public void setDisplayName(String displayName) {
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public String getChannelDescription() {
|
||||
return channelDescription;
|
||||
}
|
||||
|
||||
public void setChannelDescription(String channelDescription) {
|
||||
this.channelDescription = channelDescription;
|
||||
}
|
||||
|
||||
public String getOwnerLogin() {
|
||||
return ownerLogin;
|
||||
}
|
||||
|
||||
public void setOwnerLogin(String ownerLogin) {
|
||||
this.ownerLogin = ownerLogin;
|
||||
}
|
||||
|
||||
public String getOwnerBlockchainName() {
|
||||
return ownerBlockchainName;
|
||||
}
|
||||
|
||||
public void setOwnerBlockchainName(String ownerBlockchainName) {
|
||||
this.ownerBlockchainName = ownerBlockchainName;
|
||||
}
|
||||
|
||||
public int getChannelRootBlockNumber() {
|
||||
return channelRootBlockNumber;
|
||||
}
|
||||
|
||||
public void setChannelRootBlockNumber(int channelRootBlockNumber) {
|
||||
this.channelRootBlockNumber = channelRootBlockNumber;
|
||||
}
|
||||
|
||||
public byte[] getChannelRootBlockHash() {
|
||||
return channelRootBlockHash == null ? null : Arrays.copyOf(channelRootBlockHash, channelRootBlockHash.length);
|
||||
}
|
||||
|
||||
public void setChannelRootBlockHash(byte[] channelRootBlockHash) {
|
||||
this.channelRootBlockHash = channelRootBlockHash == null ? null : Arrays.copyOf(channelRootBlockHash, channelRootBlockHash.length);
|
||||
}
|
||||
|
||||
public long getCreatedAtMs() {
|
||||
return createdAtMs;
|
||||
}
|
||||
|
||||
public void setCreatedAtMs(long createdAtMs) {
|
||||
this.createdAtMs = createdAtMs;
|
||||
}
|
||||
}
|
||||
@ -45,6 +45,7 @@ import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUser
|
||||
// --- NEW: connections friends lists ---
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetFriendsLists_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriendsLists_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed_Handler;
|
||||
@ -80,6 +81,10 @@ import java.util.Map;
|
||||
*/
|
||||
public final class JsonHandlerRegistry {
|
||||
|
||||
static {
|
||||
ChannelNamesStateBootstrapper.bootstrapOrFailFast();
|
||||
}
|
||||
|
||||
private static final Map<String, JsonMessageHandler> HANDLERS = Map.ofEntries(
|
||||
Map.entry("AddUser", new Net_AddUser_Handler()),
|
||||
Map.entry("GetUser", new Net_GetUser_Handler()),
|
||||
|
||||
@ -19,19 +19,21 @@ import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_ut
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainWriter;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.channels.ChannelNameRules;
|
||||
import shine.db.dao.BlockchainStateDAO;
|
||||
import shine.db.dao.BlocksDAO;
|
||||
import shine.db.dao.ChannelNameStateDAO;
|
||||
import shine.db.dao.UserParamsDAO;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
import shine.db.entities.BlockEntry;
|
||||
import shine.db.entities.ChannelNameStateEntry;
|
||||
import shine.db.entities.UserParamEntry;
|
||||
import utils.blockchain.BlockchainNameUtil;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/**
|
||||
@ -49,8 +51,13 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
|
||||
private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
|
||||
private final UserParamsDAO userParamsDAO = UserParamsDAO.getInstance();
|
||||
private final ChannelNameStateDAO channelNameStateDAO = ChannelNameStateDAO.getInstance();
|
||||
|
||||
private final BlockchainWriter dbWriter = new BlockchainWriter(blocksDAO, stateDAO, userParamsDAO);
|
||||
private final BlockchainWriter dbWriter = new BlockchainWriter(blocksDAO, stateDAO, userParamsDAO, channelNameStateDAO);
|
||||
|
||||
public Net_AddBlock_Handler() {
|
||||
ChannelNamesStateBootstrapper.bootstrapOrFailFast();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
|
||||
@ -114,9 +121,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
}
|
||||
|
||||
private static String humanMessage(String code) {
|
||||
if (code == null) return "Ошибка добавления блока";
|
||||
|
||||
return switch (code) {
|
||||
if (code == null) return "Ошибка добавления блока"; return switch (code) {
|
||||
case "empty_blockchain_name" -> "Пустое имя блокчейна";
|
||||
case "bad_blockchain_name" -> "Некорректное имя блокчейна";
|
||||
case "db_error" -> "Ошибка базы данных";
|
||||
@ -127,6 +132,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
case "limit_check_failed" -> "Ошибка проверки лимита размера";
|
||||
case "bad_block_format" -> "Некорректный формат блока";
|
||||
case "bad_block_body" -> "Некорректное тело блока";
|
||||
case "bad_channel_name" -> "Некорректное название канала";
|
||||
case "bad_block_number" -> "Некорректный номер блока";
|
||||
case "req_global_mismatch" -> "Номер блока в запросе не совпадает с номером в блоке";
|
||||
case "bad_prev_hash" -> "Некорректный prevHash (цепочка не совпадает)";
|
||||
@ -136,7 +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_name_already_exists" -> "Канал с таким именем уже существует";
|
||||
case "channel_name_already_exists" -> "Такое название канала уже занято";
|
||||
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
||||
default -> "Ошибка: " + code;
|
||||
};
|
||||
@ -237,9 +243,19 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_body", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
|
||||
ChannelNameStateEntry channelNameStateEntry = null;
|
||||
if (block.body instanceof CreateChannelBody createChannelBody) {
|
||||
final String normalizedName;
|
||||
final String slug;
|
||||
try {
|
||||
if (channelNameExists(blockchainName, createChannelBody.channelName)) {
|
||||
normalizedName = ChannelNameRules.requireValidDisplayNameForCreate(createChannelBody.channelName);
|
||||
slug = ChannelNameRules.toCanonicalSlug(normalizedName);
|
||||
} catch (IllegalArgumentException badName) {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_name", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
|
||||
try {
|
||||
if (channelNameStateDAO.existsBySlug(slug)) {
|
||||
return new AddBlockResult(409, "channel_name_already_exists", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
@ -247,6 +263,20 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
blockchainName, createChannelBody.channelName, e);
|
||||
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
|
||||
channelNameStateEntry = new ChannelNameStateEntry();
|
||||
channelNameStateEntry.setSlug(slug);
|
||||
channelNameStateEntry.setDisplayName(normalizedName);
|
||||
channelNameStateEntry.setChannelDescription(
|
||||
createChannelBody.channelDescription == null
|
||||
? ""
|
||||
: ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription)
|
||||
);
|
||||
channelNameStateEntry.setOwnerLogin(login);
|
||||
channelNameStateEntry.setOwnerBlockchainName(blockchainName);
|
||||
channelNameStateEntry.setChannelRootBlockNumber(block.blockNumber);
|
||||
channelNameStateEntry.setChannelRootBlockHash(block.getHash32());
|
||||
channelNameStateEntry.setCreatedAtMs(block.timestamp * 1000L);
|
||||
}
|
||||
|
||||
// 4.2) запрет дырок: blockNumber строго last+1
|
||||
@ -390,9 +420,11 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
);
|
||||
}
|
||||
|
||||
dbWriter.appendBlockAndState(blockchainName, block, st, be, upsertedParam);
|
||||
|
||||
dbWriter.appendBlockAndState(blockchainName, block, st, be, upsertedParam, channelNameStateEntry);
|
||||
} catch (Exception e) {
|
||||
if (isChannelSlugConflict(e)) {
|
||||
return new AddBlockResult(409, "channel_name_already_exists", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
log.error("AddBlock: внутренняя ошибка при записи блока (login={}, blockchainName={}, blockNumber={})",
|
||||
login, blockchainName, block.blockNumber, e);
|
||||
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex);
|
||||
@ -415,28 +447,15 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
return Base64Ws.decode(b64);
|
||||
}
|
||||
|
||||
private boolean channelNameExists(String blockchainName, String channelName) throws Exception {
|
||||
String sql = """
|
||||
SELECT block_bytes
|
||||
FROM blocks
|
||||
WHERE bch_name = ? AND msg_type = 0 AND msg_sub_type = 1
|
||||
""";
|
||||
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection();
|
||||
PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, blockchainName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
byte[] bytes = rs.getBytes("block_bytes");
|
||||
try {
|
||||
BchBlockEntry entry = new BchBlockEntry(bytes);
|
||||
if (entry.body instanceof CreateChannelBody ccb) {
|
||||
if (ccb.channelName.equalsIgnoreCase(channelName)) return true;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// ignore bad historic rows, uniqueness check is best effort
|
||||
}
|
||||
}
|
||||
private static boolean isChannelSlugConflict(Throwable throwable) {
|
||||
Throwable cur = throwable;
|
||||
while (cur != null) {
|
||||
String message = String.valueOf(cur.getMessage());
|
||||
if (message.contains("channel_names_state.slug")
|
||||
|| message.contains("uq_channel_names_state_slug")) {
|
||||
return true;
|
||||
}
|
||||
cur = cur.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -3,9 +3,11 @@ package server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_u
|
||||
import blockchain.BchBlockEntry;
|
||||
import shine.db.dao.BlockchainStateDAO;
|
||||
import shine.db.dao.BlocksDAO;
|
||||
import shine.db.dao.ChannelNameStateDAO;
|
||||
import shine.db.dao.UserParamsDAO;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
import shine.db.entities.BlockEntry;
|
||||
import shine.db.entities.ChannelNameStateEntry;
|
||||
import shine.db.entities.UserParamEntry;
|
||||
import utils.files.FileStoreUtil;
|
||||
|
||||
@ -23,20 +25,26 @@ public final class BlockchainWriter {
|
||||
|
||||
private final BlocksDAO blocksDAO;
|
||||
private final BlockchainStateDAO stateDAO;
|
||||
private final ChannelNameStateDAO channelNameStateDAO;
|
||||
private final UserParamsDAO userParamsDAO;
|
||||
private final FileStoreUtil fs = FileStoreUtil.getInstance();
|
||||
|
||||
public BlockchainWriter(BlocksDAO blocksDAO, BlockchainStateDAO stateDAO, UserParamsDAO userParamsDAO) {
|
||||
public BlockchainWriter(BlocksDAO blocksDAO,
|
||||
BlockchainStateDAO stateDAO,
|
||||
UserParamsDAO userParamsDAO,
|
||||
ChannelNameStateDAO channelNameStateDAO) {
|
||||
this.blocksDAO = blocksDAO;
|
||||
this.stateDAO = stateDAO;
|
||||
this.userParamsDAO = userParamsDAO;
|
||||
this.channelNameStateDAO = channelNameStateDAO;
|
||||
}
|
||||
|
||||
public void appendBlockAndState(String blockchainName,
|
||||
BchBlockEntry block,
|
||||
BlockchainStateEntry st,
|
||||
BlockEntry be,
|
||||
UserParamEntry userParamEntry) throws SQLException {
|
||||
UserParamEntry userParamEntry,
|
||||
ChannelNameStateEntry channelNameStateEntry) throws SQLException {
|
||||
|
||||
long nowMs = System.currentTimeMillis();
|
||||
|
||||
@ -59,6 +67,10 @@ public final class BlockchainWriter {
|
||||
userParamsDAO.upsertIfNewer(c, userParamEntry);
|
||||
}
|
||||
|
||||
if (channelNameStateEntry != null) {
|
||||
channelNameStateDAO.insert(c, channelNameStateEntry);
|
||||
}
|
||||
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
|
||||
@ -0,0 +1,144 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||
|
||||
import blockchain.BchBlockEntry;
|
||||
import blockchain.body.CreateChannelBody;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.channels.ChannelNameRules;
|
||||
import shine.db.dao.ChannelNameStateDAO;
|
||||
import shine.db.entities.ChannelNameStateEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class ChannelNamesStateBootstrapper {
|
||||
private static final Logger log = LoggerFactory.getLogger(ChannelNamesStateBootstrapper.class);
|
||||
private static final int MSG_TYPE_TECH = 0;
|
||||
private static final int MSG_SUB_TYPE_CREATE_CHANNEL = 1;
|
||||
private static volatile boolean bootstrapped;
|
||||
|
||||
private ChannelNamesStateBootstrapper() {}
|
||||
|
||||
public static void bootstrapOrFailFast() {
|
||||
if (bootstrapped) return;
|
||||
synchronized (ChannelNamesStateBootstrapper.class) {
|
||||
if (bootstrapped) return;
|
||||
rebuildFromBlocksOrThrow();
|
||||
bootstrapped = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void rebuildFromBlocksOrThrow() {
|
||||
ChannelNameStateDAO dao = ChannelNameStateDAO.getInstance();
|
||||
List<ChannelNameStateEntry> entries = new ArrayList<>();
|
||||
Map<String, String> slugToIdentity = new LinkedHashMap<>();
|
||||
List<String> conflicts = new ArrayList<>();
|
||||
List<String> skipped = new ArrayList<>();
|
||||
|
||||
String sql = """
|
||||
SELECT login, bch_name, block_number, block_hash, block_bytes
|
||||
FROM blocks
|
||||
WHERE msg_type = ? AND msg_sub_type = ?
|
||||
ORDER BY bch_name, block_number
|
||||
""";
|
||||
|
||||
try (Connection c = SqliteDbController.getInstance().getConnection()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setInt(1, MSG_TYPE_TECH);
|
||||
ps.setInt(2, MSG_SUB_TYPE_CREATE_CHANNEL);
|
||||
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
String ownerLogin = rs.getString("login");
|
||||
String ownerBch = rs.getString("bch_name");
|
||||
int blockNumber = rs.getInt("block_number");
|
||||
byte[] blockHash = rs.getBytes("block_hash");
|
||||
byte[] blockBytes = rs.getBytes("block_bytes");
|
||||
|
||||
final BchBlockEntry parsed;
|
||||
final CreateChannelBody createChannelBody;
|
||||
try {
|
||||
parsed = new BchBlockEntry(blockBytes);
|
||||
if (!(parsed.body instanceof CreateChannelBody ccb)) continue;
|
||||
createChannelBody = ccb;
|
||||
} catch (Exception parseError) {
|
||||
skipped.add(ownerBch + "#" + blockNumber + " (parse_error)");
|
||||
continue;
|
||||
}
|
||||
|
||||
final String displayName;
|
||||
final String slug;
|
||||
final String channelDescription;
|
||||
try {
|
||||
displayName = ChannelNameRules.normalizeDisplayName(createChannelBody.channelName);
|
||||
slug = ChannelNameRules.toCanonicalSlug(displayName);
|
||||
channelDescription = ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription);
|
||||
} catch (Exception badName) {
|
||||
skipped.add(ownerBch + "#" + blockNumber + " (invalid_name)");
|
||||
continue;
|
||||
}
|
||||
|
||||
String identity = ownerBch + "#" + blockNumber;
|
||||
String existing = slugToIdentity.putIfAbsent(slug, identity);
|
||||
if (existing != null && !existing.equals(identity)) {
|
||||
conflicts.add("slug=\"" + slug + "\" conflicts: " + existing + " vs " + identity);
|
||||
continue;
|
||||
}
|
||||
|
||||
ChannelNameStateEntry entry = new ChannelNameStateEntry();
|
||||
entry.setSlug(slug);
|
||||
entry.setDisplayName(displayName);
|
||||
entry.setChannelDescription(channelDescription == null ? "" : channelDescription);
|
||||
entry.setOwnerLogin(ownerLogin);
|
||||
entry.setOwnerBlockchainName(ownerBch);
|
||||
entry.setChannelRootBlockNumber(blockNumber);
|
||||
entry.setChannelRootBlockHash(blockHash);
|
||||
entry.setCreatedAtMs(parsed.timestamp * 1000L);
|
||||
entries.add(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dao.clearAll(c);
|
||||
dao.insertAll(c, entries);
|
||||
c.commit();
|
||||
log.info("channel_names_state bootstrapped: {}", entries.size());
|
||||
if (!conflicts.isEmpty()) {
|
||||
log.warn("channel_names_state bootstrap detected {} slug conflicts (kept first occurrence)", conflicts.size());
|
||||
int preview = Math.min(conflicts.size(), 10);
|
||||
for (int i = 0; i < preview; i++) {
|
||||
log.warn("channel_names_state conflict: {}", conflicts.get(i));
|
||||
}
|
||||
}
|
||||
if (!skipped.isEmpty()) {
|
||||
log.warn("channel_names_state bootstrap skipped {} legacy entries", skipped.size());
|
||||
int preview = Math.min(skipped.size(), 10);
|
||||
for (int i = 0; i < preview; i++) {
|
||||
log.warn("channel_names_state skipped: {}", skipped.get(i));
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
try {
|
||||
c.rollback();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
try {
|
||||
c.setAutoCommit(true);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to bootstrap channel_names_state", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,8 @@ import blockchain.BchBlockEntry;
|
||||
import blockchain.body.BodyRecord;
|
||||
import blockchain.body.CreateChannelBody;
|
||||
import blockchain.body.TextBody;
|
||||
import blockchain.body.TextLineBody;
|
||||
import blockchain.body.TextReplyBody;
|
||||
import shine.db.MsgSubType;
|
||||
|
||||
import java.sql.Connection;
|
||||
@ -15,6 +17,7 @@ import java.util.List;
|
||||
|
||||
final class ChannelsReadSupport {
|
||||
static final int MSG_TYPE_TEXT = 1;
|
||||
static final int MSG_TYPE_REACTION = 2;
|
||||
static final int MSG_TYPE_TECH = 0;
|
||||
|
||||
private ChannelsReadSupport() {}
|
||||
@ -122,7 +125,11 @@ final class ChannelsReadSupport {
|
||||
BchBlockEntry e = new BchBlockEntry(blockBytes);
|
||||
TextInfo ti = new TextInfo();
|
||||
ti.createdAtMs = e.timestamp * 1000L;
|
||||
if (e.body instanceof TextBody tb) {
|
||||
if (e.body instanceof TextLineBody tlb) {
|
||||
ti.text = tlb.message;
|
||||
} else if (e.body instanceof TextReplyBody trb) {
|
||||
ti.text = trb.message;
|
||||
} else if (e.body instanceof TextBody tb) {
|
||||
ti.text = tb.message;
|
||||
}
|
||||
return ti;
|
||||
@ -205,6 +212,74 @@ final class ChannelsReadSupport {
|
||||
}
|
||||
}
|
||||
|
||||
static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException {
|
||||
if (rootNumber == 0) return "";
|
||||
|
||||
// Preferred source: persisted state (fast path, works for CreateChannelBody v2).
|
||||
String stateSql = """
|
||||
SELECT channel_description
|
||||
FROM channel_names_state
|
||||
WHERE owner_bch_name = ? AND channel_root_block_number = ?
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(stateSql)) {
|
||||
ps.setString(1, ownerBch);
|
||||
ps.setInt(2, rootNumber);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
return String.valueOf(rs.getString("channel_description") == null ? "" : rs.getString("channel_description"));
|
||||
}
|
||||
}
|
||||
} catch (SQLException ignored) {
|
||||
// keep compatibility for environments where table schema is older/corrupted
|
||||
}
|
||||
|
||||
// Fallback: parse root block directly.
|
||||
String sql = "SELECT block_bytes FROM blocks WHERE bch_name=? AND block_number=? LIMIT 1";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBch);
|
||||
ps.setInt(2, rootNumber);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return "";
|
||||
byte[] bytes = rs.getBytes("block_bytes");
|
||||
BchBlockEntry e = new BchBlockEntry(bytes);
|
||||
BodyRecord body = e.body;
|
||||
if (body instanceof CreateChannelBody ccb) return ccb.channelDescription == null ? "" : ccb.channelDescription;
|
||||
return "";
|
||||
} catch (Exception ignored) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static boolean isLikedByLogin(Connection c, String login, String toBch, int toBlockNumber, byte[] toBlockHash) throws SQLException {
|
||||
if (login == null || login.isBlank() || toBch == null || toBch.isBlank() || toBlockHash == null || toBlockHash.length != 32) {
|
||||
return false;
|
||||
}
|
||||
String sql = """
|
||||
SELECT msg_sub_type
|
||||
FROM blocks
|
||||
WHERE login = ? COLLATE NOCASE
|
||||
AND msg_type = ?
|
||||
AND to_bch_name = ?
|
||||
AND to_block_number = ?
|
||||
AND to_block_hash = ?
|
||||
ORDER BY block_number DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
ps.setInt(2, MSG_TYPE_REACTION);
|
||||
ps.setString(3, toBch);
|
||||
ps.setInt(4, toBlockNumber);
|
||||
ps.setBytes(5, toBlockHash);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return false;
|
||||
return rs.getInt("msg_sub_type") == MsgSubType.REACTION_LIKE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static byte[] hexToBytes(String s) {
|
||||
if (s == null) return null;
|
||||
String x = s.trim();
|
||||
|
||||
@ -38,6 +38,10 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
||||
boolean asc = req.getSort() == null || !"desc".equalsIgnoreCase(req.getSort());
|
||||
|
||||
try (Connection c = SqliteDbController.getInstance().getConnection()) {
|
||||
String viewerLogin = ctx != null ? ctx.getLogin() : null;
|
||||
if (viewerLogin == null || viewerLogin.isBlank()) {
|
||||
viewerLogin = ChannelsReadSupport.canonicalLogin(c, req.getLogin());
|
||||
}
|
||||
String ownerBch = req.getChannel().getOwnerBlockchainName();
|
||||
int lineCode = req.getChannel().getChannelRootBlockNumber();
|
||||
|
||||
@ -50,6 +54,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
||||
channel.setOwnerBlockchainName(ownerBch);
|
||||
channel.setOwnerLogin(BlockchainNameUtil.loginFromBlockchainName(ownerBch));
|
||||
channel.setChannelName(ChannelsReadSupport.detectChannelName(c, ownerBch, lineCode));
|
||||
channel.setChannelDescription(ChannelsReadSupport.detectChannelDescription(c, ownerBch, lineCode));
|
||||
Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef();
|
||||
rootRef.setBlockNumber(lineCode);
|
||||
rootRef.setBlockHash(req.getChannel().getChannelRootBlockHash());
|
||||
@ -102,6 +107,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
||||
int[] stats = ChannelsReadSupport.loadStats(c, ownerBch, post.blockNumber, post.blockHash);
|
||||
item.setLikesCount(stats[0]);
|
||||
item.setRepliesCount(stats[1]);
|
||||
item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
|
||||
|
||||
items.add(item);
|
||||
}
|
||||
|
||||
@ -27,7 +27,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_GetMessageThread_Request req = (Net_GetMessageThread_Request) baseRequest;
|
||||
if (req.getMessage() == null || req.getMessage().getBlockchainName() == null || req.getMessage().getBlockNumber() == null) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля message");
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля message");
|
||||
}
|
||||
|
||||
int depthUp = req.getDepthUp() == null ? 20 : Math.max(0, req.getDepthUp());
|
||||
@ -35,9 +35,13 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
||||
int childLimit = req.getLimitChildrenPerNode() == null ? 50 : Math.max(1, req.getLimitChildrenPerNode());
|
||||
|
||||
try (Connection c = SqliteDbController.getInstance().getConnection()) {
|
||||
String viewerLogin = ctx != null ? ctx.getLogin() : null;
|
||||
if (viewerLogin == null || viewerLogin.isBlank()) {
|
||||
viewerLogin = ChannelsReadSupport.canonicalLogin(c, req.getLogin());
|
||||
}
|
||||
PostRow focusRow = findByNumber(c, req.getMessage().getBlockchainName(), req.getMessage().getBlockNumber());
|
||||
if (focusRow == null) {
|
||||
return NetExceptionResponseFactory.error(req, 404, "message_not_found", "Сообщение не найдено");
|
||||
return NetExceptionResponseFactory.error(req, 404, "message_not_found", "Сообщение не найдено");
|
||||
}
|
||||
|
||||
Net_GetMessageThread_Response resp = new Net_GetMessageThread_Response();
|
||||
@ -45,7 +49,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
|
||||
resp.setFocus(toNode(c, focusRow));
|
||||
resp.setFocus(toNode(c, focusRow, viewerLogin));
|
||||
|
||||
List<Net_GetMessageThread_Response.MessageNode> ancestors = new ArrayList<>();
|
||||
PostRow cur = focusRow;
|
||||
@ -53,27 +57,27 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
||||
if (cur.toBlockNumber == null || cur.toBchName == null) break;
|
||||
PostRow parent = findByNumber(c, cur.toBchName, cur.toBlockNumber);
|
||||
if (parent == null) break;
|
||||
ancestors.add(0, toNode(c, parent));
|
||||
ancestors.add(0, toNode(c, parent, viewerLogin));
|
||||
cur = parent;
|
||||
}
|
||||
resp.setAncestors(ancestors);
|
||||
|
||||
resp.setDescendants(loadChildren(c, focusRow, depthDown, childLimit));
|
||||
resp.setDescendants(loadChildren(c, focusRow, depthDown, childLimit, viewerLogin));
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
log.error("GetMessageThread failed", e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
|
||||
}
|
||||
}
|
||||
|
||||
private List<Net_GetMessageThread_Response.MessageNodeTree> loadChildren(Connection c, PostRow parent, int depthDown, int childLimit) throws Exception {
|
||||
private List<Net_GetMessageThread_Response.MessageNodeTree> loadChildren(Connection c, PostRow parent, int depthDown, int childLimit, String viewerLogin) throws Exception {
|
||||
if (depthDown <= 0) return List.of();
|
||||
List<PostRow> replies = findReplies(c, parent.bchName, parent.blockNumber, parent.blockHash, childLimit);
|
||||
List<Net_GetMessageThread_Response.MessageNodeTree> out = new ArrayList<>();
|
||||
for (PostRow row : replies) {
|
||||
Net_GetMessageThread_Response.MessageNodeTree t = new Net_GetMessageThread_Response.MessageNodeTree();
|
||||
t.setNode(toNode(c, row));
|
||||
t.setChildren(loadChildren(c, row, depthDown - 1, childLimit));
|
||||
t.setNode(toNode(c, row, viewerLogin));
|
||||
t.setChildren(loadChildren(c, row, depthDown - 1, childLimit, viewerLogin));
|
||||
out.add(t);
|
||||
}
|
||||
return out;
|
||||
@ -133,7 +137,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
||||
return row;
|
||||
}
|
||||
|
||||
private Net_GetMessageThread_Response.MessageNode toNode(Connection c, PostRow row) throws Exception {
|
||||
private Net_GetMessageThread_Response.MessageNode toNode(Connection c, PostRow row, String viewerLogin) throws Exception {
|
||||
Net_GetMessageThread_Response.MessageNode node = new Net_GetMessageThread_Response.MessageNode();
|
||||
Net_GetChannelMessages_Response.BlockRef ref = new Net_GetChannelMessages_Response.BlockRef();
|
||||
ref.setBlockNumber(row.blockNumber);
|
||||
@ -173,6 +177,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
||||
int[] stats = ChannelsReadSupport.loadStats(c, row.bchName, row.blockNumber, row.blockHash);
|
||||
node.setLikesCount(stats[0]);
|
||||
node.setRepliesCount(stats[1]);
|
||||
node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
|
||||
|
||||
if (row.lineCode != null && row.lineCode >= 0) {
|
||||
Net_GetMessageThread_Response.ChannelInfo ci = new Net_GetMessageThread_Response.ChannelInfo();
|
||||
@ -222,3 +227,4 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
||||
int msgSubType;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -64,6 +64,7 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
|
||||
channelRef.setOwnerLogin(key.ownerLogin);
|
||||
channelRef.setOwnerBlockchainName(key.ownerBch);
|
||||
channelRef.setChannelName(ChannelsReadSupport.detectChannelName(c, key.ownerBch, key.rootNumber));
|
||||
channelRef.setChannelDescription(ChannelsReadSupport.detectChannelDescription(c, key.ownerBch, key.rootNumber));
|
||||
channelRef.setPersonal(key.rootNumber == 0);
|
||||
|
||||
Net_ListSubscriptionsFeed_Response.BlockRef rootRef = new Net_ListSubscriptionsFeed_Response.BlockRef();
|
||||
|
||||
@ -3,10 +3,14 @@ package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_GetChannelMessages_Request extends Net_Request {
|
||||
private String login;
|
||||
private ChannelSelector channel;
|
||||
private Integer limit;
|
||||
private String sort;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
|
||||
public ChannelSelector getChannel() { return channel; }
|
||||
public void setChannel(ChannelSelector channel) { this.channel = channel; }
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
||||
private String ownerLogin;
|
||||
private String ownerBlockchainName;
|
||||
private String channelName;
|
||||
private String channelDescription;
|
||||
private BlockRef channelRoot;
|
||||
|
||||
public String getOwnerLogin() { return ownerLogin; }
|
||||
@ -30,6 +31,9 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
||||
public String getChannelName() { return channelName; }
|
||||
public void setChannelName(String channelName) { this.channelName = channelName; }
|
||||
|
||||
public String getChannelDescription() { return channelDescription; }
|
||||
public void setChannelDescription(String channelDescription) { this.channelDescription = channelDescription; }
|
||||
|
||||
public BlockRef getChannelRoot() { return channelRoot; }
|
||||
public void setChannelRoot(BlockRef channelRoot) { this.channelRoot = channelRoot; }
|
||||
}
|
||||
@ -41,6 +45,7 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
||||
private long createdAtMs;
|
||||
private String text;
|
||||
private int likesCount;
|
||||
private boolean likedByMe;
|
||||
private int repliesCount;
|
||||
private int versionsTotal;
|
||||
private List<VersionItem> versions = new ArrayList<>();
|
||||
@ -63,6 +68,9 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
||||
public int getLikesCount() { return likesCount; }
|
||||
public void setLikesCount(int likesCount) { this.likesCount = likesCount; }
|
||||
|
||||
public boolean isLikedByMe() { return likedByMe; }
|
||||
public void setLikedByMe(boolean likedByMe) { this.likedByMe = likedByMe; }
|
||||
|
||||
public int getRepliesCount() { return repliesCount; }
|
||||
public void setRepliesCount(int repliesCount) { this.repliesCount = repliesCount; }
|
||||
|
||||
|
||||
@ -3,11 +3,15 @@ package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_GetMessageThread_Request extends Net_Request {
|
||||
private String login;
|
||||
private MessageSelector message;
|
||||
private Integer depthUp;
|
||||
private Integer depthDown;
|
||||
private Integer limitChildrenPerNode;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
|
||||
public MessageSelector getMessage() { return message; }
|
||||
public void setMessage(MessageSelector message) { this.message = message; }
|
||||
|
||||
|
||||
@ -42,6 +42,7 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
||||
private String ownerLogin;
|
||||
private String ownerBlockchainName;
|
||||
private String channelName;
|
||||
private String channelDescription;
|
||||
private boolean personal;
|
||||
private BlockRef channelRoot;
|
||||
|
||||
@ -54,6 +55,9 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
||||
public String getChannelName() { return channelName; }
|
||||
public void setChannelName(String channelName) { this.channelName = channelName; }
|
||||
|
||||
public String getChannelDescription() { return channelDescription; }
|
||||
public void setChannelDescription(String channelDescription) { this.channelDescription = channelDescription; }
|
||||
|
||||
public boolean isPersonal() { return personal; }
|
||||
public void setPersonal(boolean personal) { this.personal = personal; }
|
||||
|
||||
|
||||
@ -9,7 +9,6 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseF
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.MsgSubType;
|
||||
import shine.db.dao.ConnectionsStateDAO;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
@ -42,15 +41,10 @@ public class Net_AddCloseFriend_Handler implements JsonMessageHandler {
|
||||
return NetExceptionResponseFactory.error(req, 404, "BLOCKCHAIN_NOT_FOUND", "У пользователя нет blockchain");
|
||||
}
|
||||
|
||||
ConnectionsStateDAO.getInstance().upsertRelation(
|
||||
c,
|
||||
from,
|
||||
MsgSubType.CONNECTION_FRIEND,
|
||||
canonicalTo,
|
||||
targetBch,
|
||||
0,
|
||||
new byte[32]
|
||||
);
|
||||
// Idempotent insert for close-friend relation.
|
||||
// Using INSERT OR IGNORE avoids ON CONFLICT(column list) mismatches
|
||||
// across DB instances with different UNIQUE schemas.
|
||||
insertCloseFriendIgnoreDuplicate(c, from, canonicalTo, targetBch);
|
||||
|
||||
Net_AddCloseFriend_Response resp = new Net_AddCloseFriend_Response();
|
||||
resp.setOp(req.getOp());
|
||||
@ -82,4 +76,26 @@ public class Net_AddCloseFriend_Handler implements JsonMessageHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void insertCloseFriendIgnoreDuplicate(Connection c,
|
||||
String login,
|
||||
String toLogin,
|
||||
String toBchName) throws Exception {
|
||||
String sql = """
|
||||
INSERT OR IGNORE INTO connections_state (
|
||||
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
ps.setInt(2, MsgSubType.CONNECTION_FRIEND);
|
||||
ps.setString(3, toLogin);
|
||||
ps.setString(4, toBchName);
|
||||
ps.setInt(5, 0);
|
||||
ps.setBytes(6, new byte[32]);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,9 +23,17 @@ public final class AuthKeyUtils {
|
||||
public static byte[] parseEd25519PublicKey(String key, String fieldName) {
|
||||
String normalized = normalize(key, fieldName);
|
||||
|
||||
// Legacy format is plain BASE64(32 bytes) and may contain '/' characters.
|
||||
// Try legacy decode first to avoid misinterpreting base64 payload as algorithm prefix.
|
||||
try {
|
||||
return Base64Ws.decodeLen(normalized, 32, fieldName);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// continue with explicit algorithm/key format
|
||||
}
|
||||
|
||||
int slash = normalized.indexOf('/');
|
||||
if (slash < 0) {
|
||||
return Base64Ws.decodeLen(normalized, 32, fieldName);
|
||||
throw new IllegalArgumentException(fieldName + " has bad base64/key format");
|
||||
}
|
||||
|
||||
String algorithm = normalized.substring(0, slash).trim();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user