merge: drygmira/main into main

This commit is contained in:
AidarKC 2026-04-14 22:05:18 +03:00
commit 2830f75f65
50 changed files with 6616 additions and 537 deletions

1
README.md Normal file
View File

@ -0,0 +1 @@
# -

View File

@ -4,6 +4,12 @@ plugins {
id 'com.github.johnrengelman.shadow' version '8.1.1' id 'com.github.johnrengelman.shadow' version '8.1.1'
} }
allprojects {
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}
}
group = 'shine' group = 'shine'
version = '1.1_codex' version = '1.1_codex'

View 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}`);
});

View File

@ -6,7 +6,7 @@
<link rel="manifest" href="./manifest.webmanifest" /> <link rel="manifest" href="./manifest.webmanifest" />
<title>Shine UI Demo</title> <title>Shine UI Demo</title>
<script> <script>
window.__SHINE_BUILD_HASH__ = '20260407120000'; window.__SHINE_BUILD_HASH__ = '20260413151200';
</script> </script>
<script> <script>
(function attachStylesWithBuildHash() { (function attachStylesWithBuildHash() {

View File

@ -41,6 +41,7 @@ import * as contactSearchView from './pages/contact-search-view.js';
import * as chatView from './pages/chat-view.js'; import * as chatView from './pages/chat-view.js';
import * as channelsList from './pages/channels-list.js'; import * as channelsList from './pages/channels-list.js';
import * as channelView from './pages/channel-view.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 addChannelView from './pages/add-channel-view.js';
import * as networkView from './pages/network-view.js'; import * as networkView from './pages/network-view.js';
import * as notificationsView from './pages/notifications-view.js'; import * as notificationsView from './pages/notifications-view.js';
@ -72,6 +73,7 @@ const routes = {
'chat-view': chatView, 'chat-view': chatView,
'channels-list': channelsList, 'channels-list': channelsList,
'channel-view': channelView, 'channel-view': channelView,
'channel-thread-view': channelThreadView,
'add-channel-view': addChannelView, 'add-channel-view': addChannelView,
'network-view': networkView, 'network-view': networkView,
'notifications-view': notificationsView, '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() { function renderApp() {
const route = getRoute(); const route = getRoute();
const pageId = route.pageId || (state.session.isAuthorized ? 'profile-view' : 'start-view'); const pageId = route.pageId || (state.session.isAuthorized ? 'profile-view' : 'start-view');
@ -162,18 +203,26 @@ function renderApp() {
currentCleanup = null; currentCleanup = null;
} }
screenEl.innerHTML = ''; try {
const screen = page.render({ route, navigate }); screenEl.innerHTML = '';
screenEl.append(screen); const screen = page.render({ route, navigate });
currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null; if (!(screen instanceof Node)) {
throw new Error('Page render returned invalid node');
}
const showAppChrome = page.pageMeta?.showAppChrome !== false; screenEl.append(screen);
screenEl.classList.toggle('no-app-chrome', !showAppChrome); currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
toolbarEl.innerHTML = ''; const showAppChrome = page.pageMeta?.showAppChrome !== false;
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
if (showAppChrome) { toolbarEl.innerHTML = '';
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate)); if (showAppChrome) {
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
}
} catch (error) {
console.error('[renderApp] controlled fallback', error);
renderPageFailureFallback(pageId, error);
} }
} }

View File

@ -1,38 +1,153 @@
import { renderHeader } from '../components/header.js'; 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 }) { export function render({ navigate }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack'; screen.className = 'stack channels-screen channels-screen--add';
screen.append( screen.append(
renderHeader({ renderHeader({
title: 'Добавить канал', title: 'Создать канал',
leftAction: { label: '←', onClick: () => navigate('channels-list') }, leftAction: { label: '<', onClick: () => navigate('channels-list') },
}) }),
); );
const form = document.createElement('form'); const form = document.createElement('form');
form.className = 'card stack'; form.className = 'card stack';
form.innerHTML = ` form.innerHTML = `
<label for="channel-name">Имя канала</label> <strong class="channel-head-title">Создание канала</strong>
<input id="channel-name" class="input" maxlength="64" placeholder="Например: Новости команды" required /> <p class="channel-head-meta">Можно использовать кириллицу, латиницу, цифры, пробел, _ и -.</p>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;"> <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="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> </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(); 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),
});
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();
}
}); });
form.querySelector('#cancel-create-channel').addEventListener('click', () => { cancelEl.addEventListener('click', () => navigate('channels-list'));
navigate('channels-list');
});
screen.append(form); screen.append(form);
nameEl.focus();
updateValidation();
return screen; return screen;
} }

View 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

View File

@ -6,6 +6,7 @@ import {
setAuthError, setAuthError,
state, state,
} from '../state.js'; } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
export const pageMeta = { id: 'login-password-view', title: 'Войти по логину', showAppChrome: false }; export const pageMeta = { id: 'login-password-view', title: 'Войти по логину', showAppChrome: false };
@ -32,7 +33,11 @@ export function render({ navigate }) {
const hint = document.createElement('p'); const hint = document.createElement('p');
hint.className = 'meta-muted'; 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 = ` form.innerHTML = `
<label class="stack"><span class="field-label">Логин</span></label> <label class="stack"><span class="field-label">Логин</span></label>
@ -40,7 +45,7 @@ export function render({ navigate }) {
`; `;
form.children[0].append(loginInput); form.children[0].append(loginInput);
form.children[1].append(passwordInput); form.children[1].append(passwordInput);
form.append(hint); form.append(hint, status);
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'auth-footer-actions'; actions.className = 'auth-footer-actions';
@ -56,11 +61,13 @@ export function render({ navigate }) {
enterButton.type = 'button'; enterButton.type = 'button';
enterButton.textContent = 'Войти'; enterButton.textContent = 'Войти';
enterButton.addEventListener('click', async () => { enterButton.addEventListener('click', async () => {
status.style.display = 'none';
state.loginDraft.login = loginInput.value.trim(); state.loginDraft.login = loginInput.value.trim();
state.loginDraft.password = passwordInput.value; state.loginDraft.password = passwordInput.value;
if (!state.loginDraft.login || !state.loginDraft.password) { if (!state.loginDraft.login || !state.loginDraft.password) {
window.alert('Введите логин и пароль'); status.textContent = 'Введите логин и пароль.';
status.style.display = '';
return; return;
} }
@ -81,8 +88,10 @@ export function render({ navigate }) {
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial; state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
navigate('registration-keys-view'); navigate('registration-keys-view');
} catch (error) { } catch (error) {
setAuthError(error.message); const message = toUserMessage(error, 'Не удалось выполнить вход.');
window.alert(error.message); setAuthError(message);
status.textContent = message;
status.style.display = '';
} finally { } finally {
setAuthBusy(false); setAuthBusy(false);
enterButton.disabled = false; enterButton.disabled = false;

View File

@ -1,5 +1,6 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import { authService, clearAuthMessages, state } from '../state.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 }; export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
@ -28,6 +29,10 @@ export function render({ navigate }) {
statusText.className = 'meta-muted'; statusText.className = 'meta-muted';
statusText.textContent = 'Проверка логина: не выполнена'; statusText.textContent = 'Проверка логина: не выполнена';
const formError = document.createElement('p');
formError.className = 'status-line is-unavailable';
formError.style.display = 'none';
const checkButton = document.createElement('button'); const checkButton = document.createElement('button');
checkButton.className = 'ghost-btn'; checkButton.className = 'ghost-btn';
checkButton.type = 'button'; checkButton.type = 'button';
@ -37,6 +42,7 @@ export function render({ navigate }) {
const login = loginInput.value.trim(); const login = loginInput.value.trim();
if (!login) { if (!login) {
statusText.textContent = 'Введите логин'; statusText.textContent = 'Введите логин';
formError.style.display = 'none';
return false; return false;
} }
@ -47,9 +53,10 @@ export function render({ navigate }) {
const isFree = await authService.ensureLoginFree(login); const isFree = await authService.ensureLoginFree(login);
statusText.textContent = isFree ? 'Логин свободен ✅' : 'Логин уже занят ❌'; statusText.textContent = isFree ? 'Логин свободен ✅' : 'Логин уже занят ❌';
statusText.className = isFree ? 'is-available' : 'is-unavailable'; statusText.className = isFree ? 'is-available' : 'is-unavailable';
formError.style.display = 'none';
return isFree; return isFree;
} catch (error) { } catch (error) {
statusText.textContent = error.message; statusText.textContent = toUserMessage(error, 'Не удалось проверить логин');
statusText.className = 'is-unavailable'; statusText.className = 'is-unavailable';
return false; return false;
} finally { } finally {
@ -66,7 +73,7 @@ export function render({ navigate }) {
`; `;
form.children[0].append(loginInput); form.children[0].append(loginInput);
form.children[1].append(passwordInput); form.children[1].append(passwordInput);
form.append(checkButton, statusText); form.append(checkButton, statusText, formError);
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'auth-footer-actions'; actions.className = 'auth-footer-actions';
@ -82,9 +89,9 @@ export function render({ navigate }) {
nextButton.type = 'button'; nextButton.type = 'button';
nextButton.textContent = 'Далее'; nextButton.textContent = 'Далее';
nextButton.addEventListener('click', async () => { nextButton.addEventListener('click', async () => {
formError.style.display = 'none';
const isFree = await runAvailabilityCheck(); const isFree = await runAvailabilityCheck();
if (!isFree) { if (!isFree) {
window.alert('Выберите свободный логин');
return; return;
} }
@ -92,7 +99,8 @@ export function render({ navigate }) {
state.registrationDraft.password = passwordInput.value; state.registrationDraft.password = passwordInput.value;
if (!state.registrationDraft.password) { if (!state.registrationDraft.password) {
window.alert('Введите пароль'); formError.textContent = 'Введите пароль.';
formError.style.display = '';
return; return;
} }

View File

@ -7,6 +7,7 @@ import {
setAuthInfo, setAuthInfo,
state, state,
} from '../state.js'; } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false }; export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false };
@ -31,6 +32,14 @@ export function render({ navigate }) {
question.className = 'auth-copy'; question.className = 'auth-copy';
question.textContent = 'Какие ключи сохранить в зашифрованном контейнере IndexedDB?'; 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'); const rootToggle = document.createElement('input');
rootToggle.type = 'checkbox'; rootToggle.type = 'checkbox';
rootToggle.checked = state.keyStorage.saveRoot; rootToggle.checked = state.keyStorage.saveRoot;
@ -46,17 +55,17 @@ export function render({ navigate }) {
const rootRow = document.createElement('label'); const rootRow = document.createElement('label');
rootRow.className = 'checkbox-row'; rootRow.className = 'checkbox-row';
rootRow.append(rootToggle, document.createTextNode('root key')); rootRow.append(rootToggle, document.createTextNode('Ключ root'));
const blockchainRow = document.createElement('label'); const blockchainRow = document.createElement('label');
blockchainRow.className = 'checkbox-row'; blockchainRow.className = 'checkbox-row';
blockchainRow.append(blockchainToggle, document.createTextNode('blockchain.key')); blockchainRow.append(blockchainToggle, document.createTextNode('Ключ blockchain'));
const deviceRow = document.createElement('label'); const deviceRow = document.createElement('label');
deviceRow.className = 'checkbox-row'; 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'); const actions = document.createElement('div');
actions.className = 'auth-footer-actions'; actions.className = 'auth-footer-actions';
@ -72,6 +81,7 @@ export function render({ navigate }) {
okButton.type = 'button'; okButton.type = 'button';
okButton.textContent = 'OK'; okButton.textContent = 'OK';
okButton.addEventListener('click', async () => { okButton.addEventListener('click', async () => {
status.style.display = 'none';
try { try {
if (!state.registrationDraft.pendingKeyBundle || !state.registrationDraft.pendingSessionMaterial) { if (!state.registrationDraft.pendingKeyBundle || !state.registrationDraft.pendingSessionMaterial) {
throw new Error('Сначала завершите шаг регистрации на предыдущем экране'); throw new Error('Сначала завершите шаг регистрации на предыдущем экране');
@ -117,11 +127,15 @@ export function render({ navigate }) {
state.registrationDraft.pendingSessionMaterial = null; state.registrationDraft.pendingSessionMaterial = null;
await refreshSessions(); await refreshSessions();
setAuthInfo(isLoginFlow ? 'Ключи сохранены, вход завершён.' : 'Ключи сохранены, регистрация завершена.'); setAuthInfo(isLoginFlow
? `Ключи сохранены. Вы вошли как @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`
: `Ключи сохранены. Регистрация завершена для @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`);
navigate('profile-view'); navigate('profile-view');
} catch (error) { } catch (error) {
setAuthError(error.message); const message = toUserMessage(error, 'Не удалось сохранить ключи на устройстве.');
window.alert(error.message); setAuthError(message);
status.textContent = message;
status.style.display = '';
} }
}); });

View File

@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import { import {
authService, authService,
refreshRegistrationBalance, refreshRegistrationBalance,
@ -6,9 +6,25 @@ import {
setAuthInfo, setAuthInfo,
state, state,
} from '../state.js'; } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false }; 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 }) { export function render({ navigate }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack'; screen.className = 'stack';
@ -16,6 +32,10 @@ export function render({ navigate }) {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'card stack'; card.className = 'card stack';
const status = document.createElement('p');
status.className = 'status-line is-unavailable';
status.style.display = 'none';
const walletValue = document.createElement('input'); const walletValue = document.createElement('input');
walletValue.className = 'input'; walletValue.className = 'input';
walletValue.type = 'text'; walletValue.type = 'text';
@ -39,7 +59,9 @@ export function render({ navigate }) {
copyButton.textContent = 'Скопировать номер'; copyButton.textContent = 'Скопировать номер';
}, 1500); }, 1500);
} catch { } 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.type = 'button';
submitButton.textContent = 'Зарегистрироваться'; submitButton.textContent = 'Зарегистрироваться';
submitButton.addEventListener('click', async () => { 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 { try {
submitButton.disabled = true; submitButton.disabled = true;
submitButton.textContent = 'Регистрация...'; submitButton.textContent = 'Регистрация...';
@ -85,12 +125,14 @@ export function render({ navigate }) {
state.registrationDraft.pendingKeyBundle = result.keyBundle; state.registrationDraft.pendingKeyBundle = result.keyBundle;
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial; state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
setAuthInfo(`Отлично, вы зарегистрировались: ${result.login}`); setAuthInfo(`Регистрация завершена. Вы вошли как @${result.login}. Далее откройте вкладку «Каналы».`);
window.alert('Отлично, вы зарегистрировались');
navigate('registration-keys-view'); navigate('registration-keys-view');
} catch (error) { } catch (error) {
setAuthError(error.message); const message = toUserMessage(error, 'Не удалось завершить регистрацию.');
window.alert(error.message); setAuthError(message);
status.className = 'status-line is-unavailable';
status.textContent = message;
status.style.display = '';
} finally { } finally {
submitButton.disabled = false; submitButton.disabled = false;
submitButton.textContent = 'Зарегистрироваться'; submitButton.textContent = 'Зарегистрироваться';
@ -106,7 +148,7 @@ export function render({ navigate }) {
`; `;
card.children[1].append(walletRow); card.children[1].append(walletRow);
card.children[2].append(balanceRow); card.children[2].append(balanceRow);
card.append(topupButton, submitButton); card.append(topupButton, submitButton, status);
screen.append( screen.append(
renderHeader({ renderHeader({

View File

@ -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 const pageMeta = { id: 'start-view', title: 'Старт', showAppChrome: false };
export function render({ navigate }) { export function render({ navigate }) {
clearStartHint();
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'auth-screen stack'; screen.className = 'auth-screen stack';
@ -37,17 +39,6 @@ export function render({ navigate }) {
settingsButton.addEventListener('click', () => navigate('entry-settings-view')); settingsButton.addEventListener('click', () => navigate('entry-settings-view'));
actions.append(loginButton, registerButton, settingsButton); actions.append(loginButton, registerButton, settingsButton);
screen.append(logo, title, actions);
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);
return screen; return screen;
} }

View File

@ -19,16 +19,51 @@ export function getRoute() {
return { pageId: '', params: {} }; 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') { if (pageId === 'chat-view') {
return { pageId, params: { chatId: dynamicId || '' } }; return { pageId, params: { chatId: dynamicId || '' } };
} }
if (pageId === 'channel-view') { 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 || '' } }; 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') { if (pageId === 'device-session-view') {
return { pageId, params: { sessionId: dynamicId || '' } }; return { pageId, params: { sessionId: dynamicId || '' } };
} }
@ -57,6 +92,6 @@ export function resolveToolbarActive(pageId) {
return 'profile-view'; return 'profile-view';
} }
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list'; 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'; return 'profile-view';
} }

View File

@ -12,6 +12,12 @@ import {
signBase64, signBase64,
utf8Bytes, utf8Bytes,
} from './crypto-utils.js'; } from './crypto-utils.js';
import {
channelNameErrorText,
normalizeChannelDisplayName,
toCanonicalChannelSlug,
validateChannelDisplayName,
} from './channel-name-rules.js';
import { import {
loadEncryptedUserSecrets, loadEncryptedUserSecrets,
loadSessionMaterial, loadSessionMaterial,
@ -20,20 +26,51 @@ import {
} from './key-vault.js'; } from './key-vault.js';
const BCH_SUFFIX = '001'; 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) { function normalizeServerUrl(url) {
const value = (url || '').trim(); const value = (url || '').trim();
if (!value) return 'wss://shineup.me/ws'; 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://')) { if (value.startsWith('https://') || value.startsWith('http://')) {
return `${value.replace(/^http/, 'ws').replace(/\/$/, '')}/ws`; 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; return value;
} }
function opError(op, response) { function opError(op, response) {
const message = response?.payload?.message || response?.message || 'Неизвестная ошибка сервера'; const payload = response?.payload || {};
const code = response?.payload?.code || response?.code || 'UNKNOWN'; 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})`); const error = new Error(`${op}: ${message} (${code})`);
error.op = op; error.op = op;
error.code = code; error.code = code;
@ -41,6 +78,27 @@ function opError(op, response) {
return error; 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() { function makeClientInfo() {
const ua = navigator.userAgent || 'unknown'; const ua = navigator.userAgent || 'unknown';
return ua.slice(0, 50); return ua.slice(0, 50);
@ -51,11 +109,21 @@ function hexToBytes(hex) {
if (!clean || clean.length % 2 !== 0) throw new Error('Некорректный hex'); if (!clean || clean.length % 2 !== 0) throw new Error('Некорректный hex');
const out = new Uint8Array(clean.length / 2); const out = new Uint8Array(clean.length / 2);
for (let i = 0; i < out.length; i += 1) { 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; 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) { function concatBytes(...chunks) {
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const out = new Uint8Array(total); const out = new Uint8Array(total);
@ -81,6 +149,12 @@ function int16Bytes(value) {
return bytes; 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) { function int64Bytes(value) {
const bytes = new Uint8Array(8); const bytes = new Uint8Array(8);
const view = new DataView(bytes.buffer); 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 { export class AuthService {
constructor(serverUrl) { constructor(serverUrl) {
this.serverUrl = normalizeServerUrl(serverUrl); this.serverUrl = normalizeServerUrl(serverUrl);
this.ws = new WsJsonClient(this.serverUrl); this.ws = new WsJsonClient(this.serverUrl);
this.headerHashCache = new Map();
this.writeLocks = new Map();
} }
async reconnect(serverUrl) { async reconnect(serverUrl) {
@ -119,6 +406,20 @@ export class AuthService {
this.ws.close(); this.ws.close();
this.serverUrl = normalized; this.serverUrl = normalized;
this.ws = new WsJsonClient(this.serverUrl); 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) { async getUser(login) {
@ -293,18 +594,483 @@ export class AuthService {
return response.payload || {}; return response.payload || {};
} }
async getChannelMessages(channel, limit = 200, sort = 'asc') { async getChannelMessages(channel, limit = 200, sort = 'asc', login = '') {
const response = await this.ws.request('GetChannelMessages', { channel, limit, sort }); 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); if (response.status !== 200) throw opError('GetChannelMessages', response);
return response.payload || {}; return response.payload || {};
} }
async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50) { async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50, login = '') {
const response = await this.ws.request('GetMessageThread', { message, depthUp, depthDown, limitChildrenPerNode }); 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); if (response.status !== 200) throw opError('GetMessageThread', response);
return response.payload || {}; 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) { onEvent(op, handler) {
return this.ws.onEvent(op, handler); return this.ws.onEvent(op, handler);

View 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 '';
}

View 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('');
}

View File

@ -1,4 +1,21 @@
const encoder = new TextEncoder(); 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) { function base64UrlToBase64(value) {
@ -8,7 +25,7 @@ function base64UrlToBase64(value) {
} }
export function randomBase64(byteLen = 32) { export function randomBase64(byteLen = 32) {
const bytes = crypto.getRandomValues(new Uint8Array(byteLen)); const bytes = getCryptoApi().getRandomValues(new Uint8Array(byteLen));
return bytesToBase64(bytes); return bytesToBase64(bytes);
} }
@ -35,7 +52,7 @@ export function utf8Bytes(value) {
} }
export async function sha256Bytes(bytes) { 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); return new Uint8Array(digest);
} }
@ -65,8 +82,9 @@ function ed25519Pkcs8FromSeed(seed32) {
export async function deriveEd25519FromPassword(password, suffix) { export async function deriveEd25519FromPassword(password, suffix) {
const seed = await derivePasswordSeed(password, suffix); const seed = await derivePasswordSeed(password, suffix);
const pkcs8 = ed25519Pkcs8FromSeed(seed); const pkcs8 = ed25519Pkcs8FromSeed(seed);
const privateKey = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']); const subtle = getSubtleApi();
const jwk = await crypto.subtle.exportKey('jwk', privateKey); 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'); if (!jwk.x) throw new Error('Не удалось получить публичный ключ Ed25519');
return { return {
@ -77,7 +95,8 @@ export async function deriveEd25519FromPassword(password, suffix) {
} }
export async function deriveAesKeyFromStoragePwd(storagePwd, saltBytes) { export async function deriveAesKeyFromStoragePwd(storagePwd, saltBytes) {
const baseKey = await crypto.subtle.importKey( const subtle = getSubtleApi();
const baseKey = await subtle.importKey(
'raw', 'raw',
utf8Bytes(storagePwd), utf8Bytes(storagePwd),
{ name: 'PBKDF2' }, { name: 'PBKDF2' },
@ -85,7 +104,7 @@ export async function deriveAesKeyFromStoragePwd(storagePwd, saltBytes) {
['deriveKey'], ['deriveKey'],
); );
return crypto.subtle.deriveKey( return subtle.deriveKey(
{ {
name: 'PBKDF2', name: 'PBKDF2',
salt: saltBytes, salt: saltBytes,
@ -103,11 +122,13 @@ export async function deriveAesKeyFromStoragePwd(storagePwd, saltBytes) {
} }
export async function encryptJsonWithStoragePwd(value, storagePwd) { export async function encryptJsonWithStoragePwd(value, storagePwd) {
const salt = crypto.getRandomValues(new Uint8Array(16)); const cryptoApi = getCryptoApi();
const iv = crypto.getRandomValues(new Uint8Array(12)); const subtle = getSubtleApi();
const salt = cryptoApi.getRandomValues(new Uint8Array(16));
const iv = cryptoApi.getRandomValues(new Uint8Array(12));
const key = await deriveAesKeyFromStoragePwd(storagePwd, salt); const key = await deriveAesKeyFromStoragePwd(storagePwd, salt);
const plainBytes = utf8Bytes(JSON.stringify(value)); 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 { return {
saltB64: bytesToBase64(salt), saltB64: bytesToBase64(salt),
@ -121,35 +142,35 @@ export async function decryptJsonWithStoragePwd(envelope, storagePwd) {
const iv = base64ToBytes(envelope.ivB64); const iv = base64ToBytes(envelope.ivB64);
const cipher = base64ToBytes(envelope.cipherB64); const cipher = base64ToBytes(envelope.cipherB64);
const key = await deriveAesKeyFromStoragePwd(storagePwd, salt); 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); const text = new TextDecoder().decode(plain);
return JSON.parse(text); return JSON.parse(text);
} }
export async function generateEd25519Pair() { 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) { 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)); return bytesToBase64(new Uint8Array(raw));
} }
export async function exportPkcs8B64(privateKey) { 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)); return bytesToBase64(new Uint8Array(raw));
} }
export async function importPkcs8Ed25519(pkcs8B64) { 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) { 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)); return bytesToBase64(new Uint8Array(signature));
} }
export async function signBytes(privateKey, bytes) { 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); return new Uint8Array(signature);
} }

View 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;
}

View File

@ -5,12 +5,52 @@ const DEFAULT_TIMEOUT_MS = 12000;
function buildWsUrl(raw) { function buildWsUrl(raw) {
const value = (raw || '').trim(); const value = (raw || '').trim();
if (!value) return 'wss://shineup.me/ws'; if (!value) return 'wss://shineup.me/ws';
if (value.startsWith('ws://') || value.startsWith('wss://')) return value; if (value.startsWith('/')) {
if (value.startsWith('http://')) return `ws://${value.slice('http://'.length)}`; const secure = window.location.protocol === 'https:';
if (value.startsWith('https://')) return `wss://${value.slice('https://'.length)}`; 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; 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) { function createRequestId(op) {
return `${op}-${Date.now()}-${Math.random().toString(16).slice(2)}`; return `${op}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
} }
@ -27,6 +67,15 @@ export class WsJsonClient {
async open() { async open() {
if (this.ws && this.ws.readyState === WebSocket.OPEN) return; if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
if (this.openPromise) return this.openPromise; 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) => { this.openPromise = new Promise((resolve, reject) => {
const ws = new WebSocket(this.url); const ws = new WebSocket(this.url);
@ -54,7 +103,6 @@ export class WsJsonClient {
}); });
}).finally(() => { }).finally(() => {
this.openPromise = null; this.openPromise = null;
this.eventListeners = new Map();
}); });
return this.openPromise; return this.openPromise;

View File

@ -4,6 +4,7 @@ import { clearClientAuthData } from './services/key-vault.js';
const clone = (value) => JSON.parse(JSON.stringify(value)); const clone = (value) => JSON.parse(JSON.stringify(value));
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1'; const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
const REACTIONS_STORAGE_KEY = 'shine-ui-message-reactions-v2';
const INVALID_SESSION_CODES = new Set([ const INVALID_SESSION_CODES = new Set([
'NOT_AUTHENTICATED', 'NOT_AUTHENTICATED',
'SESSION_NOT_FOUND', 'SESSION_NOT_FOUND',
@ -13,18 +14,63 @@ const INVALID_SESSION_CODES = new Set([
function readLocalWsOverrideUrl() { function readLocalWsOverrideUrl() {
try { 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); const asNum = Number(value);
if (!Number.isFinite(asNum)) return ''; if (!Number.isFinite(asNum)) return '';
const port = Math.trunc(asNum); const port = Math.trunc(asNum);
if (port <= 0 || port > 65535) return ''; 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 { } catch {
return ''; 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'; const DEFAULT_SHINE_SERVER = 'wss://shineup.me/ws';
function loadStoredSession() { 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) { function persistSession(session) {
try { try {
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session)); localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
@ -55,6 +121,7 @@ function clearStoredSession() {
function createInitialState({ withStoredSession = true } = {}) { function createInitialState({ withStoredSession = true } = {}) {
const storedSession = withStoredSession ? loadStoredSession() : null; const storedSession = withStoredSession ? loadStoredSession() : null;
const storedReactions = loadStoredReactions();
const initialShineServer = LOCAL_WS_OVERRIDE_URL || DEFAULT_SHINE_SERVER; const initialShineServer = LOCAL_WS_OVERRIDE_URL || DEFAULT_SHINE_SERVER;
return { return {
@ -120,6 +187,7 @@ function createInitialState({ withStoredSession = true } = {}) {
channelsFeed: null, channelsFeed: null,
channelsIndex: {}, channelsIndex: {},
localChannelPosts: {}, localChannelPosts: {},
messageReactions: storedReactions,
}; };
} }
@ -247,6 +315,10 @@ function resetStateForSignedOut() {
state.deviceConnect = next.deviceConnect; state.deviceConnect = next.deviceConnect;
state.authUi = next.authUi; state.authUi = next.authUi;
state.sessions = next.sessions; 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 = '' } = {}) { export async function terminateCurrentSession({ infoMessage = '' } = {}) {
@ -295,3 +367,31 @@ export function addLocalChannelPost(channelId, post) {
body: text, 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

View File

@ -7,9 +7,11 @@ body {
width: min(100vw, 430px); width: min(100vw, 430px);
height: 100dvh; height: 100dvh;
position: relative; position: relative;
background: linear-gradient(165deg, rgba(16, 22, 36, 0.96), rgba(11, 16, 27, 0.99)); background:
border-left: 1px solid rgba(255, 255, 255, 0.05); radial-gradient(circle at 16% -8%, rgba(211, 168, 76, 0.16), transparent 38%),
border-right: 1px solid rgba(255, 255, 255, 0.05); 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); box-shadow: var(--shadow);
overflow: hidden; overflow: hidden;
} }
@ -36,7 +38,7 @@ body {
right: 0; right: 0;
bottom: 0; bottom: 0;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom)); 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) { @media (min-width: 900px) {

View File

@ -1,14 +1,14 @@
:root { :root {
--bg-0: #080b12; --bg-0: #050c1a;
--bg-1: #101624; --bg-1: #0a1630;
--bg-2: #171f32; --bg-2: #132346;
--card: #1a2436; --card: #162646;
--card-soft: #202d45; --card-soft: #1a2f55;
--line: #2a3854; --line: #2f4777;
--text: #ebf1ff; --text: #edf2ff;
--text-muted: #99a8cb; --text-muted: #9eb0d8;
--accent: #53d8fb; --accent: #d9b56f;
--accent-soft: rgba(83, 216, 251, 0.17); --accent-soft: rgba(217, 181, 111, 0.18);
--danger: #ff718f; --danger: #ff718f;
--ok: #84f4a1; --ok: #84f4a1;
--radius-lg: 18px; --radius-lg: 18px;
@ -27,7 +27,10 @@ body {
margin: 0; margin: 0;
padding: 0; padding: 0;
min-height: 100%; 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); color: var(--text);
font-family: var(--font-main); font-family: var(--font-main);
} }

View File

@ -3,8 +3,7 @@ package blockchain;
import blockchain.body.*; import blockchain.body.*;
/** /**
* Парсер body выбирает класс по header: type/subType/version, * Parser for body record by header type/subType/version.
* потому что bodyBytes больше НЕ содержат type/subType/version.
*/ */
public final class BodyRecordParser { public final class BodyRecordParser {
@ -15,25 +14,26 @@ public final class BodyRecordParser {
int t = type & 0xFFFF; int t = type & 0xFFFF;
int v = version & 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; int key = (t << 16) | v;
BodyRecord r = switch (key) { 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 -> { case TextBody.KEY -> {
int st = subType & 0xFFFF;
if (st == (MsgSubType.TEXT_POST & 0xFFFF) if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|| st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
yield new TextLineBody(subType, version, bodyBytes); yield new TextLineBody(subType, version, bodyBytes);
@ -47,13 +47,13 @@ public final class BodyRecordParser {
throw new IllegalArgumentException("Unknown TEXT subType for type=1 ver=1: subType=" + st); throw new IllegalArgumentException("Unknown TEXT subType for type=1 ver=1: subType=" + st);
} }
case ReactionBody.KEY -> new ReactionBody(subType, version, bodyBytes); case ReactionBody.KEY -> new ReactionBody(subType, version, bodyBytes);
case ConnectionBody.KEY -> new ConnectionBody(subType, version, bodyBytes); case ConnectionBody.KEY -> new ConnectionBody(subType, version, bodyBytes);
case UserParamBody.KEY -> new UserParamBody(subType, version, bodyBytes); case UserParamBody.KEY -> new UserParamBody(subType, version, bodyBytes);
default -> throw new IllegalArgumentException(String.format( default -> throw new IllegalArgumentException(String.format(
"Unknown body type/version from header: type=%d ver=%d subType=%d", "Unknown body type/version from header: type=%d ver=%d subType=%d",
t, v, (subType & 0xFFFF) t, v, st
)); ));
}; };

View File

@ -68,6 +68,8 @@ public final class MsgSubType {
/** Лайк (LIKE). */ /** Лайк (LIKE). */
public static final short REACTION_LIKE = 1; public static final short REACTION_LIKE = 1;
/** Снятие лайка (UNLIKE). */
public static final short REACTION_UNLIKE = 2;
/* ===================== CONNECTION (msg_type=3) ===================== */ /* ===================== CONNECTION (msg_type=3) ===================== */

View File

@ -9,48 +9,51 @@ import java.util.Arrays;
import java.util.Objects; import java.util.Objects;
/** /**
* CreateChannelBody TECH сообщение создания канала. * TECH body for create channel.
* *
* type=0, ver=1 (в заголовке блока) * v1 body bytes:
* subType=MsgSubType.TECH_CREATE_CHANNEL (=1) * [4] lineCode
*
* Это сообщение идёт по ТЕХ-ЛИНИИ (hasLine):
* - prevLineNumber/hash указывают на предыдущее TECH-сообщение (HEADER или прошлый CREATE_CHANNEL)
* - thisLineNumber: 1,2,3... (тех-нумерация)
*
* bodyBytes (BigEndian), новый формат line-prefix:
* [4] lineCode (для TECH линии обычно 0)
* [4] prevLineNumber * [4] prevLineNumber
* [32] prevLineHash32 * [32] prevLineHash32
* [4] thisLineNumber * [4] thisLineNumber
* [1] channelNameLen (uint8) * [1] channelNameLen
* [N] channelName UTF-8 (^[A-Za-z0-9_]+$) * [N] channelName UTF-8
* *
* Важно: * v2 body bytes:
* - канал "0" зарезервирован (создаётся по умолчанию от HEADER), создавать его нельзя. * [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 final class CreateChannelBody implements BodyRecord, BodyHasLine {
public static final short TYPE = 0; public static final short TYPE = 0;
public static final short VER = 1; 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 = ((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; public static final short SUBTYPE = MsgSubType.TECH_CREATE_CHANNEL;
private static final byte[] ZERO32 = new byte[32]; 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 subType;
public final short version; // из header public final short version;
// line
public final int lineCode; public final int lineCode;
public final int prevLineNumber; public final int prevLineNumber;
public final byte[] prevLineHash32; // 32 public final byte[] prevLineHash32;
public final int thisLineNumber; public final int thisLineNumber;
// payload
public final String channelName; public final String channelName;
public final String channelDescription;
public CreateChannelBody(short subType, short version, byte[] bodyBytes) { public CreateChannelBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null"); Objects.requireNonNull(bodyBytes, "bodyBytes == null");
@ -58,14 +61,14 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
this.subType = subType; this.subType = subType;
this.version = version; this.version = version;
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) { int ver = this.version & 0xFFFF;
throw new IllegalArgumentException("CreateChannelBody version must be 1, got=" + (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)) { if ((this.subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) {
throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1), got=" + (this.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) { if (bodyBytes.length < 4 + (4 + 32 + 4) + 1 + 1) {
throw new IllegalArgumentException("CreateChannelBody too short"); 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); ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
this.lineCode = bb.getInt(); this.lineCode = bb.getInt();
this.prevLineNumber = bb.getInt(); this.prevLineNumber = bb.getInt();
this.prevLineHash32 = new byte[32]; this.prevLineHash32 = new byte[32];
@ -83,16 +85,44 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
int nameLen = Byte.toUnsignedInt(bb.get()); int nameLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("channelNameLen is 0"); 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); throw new IllegalArgumentException("CreateChannelBody tail mismatch: remaining=" + bb.remaining() + " nameLen=" + nameLen);
} }
byte[] nameBytes = new byte[nameLen]; byte[] nameBytes = new byte[nameLen];
bb.get(nameBytes); bb.get(nameBytes);
this.channelName = new String(nameBytes, StandardCharsets.UTF_8); 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, public CreateChannelBody(int lineCode,
@ -100,11 +130,30 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
byte[] prevLineHash32, byte[] prevLineHash32,
int thisLineNumber, int thisLineNumber,
String channelName) { 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"); Objects.requireNonNull(channelName, "channelName == null");
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
this.subType = SUBTYPE; this.subType = SUBTYPE;
this.version = VER; this.version = version;
this.lineCode = lineCode; this.lineCode = lineCode;
this.prevLineNumber = prevLineNumber; this.prevLineNumber = prevLineNumber;
@ -112,46 +161,73 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
this.thisLineNumber = thisLineNumber; this.thisLineNumber = thisLineNumber;
this.channelName = channelName; this.channelName = channelName;
this.channelDescription = channelDescription == null ? "" : channelDescription;
} }
@Override @Override
public CreateChannelBody check() { public CreateChannelBody check() {
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); 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)"); 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"); throw new IllegalArgumentException("channelName is blank");
}
if (!channelName.matches("^[A-Za-z0-9_]+$")) int cpLen = normalizedName.codePointCount(0, normalizedName.length());
throw new IllegalArgumentException("channelName must match ^[A-Za-z0-9_]+$"); if (cpLen > MAX_NAME_LENGTH) {
throw new IllegalArgumentException("channelName length must be <=32");
}
if ("0".equals(channelName)) String normalizedDescription = normalizeDescription(channelDescription);
throw new IllegalArgumentException("channelName \"0\" is reserved"); 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"); 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"); throw new IllegalArgumentException("prevLineHash32 invalid");
if (thisLineNumber <= 0) }
if (thisLineNumber <= 0) {
throw new IllegalArgumentException("thisLineNumber must be >=1 for CreateChannelBody"); throw new IllegalArgumentException("thisLineNumber must be >=1 for CreateChannelBody");
}
return this; 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 @Override
public byte[] toBytes() { public byte[] toBytes() {
byte[] nameUtf8 = channelName.getBytes(StandardCharsets.UTF_8); byte[] nameUtf8 = normalizeDisplayName(channelName).getBytes(StandardCharsets.UTF_8);
if (nameUtf8.length == 0 || nameUtf8.length > 255) if (nameUtf8.length == 0 || nameUtf8.length > 255) {
throw new IllegalArgumentException("channelName utf8 len must be 1..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); ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.putInt(lineCode); bb.putInt(lineCode);
bb.putInt(prevLineNumber); bb.putInt(prevLineNumber);
bb.put(prevLineHash32 == null ? ZERO32 : Arrays.copyOf(prevLineHash32, 32)); bb.put(prevLineHash32 == null ? ZERO32 : Arrays.copyOf(prevLineHash32, 32));
bb.putInt(thisLineNumber); bb.putInt(thisLineNumber);
@ -159,12 +235,27 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
bb.put((byte) nameUtf8.length); bb.put((byte) nameUtf8.length);
bb.put(nameUtf8); bb.put(nameUtf8);
if (isV2) {
bb.putShort((short) (descriptionUtf8.length & 0xFFFF));
if (descriptionUtf8.length > 0) {
bb.put(descriptionUtf8);
}
}
return bb.array(); return bb.array();
} }
/* ====================== BodyHasLine ====================== */ @Override
@Override public int lineCode() { return lineCode; } public int lineCode() { return lineCode; }
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
@Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); } @Override
@Override public int lineSeq() { return thisLineNumber; } public int prevLineBlockGlobalNumber() { return prevLineNumber; }
@Override
public byte[] prevLineBlockHash32() {
return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32);
}
@Override
public int lineSeq() { return thisLineNumber; }
} }

View File

@ -13,6 +13,7 @@ import java.util.Objects;
* *
* subType (в заголовке блока): * subType (в заголовке блока):
* 1 = LIKE * 1 = LIKE
* 2 = UNLIKE
* *
* bodyBytes (BigEndian), новый формат: * bodyBytes (BigEndian), новый формат:
* [1] toBlockchainNameLen (uint8) * [1] toBlockchainNameLen (uint8)
@ -45,7 +46,7 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) { if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("ReactionBody version must be 1, got=" + (this.version & 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)); throw new IllegalArgumentException("Bad reaction subType: " + (this.subType & 0xFFFF));
} }
@ -88,7 +89,7 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
@Override @Override
public ReactionBody check() { public ReactionBody check() {
if ((subType & 0xFFFF) != (MsgSubType.REACTION_LIKE & 0xFFFF)) if (!isSupportedSubType(subType))
throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF)); throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF));
if (toBlockchainName == null || toBlockchainName.isBlank()) if (toBlockchainName == null || toBlockchainName.isBlank())
@ -123,4 +124,10 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
@Override public String toBchName() { return toBlockchainName; } @Override public String toBchName() { return toBlockchainName; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; } @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);
}
} }

View File

@ -36,6 +36,7 @@ public final class DatabaseInitializer {
/* ===================== REACTION (msg_type=2) ===================== */ /* ===================== REACTION (msg_type=2) ===================== */
public static final short REACTION_LIKE = 1; public static final short REACTION_LIKE = 1;
public static final short REACTION_UNLIKE = 2;
/* ===================== CONNECTION (msg_type=3) ===================== */ /* ===================== CONNECTION (msg_type=3) ===================== */
public static final short CONNECTION_FRIEND = 10; public static final short CONNECTION_FRIEND = 10;
@ -273,12 +274,12 @@ public final class DatabaseInitializer {
rel_type INTEGER NOT NULL, rel_type INTEGER NOT NULL,
to_login TEXT NOT NULL, to_login TEXT NOT NULL,
to_bch_name TEXT NOT NULL, to_bch_name TEXT NOT NULL,
to_block_number INTEGER, to_block_number INTEGER NOT NULL,
to_block_hash BLOB, to_block_hash BLOB NOT NULL,
FOREIGN KEY (login) REFERENCES solana_users(login), 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); 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 // 8) message_stats
st.executeUpdate(""" st.executeUpdate("""
CREATE TABLE IF NOT EXISTS message_stats ( CREATE TABLE IF NOT EXISTS message_stats (
@ -328,7 +334,70 @@ public final class DatabaseInitializer {
ON message_stats (to_login); 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(""" st.executeUpdate("""
CREATE TABLE IF NOT EXISTS direct_messages ( CREATE TABLE IF NOT EXISTS direct_messages (
message_id TEXT NOT NULL PRIMARY KEY, message_id TEXT NOT NULL PRIMARY KEY,
@ -351,7 +420,7 @@ public final class DatabaseInitializer {
ON direct_messages (from_login, created_at_ms); ON direct_messages (from_login, created_at_ms);
"""); """);
// 10) user_push_tokens // 11) user_push_tokens
st.executeUpdate(""" st.executeUpdate("""
CREATE TABLE IF NOT EXISTS user_push_tokens ( CREATE TABLE IF NOT EXISTS user_push_tokens (
token_id TEXT NOT NULL PRIMARY KEY, token_id TEXT NOT NULL PRIMARY KEY,

View File

@ -1,52 +1,55 @@
package shine.db; package shine.db;
import java.sql.SQLException; import java.sql.SQLException;
import java.sql.ResultSet;
import java.sql.Statement; import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
/** /**
* DatabaseTriggersInstaller устанавливает триггеры, которые поддерживают бизнес-логику БД. * DatabaseTriggersInstaller †усСР°РЅР°РІР»РёРІР°РµС СриггерС, РєРѕСРѕСЂСРµ поддерРРёРІР°СЋС Р±РёР·РЅРµСЃ-логику РР.
* *
* Мы специально сделали триггеры максимально "совместимыми": * РњС СЃРїРµСиально сделали ССЂРёРіРіРµСЂС РјР°РєСЃРёРјР°Р»СЊРЅРѕ "совместимыми":
* - НЕТ динамических сообщений в RAISE(...): только фиксированные строки. * - РќРРў динамиСРµСЃРєРёС СЃРѕРѕР±СениРв RAISE(...): Солько СиксированнСРµ СЃССЂРѕРєРё.
* (Некоторые SQLite-сборки / просмотрщики падают на "||" внутри RAISE.) * (НекоСРѕСЂСРµ SQLite-СЃР±РѕСЂРєРё / РїСЂРѕСЃРјРѕССЂСРёРєРё РїР°РґР°СЋС РЅР° "||" РІРЅСѓССЂРё RAISE.)
* - НЕТ UPSERT "ON CONFLICT DO UPDATE" вместо него: * - РќРРў UPSERT "ON CONFLICT DO UPDATE" †вмесСРѕ него:
* INSERT OR IGNORE + UPDATE * INSERT OR IGNORE + UPDATE
* (Старые SQLite не знают UPSERT.) * (РЎСарСРµ SQLite РЅРµ Р·РЅР°СЋС UPSERT.)
* *
* ============================================================================= * =============================================================================
* ОПИСАНИЕ ТРИГГЕРОВ * ОПИСАНИРТРИРРРР РћР
* ============================================================================= * =============================================================================
* *
* [1] trg_blocks_line_integrity_bi (BEFORE INSERT ON blocks) * [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) * 0 (TECH), 1 (TEXT), 3 (CONNECTION), 4 (USER_PARAM)
* B) Если пришло хоть одно line-поле обязаны прийти ВСЕ 4 (никаких "частичных") * B) Рсли РїСЂРёСло СРѕССЊ РѕРґРЅРѕ line-поле вЂ РѕР±СЏР·Р°РЅС РїСЂРёРСРё РРЎР 4 (РЅРёРєР°РєРёС "частичных")
* C) prev-блок линии существует в той же цепочке bch_name * C) prev-блок линии СЃСѓСесСРІСѓРµС РІ СРѕР РРµ СепоСРєРµ bch_name
* D) prev_hash совпадает с block_hash найденного prev-блока * D) prev_hash СЃРѕРІРїР°РґР°РµС СЃ block_hash РЅР°Рденного prev-блока
* E) line_code корректный: * E) line_code коррекСРЅСР:
* - либо первый шаг после root: prev_line_number == line_code * - либо первСР Саг после root: prev_line_number == line_code
* - либо prev уже принадлежит этой линии: p.line_code == NEW.line_code * - либо prev СѓРРµ принадлеРРёС СЌСоРлинии: p.line_code == NEW.line_code
* F) this_line_number: * F) this_line_number:
* - первый шаг после root: * - первСР Саг после root:
* TEXT: this_line_number = 0 * TEXT: this_line_number = 0
* TECH/CONNECTION/USER_PARAM: this_line_number = 1 * TECH/CONNECTION/USER_PARAM: this_line_number = 1
* - обычный шаг: * - РѕР±ССРЅСР Саг:
* TEXT: допускаем same или +1 (чтобы "edit" мог не двигать шаг) * TEXT: допускаем same или +1 (ССРѕР±С "edit" РјРѕРі РЅРµ РґРІРёРіР°ССЊ Саг)
* TECH/CONNECTION/USER_PARAM: строго prev.this + 1 * TECH/CONNECTION/USER_PARAM: СЃССЂРѕРіРѕ prev.this + 1
* *
* Какие ошибки кидает: * Какие РѕСРёР±РєРё кидаеС:
* - LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE * - LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE
* - LINE_ERR_PARTIAL_FIELDS * - LINE_ERR_PARTIAL_FIELDS
* - LINE_ERR_NO_PREV * - LINE_ERR_NO_PREV
@ -56,28 +59,30 @@ import java.sql.Statement;
* - LINE_ERR_THIS_LINE_BAD_STEP * - LINE_ERR_THIS_LINE_BAD_STEP
* *
* [2] trg_blocks_connection_state_ai (AFTER INSERT ON blocks WHEN msg_type=3) * [2] trg_blocks_connection_state_ai (AFTER INSERT ON blocks WHEN msg_type=3)
* Поддерживает таблицу connections_state как "текущее состояние" отношений: * ПоддерРРёРІР°РµС СаблиССѓ connections_state как "текущее состояние" РѕСРЅРѕСениР:
* - FRIEND/CONTACT/FOLLOW -> добавить/обновить состояние * - FRIEND/CONTACT/FOLLOW -> добавиССЊ/РѕР±РЅРѕРІРёССЊ СЃРѕСЃСРѕСЏРЅРёРµ
* - UNFRIEND/UNCONTACT/UNFOLLOW -> удалить соответствующее "позитивное" состояние * - UNFRIEND/UNCONTACT/UNFOLLOW -> удалиССЊ СЃРѕРѕСРІРµССЃСРІСѓСЋСее "позитивное" СЃРѕСЃСРѕСЏРЅРёРµ
* *
* [3] trg_blocks_message_stats_like_ai (AFTER INSERT ON blocks WHEN msg_type=2 AND sub_type=LIKE) * [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) * [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) * [5] trg_blocks_edit_apply_ai (AFTER INSERT ON blocks WHEN msg_type=1 AND sub_type=EDIT)
* Логика edit: * РРѕРіРёРєР° edit:
* - помечает исходный блок edited_by_block_number = NEW.block_number * - РїРѕРјРµСР°РµС РёСЃСРѕРґРЅСРблок edited_by_block_number = NEW.block_number
* - увеличивает edits_count в message_stats * - увелиСРёРІР°РµС edits_count РІ message_stats
*/ */
public final class DatabaseTriggersInstaller { public final class DatabaseTriggersInstaller {
private DatabaseTriggersInstaller() {} private DatabaseTriggersInstaller() {}
public static void createAllTriggers(Statement st) throws SQLException { 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_block_lini_integriti_by;");
st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_line_integrity_bi;"); st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_line_integrity_bi;");
@ -93,6 +98,24 @@ public final class DatabaseTriggersInstaller {
createEditApplyTrigger(st); 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 { private static void createLineIntegrityTrigger(Statement st) throws SQLException {
st.executeUpdate(""" st.executeUpdate("""
CREATE TRIGGER IF NOT EXISTS trg_blocks_line_integrity_bi CREATE TRIGGER IF NOT EXISTS trg_blocks_line_integrity_bi
@ -182,22 +205,40 @@ public final class DatabaseTriggersInstaller {
WHEN NEW.msg_type = 3 WHEN NEW.msg_type = 3
BEGIN BEGIN
-- FRIEND/CONTACT/FOLLOW: -- FRIEND/CONTACT/FOLLOW:
-- 1) если записи нет создаём -- 1) если записи РЅРµС вЂ СЃРѕР·РґР°СРј
INSERT OR IGNORE INTO connections_state ( INSERT OR IGNORE INTO connections_state (
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
) )
SELECT SELECT
NEW.login, NEW.login,
NEW.msg_sub_type, NEW.msg_sub_type,
NEW.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
),
NEW.to_bch_name, NEW.to_bch_name,
NEW.to_block_number, NEW.to_block_number,
NEW.to_block_hash NEW.to_block_hash
WHERE NEW.msg_sub_type IN (%d, %d, %d) 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; AND NEW.to_bch_name IS NOT NULL;
-- 2) если запись есть обновляем актуальные to_* -- 2) если запись есССЊ †обновляем акСуальнСРµ to_*
UPDATE connections_state UPDATE connections_state
SET SET
to_bch_name = NEW.to_bch_name, to_bch_name = NEW.to_bch_name,
@ -205,27 +246,64 @@ public final class DatabaseTriggersInstaller {
to_block_hash = NEW.to_block_hash to_block_hash = NEW.to_block_hash
WHERE login = NEW.login WHERE login = NEW.login
AND rel_type = NEW.msg_sub_type AND rel_type = NEW.msg_sub_type
AND to_login = NEW.to_login AND to_login = COALESCE(
AND NEW.msg_sub_type IN (%d, %d, %d) NEW.to_login,
AND NEW.to_login IS NOT NULL 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; AND NEW.to_bch_name IS NOT NULL;
-- UNFRIEND/UNCONTACT/UNFOLLOW: -- UNFRIEND/UNCONTACT/UNFOLLOW:
-- удаляем соответствующее "позитивное" состояние -- удаляем СЃРѕРѕСРІРµССЃСРІСѓСЋСее "позитивное" СЃРѕСЃСРѕСЏРЅРёРµ
DELETE FROM connections_state DELETE FROM connections_state
WHERE login = NEW.login 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 AND rel_type = CASE NEW.msg_sub_type
WHEN %d THEN %d WHEN %d THEN %d
WHEN %d THEN %d WHEN %d THEN %d
WHEN %d THEN %d WHEN %d THEN %d
ELSE rel_type ELSE rel_type
END 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); AND NEW.msg_sub_type IN (%d, %d, %d);
END; END;
""".formatted( """.formatted(
FRIEND, CONTACT, FOLLOW, FRIEND, CONTACT, FOLLOW,
FRIEND, CONTACT, FOLLOW, FRIEND, CONTACT,
UNFRIEND, FRIEND, UNFRIEND, FRIEND,
UNCONTACT, CONTACT, UNCONTACT, CONTACT,
@ -237,13 +315,14 @@ public final class DatabaseTriggersInstaller {
private static void createMessageStatsLikeTrigger(Statement st) throws SQLException { private static void createMessageStatsLikeTrigger(Statement st) throws SQLException {
int LIKE = (int) DatabaseInitializer.REACTION_LIKE; int LIKE = (int) DatabaseInitializer.REACTION_LIKE;
int UNLIKE = (int) DatabaseInitializer.REACTION_UNLIKE;
st.executeUpdate(""" st.executeUpdate("""
CREATE TRIGGER IF NOT EXISTS trg_blocks_message_stats_like_ai CREATE TRIGGER IF NOT EXISTS trg_blocks_message_stats_like_ai
AFTER INSERT ON blocks 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 BEGIN
-- создаём строку, если её не было -- ensure target stats row exists
INSERT OR IGNORE INTO message_stats ( INSERT OR IGNORE INTO message_stats (
to_login, to_bch_name, to_block_number, to_block_hash, to_login, to_bch_name, to_block_number, to_block_hash,
likes_count, replies_count, edits_count 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_number IS NOT NULL
AND NEW.to_block_hash 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 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 WHERE to_login = NEW.to_login
AND to_bch_name = NEW.to_bch_name AND to_bch_name = NEW.to_bch_name
AND to_block_number = NEW.to_block_number 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_bch_name IS NOT NULL
AND NEW.to_block_number IS NOT NULL AND NEW.to_block_number IS NOT NULL
AND NEW.to_block_hash 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; END;
""".formatted(LIKE)); """.formatted(
LIKE, UNLIKE,
LIKE, LIKE,
UNLIKE, LIKE,
LIKE,
LIKE
));
} }
private static void createMessageStatsReplyTrigger(Statement st) throws SQLException { private static void createMessageStatsReplyTrigger(Statement st) throws SQLException {
@ -314,7 +467,7 @@ public final class DatabaseTriggersInstaller {
AFTER INSERT ON blocks AFTER INSERT ON blocks
WHEN NEW.msg_type = 1 AND NEW.msg_sub_type IN (%d, %d) WHEN NEW.msg_type = 1 AND NEW.msg_sub_type IN (%d, %d)
BEGIN BEGIN
-- 1) помечаем исходный блок, что его "перекрыл" этот edit -- 1) РїРѕРјРµСаем РёСЃСРѕРґРЅСРблок, ССРѕ его "перекрыл" СЌСРѕС edit
UPDATE blocks UPDATE blocks
SET edited_by_block_number = NEW.block_number SET edited_by_block_number = NEW.block_number
WHERE login = NEW.login WHERE login = NEW.login
@ -322,7 +475,7 @@ public final class DatabaseTriggersInstaller {
AND block_number = NEW.to_block_number AND block_number = NEW.to_block_number
AND NEW.to_block_number IS NOT NULL; AND NEW.to_block_number IS NOT NULL;
-- 2) создаём stats-строку если её не было -- 2) СЃРѕР·РґР°СРј stats-СЃССЂРѕРєСѓ если РµС РЅРµ Р±Сло
INSERT OR IGNORE INTO message_stats ( INSERT OR IGNORE INTO message_stats (
to_login, to_bch_name, to_block_number, to_block_hash, to_login, to_bch_name, to_block_number, to_block_hash,
likes_count, replies_count, edits_count likes_count, replies_count, edits_count
@ -350,3 +503,4 @@ public final class DatabaseTriggersInstaller {
""".formatted(EDIT_POST, EDIT_REPLY)); """.formatted(EDIT_POST, EDIT_REPLY));
} }
} }

View File

@ -34,6 +34,8 @@ public final class MsgSubType {
/** Лайк (LIKE). */ /** Лайк (LIKE). */
public static final short REACTION_LIKE = 1; public static final short REACTION_LIKE = 1;
/** Снятие лайка (UNLIKE). */
public static final short REACTION_UNLIKE = 2;
/* ===================== CONNECTION (msg_type=3) ===================== */ /* ===================== CONNECTION (msg_type=3) ===================== */
/** /**

View File

@ -7,6 +7,7 @@ import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.sql.Connection; import java.sql.Connection;
import java.sql.DriverManager; import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.sql.Statement; import java.sql.Statement;
@ -37,6 +38,7 @@ public final class SqliteDbController {
} }
this.jdbcUrl = "jdbc:sqlite:" + dbPath; this.jdbcUrl = "jdbc:sqlite:" + dbPath;
ensureSchemaUpgrades();
} }
public static SqliteDbController getInstance() { public static SqliteDbController getInstance() {
@ -67,4 +69,223 @@ public final class SqliteDbController {
public void close() { public void close() {
// no-op // 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;
}
} }

View File

@ -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;
}
}

View File

@ -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);
}
}
}

View File

@ -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;
}
}

View File

@ -45,6 +45,7 @@ import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUser
// --- NEW: connections friends lists --- // --- NEW: connections friends lists ---
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetFriendsLists_Handler; 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.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_GetChannelMessages_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed_Handler;
@ -80,6 +81,10 @@ import java.util.Map;
*/ */
public final class JsonHandlerRegistry { public final class JsonHandlerRegistry {
static {
ChannelNamesStateBootstrapper.bootstrapOrFailFast();
}
private static final Map<String, JsonMessageHandler> HANDLERS = Map.ofEntries( private static final Map<String, JsonMessageHandler> HANDLERS = Map.ofEntries(
Map.entry("AddUser", new Net_AddUser_Handler()), Map.entry("AddUser", new Net_AddUser_Handler()),
Map.entry("GetUser", new Net_GetUser_Handler()), Map.entry("GetUser", new Net_GetUser_Handler()),

View File

@ -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.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_Request;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response; 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 server.logic.ws_protocol.WireCodes;
import shine.db.channels.ChannelNameRules;
import shine.db.dao.BlockchainStateDAO; import shine.db.dao.BlockchainStateDAO;
import shine.db.dao.BlocksDAO; import shine.db.dao.BlocksDAO;
import shine.db.dao.ChannelNameStateDAO;
import shine.db.dao.UserParamsDAO; import shine.db.dao.UserParamsDAO;
import shine.db.entities.BlockchainStateEntry; import shine.db.entities.BlockchainStateEntry;
import shine.db.entities.BlockEntry; import shine.db.entities.BlockEntry;
import shine.db.entities.ChannelNameStateEntry;
import shine.db.entities.UserParamEntry; import shine.db.entities.UserParamEntry;
import utils.blockchain.BlockchainNameUtil; import utils.blockchain.BlockchainNameUtil;
import java.util.Arrays; import java.util.Arrays;
import java.sql.Connection; import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.concurrent.locks.ReentrantLock; 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 BlocksDAO blocksDAO = BlocksDAO.getInstance();
private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance(); private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
private final UserParamsDAO userParamsDAO = UserParamsDAO.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 @Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) { 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) { private static String humanMessage(String code) {
if (code == null) return "Ошибка добавления блока"; if (code == null) return "Ошибка добавления блока"; return switch (code) {
return switch (code) {
case "empty_blockchain_name" -> "Пустое имя блокчейна"; case "empty_blockchain_name" -> "Пустое имя блокчейна";
case "bad_blockchain_name" -> "Некорректное имя блокчейна"; case "bad_blockchain_name" -> "Некорректное имя блокчейна";
case "db_error" -> "Ошибка базы данных"; case "db_error" -> "Ошибка базы данных";
@ -127,6 +132,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
case "limit_check_failed" -> "Ошибка проверки лимита размера"; case "limit_check_failed" -> "Ошибка проверки лимита размера";
case "bad_block_format" -> "Некорректный формат блока"; case "bad_block_format" -> "Некорректный формат блока";
case "bad_block_body" -> "Некорректное тело блока"; case "bad_block_body" -> "Некорректное тело блока";
case "bad_channel_name" -> "Некорректное название канала";
case "bad_block_number" -> "Некорректный номер блока"; case "bad_block_number" -> "Некорректный номер блока";
case "req_global_mismatch" -> "Номер блока в запросе не совпадает с номером в блоке"; case "req_global_mismatch" -> "Номер блока в запросе не совпадает с номером в блоке";
case "bad_prev_hash" -> "Некорректный prevHash (цепочка не совпадает)"; 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 "prev_line_block_not_found" -> "Не найден блок prevLineNumber для проверки линии";
case "bad_prev_line_hash" -> "Некорректный prevLineHash"; case "bad_prev_line_hash" -> "Некорректный prevLineHash";
case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine"; case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine";
case "channel_name_already_exists" -> "Канал с таким именем уже существует"; case "channel_name_already_exists" -> "Такое название канала уже занято";
case "internal_error" -> "Внутренняя ошибка сервера при записи блока"; case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
default -> "Ошибка: " + code; 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); return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_body", serverLastNum, serverLastHashHex);
} }
ChannelNameStateEntry channelNameStateEntry = null;
if (block.body instanceof CreateChannelBody createChannelBody) { if (block.body instanceof CreateChannelBody createChannelBody) {
final String normalizedName;
final String slug;
try { 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); return new AddBlockResult(409, "channel_name_already_exists", serverLastNum, serverLastHashHex);
} }
} catch (Exception e) { } catch (Exception e) {
@ -247,6 +263,20 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
blockchainName, createChannelBody.channelName, e); blockchainName, createChannelBody.channelName, e);
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex); 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 // 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) { } catch (Exception e) {
if (isChannelSlugConflict(e)) {
return new AddBlockResult(409, "channel_name_already_exists", serverLastNum, serverLastHashHex);
}
log.error("AddBlock: внутренняя ошибка при записи блока (login={}, blockchainName={}, blockNumber={})", log.error("AddBlock: внутренняя ошибка при записи блока (login={}, blockchainName={}, blockNumber={})",
login, blockchainName, block.blockNumber, e); login, blockchainName, block.blockNumber, e);
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex); 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); return Base64Ws.decode(b64);
} }
private boolean channelNameExists(String blockchainName, String channelName) throws Exception { private static boolean isChannelSlugConflict(Throwable throwable) {
String sql = """ Throwable cur = throwable;
SELECT block_bytes while (cur != null) {
FROM blocks String message = String.valueOf(cur.getMessage());
WHERE bch_name = ? AND msg_type = 0 AND msg_sub_type = 1 if (message.contains("channel_names_state.slug")
"""; || message.contains("uq_channel_names_state_slug")) {
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection(); return true;
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
}
}
} }
cur = cur.getCause();
} }
return false; return false;
} }

View File

@ -3,9 +3,11 @@ package server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_u
import blockchain.BchBlockEntry; import blockchain.BchBlockEntry;
import shine.db.dao.BlockchainStateDAO; import shine.db.dao.BlockchainStateDAO;
import shine.db.dao.BlocksDAO; import shine.db.dao.BlocksDAO;
import shine.db.dao.ChannelNameStateDAO;
import shine.db.dao.UserParamsDAO; import shine.db.dao.UserParamsDAO;
import shine.db.entities.BlockchainStateEntry; import shine.db.entities.BlockchainStateEntry;
import shine.db.entities.BlockEntry; import shine.db.entities.BlockEntry;
import shine.db.entities.ChannelNameStateEntry;
import shine.db.entities.UserParamEntry; import shine.db.entities.UserParamEntry;
import utils.files.FileStoreUtil; import utils.files.FileStoreUtil;
@ -23,20 +25,26 @@ public final class BlockchainWriter {
private final BlocksDAO blocksDAO; private final BlocksDAO blocksDAO;
private final BlockchainStateDAO stateDAO; private final BlockchainStateDAO stateDAO;
private final ChannelNameStateDAO channelNameStateDAO;
private final UserParamsDAO userParamsDAO; private final UserParamsDAO userParamsDAO;
private final FileStoreUtil fs = FileStoreUtil.getInstance(); 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.blocksDAO = blocksDAO;
this.stateDAO = stateDAO; this.stateDAO = stateDAO;
this.userParamsDAO = userParamsDAO; this.userParamsDAO = userParamsDAO;
this.channelNameStateDAO = channelNameStateDAO;
} }
public void appendBlockAndState(String blockchainName, public void appendBlockAndState(String blockchainName,
BchBlockEntry block, BchBlockEntry block,
BlockchainStateEntry st, BlockchainStateEntry st,
BlockEntry be, BlockEntry be,
UserParamEntry userParamEntry) throws SQLException { UserParamEntry userParamEntry,
ChannelNameStateEntry channelNameStateEntry) throws SQLException {
long nowMs = System.currentTimeMillis(); long nowMs = System.currentTimeMillis();
@ -59,6 +67,10 @@ public final class BlockchainWriter {
userParamsDAO.upsertIfNewer(c, userParamEntry); userParamsDAO.upsertIfNewer(c, userParamEntry);
} }
if (channelNameStateEntry != null) {
channelNameStateDAO.insert(c, channelNameStateEntry);
}
c.commit(); c.commit();
} catch (Exception e) { } catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {} try { c.rollback(); } catch (Exception ignored) {}

View File

@ -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);
}
}
}

View File

@ -4,6 +4,8 @@ import blockchain.BchBlockEntry;
import blockchain.body.BodyRecord; import blockchain.body.BodyRecord;
import blockchain.body.CreateChannelBody; import blockchain.body.CreateChannelBody;
import blockchain.body.TextBody; import blockchain.body.TextBody;
import blockchain.body.TextLineBody;
import blockchain.body.TextReplyBody;
import shine.db.MsgSubType; import shine.db.MsgSubType;
import java.sql.Connection; import java.sql.Connection;
@ -15,6 +17,7 @@ import java.util.List;
final class ChannelsReadSupport { final class ChannelsReadSupport {
static final int MSG_TYPE_TEXT = 1; static final int MSG_TYPE_TEXT = 1;
static final int MSG_TYPE_REACTION = 2;
static final int MSG_TYPE_TECH = 0; static final int MSG_TYPE_TECH = 0;
private ChannelsReadSupport() {} private ChannelsReadSupport() {}
@ -122,7 +125,11 @@ final class ChannelsReadSupport {
BchBlockEntry e = new BchBlockEntry(blockBytes); BchBlockEntry e = new BchBlockEntry(blockBytes);
TextInfo ti = new TextInfo(); TextInfo ti = new TextInfo();
ti.createdAtMs = e.timestamp * 1000L; 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; ti.text = tb.message;
} }
return ti; 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) { static byte[] hexToBytes(String s) {
if (s == null) return null; if (s == null) return null;
String x = s.trim(); String x = s.trim();

View File

@ -38,6 +38,10 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
boolean asc = req.getSort() == null || !"desc".equalsIgnoreCase(req.getSort()); boolean asc = req.getSort() == null || !"desc".equalsIgnoreCase(req.getSort());
try (Connection c = SqliteDbController.getInstance().getConnection()) { 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(); String ownerBch = req.getChannel().getOwnerBlockchainName();
int lineCode = req.getChannel().getChannelRootBlockNumber(); int lineCode = req.getChannel().getChannelRootBlockNumber();
@ -50,6 +54,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
channel.setOwnerBlockchainName(ownerBch); channel.setOwnerBlockchainName(ownerBch);
channel.setOwnerLogin(BlockchainNameUtil.loginFromBlockchainName(ownerBch)); channel.setOwnerLogin(BlockchainNameUtil.loginFromBlockchainName(ownerBch));
channel.setChannelName(ChannelsReadSupport.detectChannelName(c, ownerBch, lineCode)); channel.setChannelName(ChannelsReadSupport.detectChannelName(c, ownerBch, lineCode));
channel.setChannelDescription(ChannelsReadSupport.detectChannelDescription(c, ownerBch, lineCode));
Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef(); Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef();
rootRef.setBlockNumber(lineCode); rootRef.setBlockNumber(lineCode);
rootRef.setBlockHash(req.getChannel().getChannelRootBlockHash()); 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); int[] stats = ChannelsReadSupport.loadStats(c, ownerBch, post.blockNumber, post.blockHash);
item.setLikesCount(stats[0]); item.setLikesCount(stats[0]);
item.setRepliesCount(stats[1]); item.setRepliesCount(stats[1]);
item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
items.add(item); items.add(item);
} }

View File

@ -27,7 +27,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_GetMessageThread_Request req = (Net_GetMessageThread_Request) baseRequest; Net_GetMessageThread_Request req = (Net_GetMessageThread_Request) baseRequest;
if (req.getMessage() == null || req.getMessage().getBlockchainName() == null || req.getMessage().getBlockNumber() == null) { 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()); 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()); int childLimit = req.getLimitChildrenPerNode() == null ? 50 : Math.max(1, req.getLimitChildrenPerNode());
try (Connection c = SqliteDbController.getInstance().getConnection()) { 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()); PostRow focusRow = findByNumber(c, req.getMessage().getBlockchainName(), req.getMessage().getBlockNumber());
if (focusRow == null) { 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(); Net_GetMessageThread_Response resp = new Net_GetMessageThread_Response();
@ -45,7 +49,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
resp.setRequestId(req.getRequestId()); resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK); resp.setStatus(WireCodes.Status.OK);
resp.setFocus(toNode(c, focusRow)); resp.setFocus(toNode(c, focusRow, viewerLogin));
List<Net_GetMessageThread_Response.MessageNode> ancestors = new ArrayList<>(); List<Net_GetMessageThread_Response.MessageNode> ancestors = new ArrayList<>();
PostRow cur = focusRow; PostRow cur = focusRow;
@ -53,27 +57,27 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
if (cur.toBlockNumber == null || cur.toBchName == null) break; if (cur.toBlockNumber == null || cur.toBchName == null) break;
PostRow parent = findByNumber(c, cur.toBchName, cur.toBlockNumber); PostRow parent = findByNumber(c, cur.toBchName, cur.toBlockNumber);
if (parent == null) break; if (parent == null) break;
ancestors.add(0, toNode(c, parent)); ancestors.add(0, toNode(c, parent, viewerLogin));
cur = parent; cur = parent;
} }
resp.setAncestors(ancestors); resp.setAncestors(ancestors);
resp.setDescendants(loadChildren(c, focusRow, depthDown, childLimit)); resp.setDescendants(loadChildren(c, focusRow, depthDown, childLimit, viewerLogin));
return resp; return resp;
} catch (Exception e) { } catch (Exception e) {
log.error("GetMessageThread failed", 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(); if (depthDown <= 0) return List.of();
List<PostRow> replies = findReplies(c, parent.bchName, parent.blockNumber, parent.blockHash, childLimit); List<PostRow> replies = findReplies(c, parent.bchName, parent.blockNumber, parent.blockHash, childLimit);
List<Net_GetMessageThread_Response.MessageNodeTree> out = new ArrayList<>(); List<Net_GetMessageThread_Response.MessageNodeTree> out = new ArrayList<>();
for (PostRow row : replies) { for (PostRow row : replies) {
Net_GetMessageThread_Response.MessageNodeTree t = new Net_GetMessageThread_Response.MessageNodeTree(); Net_GetMessageThread_Response.MessageNodeTree t = new Net_GetMessageThread_Response.MessageNodeTree();
t.setNode(toNode(c, row)); t.setNode(toNode(c, row, viewerLogin));
t.setChildren(loadChildren(c, row, depthDown - 1, childLimit)); t.setChildren(loadChildren(c, row, depthDown - 1, childLimit, viewerLogin));
out.add(t); out.add(t);
} }
return out; return out;
@ -133,7 +137,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
return row; 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_GetMessageThread_Response.MessageNode node = new Net_GetMessageThread_Response.MessageNode();
Net_GetChannelMessages_Response.BlockRef ref = new Net_GetChannelMessages_Response.BlockRef(); Net_GetChannelMessages_Response.BlockRef ref = new Net_GetChannelMessages_Response.BlockRef();
ref.setBlockNumber(row.blockNumber); 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); int[] stats = ChannelsReadSupport.loadStats(c, row.bchName, row.blockNumber, row.blockHash);
node.setLikesCount(stats[0]); node.setLikesCount(stats[0]);
node.setRepliesCount(stats[1]); node.setRepliesCount(stats[1]);
node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
if (row.lineCode != null && row.lineCode >= 0) { if (row.lineCode != null && row.lineCode >= 0) {
Net_GetMessageThread_Response.ChannelInfo ci = new Net_GetMessageThread_Response.ChannelInfo(); Net_GetMessageThread_Response.ChannelInfo ci = new Net_GetMessageThread_Response.ChannelInfo();
@ -222,3 +227,4 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
int msgSubType; int msgSubType;
} }
} }

View File

@ -64,6 +64,7 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
channelRef.setOwnerLogin(key.ownerLogin); channelRef.setOwnerLogin(key.ownerLogin);
channelRef.setOwnerBlockchainName(key.ownerBch); channelRef.setOwnerBlockchainName(key.ownerBch);
channelRef.setChannelName(ChannelsReadSupport.detectChannelName(c, key.ownerBch, key.rootNumber)); channelRef.setChannelName(ChannelsReadSupport.detectChannelName(c, key.ownerBch, key.rootNumber));
channelRef.setChannelDescription(ChannelsReadSupport.detectChannelDescription(c, key.ownerBch, key.rootNumber));
channelRef.setPersonal(key.rootNumber == 0); channelRef.setPersonal(key.rootNumber == 0);
Net_ListSubscriptionsFeed_Response.BlockRef rootRef = new Net_ListSubscriptionsFeed_Response.BlockRef(); Net_ListSubscriptionsFeed_Response.BlockRef rootRef = new Net_ListSubscriptionsFeed_Response.BlockRef();

View File

@ -3,10 +3,14 @@ package server.logic.ws_protocol.JSON.handlers.channels.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request; import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_GetChannelMessages_Request extends Net_Request { public class Net_GetChannelMessages_Request extends Net_Request {
private String login;
private ChannelSelector channel; private ChannelSelector channel;
private Integer limit; private Integer limit;
private String sort; private String sort;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public ChannelSelector getChannel() { return channel; } public ChannelSelector getChannel() { return channel; }
public void setChannel(ChannelSelector channel) { this.channel = channel; } public void setChannel(ChannelSelector channel) { this.channel = channel; }

View File

@ -19,6 +19,7 @@ public class Net_GetChannelMessages_Response extends Net_Response {
private String ownerLogin; private String ownerLogin;
private String ownerBlockchainName; private String ownerBlockchainName;
private String channelName; private String channelName;
private String channelDescription;
private BlockRef channelRoot; private BlockRef channelRoot;
public String getOwnerLogin() { return ownerLogin; } public String getOwnerLogin() { return ownerLogin; }
@ -30,6 +31,9 @@ public class Net_GetChannelMessages_Response extends Net_Response {
public String getChannelName() { return channelName; } public String getChannelName() { return channelName; }
public void setChannelName(String channelName) { this.channelName = 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 BlockRef getChannelRoot() { return channelRoot; }
public void setChannelRoot(BlockRef channelRoot) { this.channelRoot = 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 long createdAtMs;
private String text; private String text;
private int likesCount; private int likesCount;
private boolean likedByMe;
private int repliesCount; private int repliesCount;
private int versionsTotal; private int versionsTotal;
private List<VersionItem> versions = new ArrayList<>(); private List<VersionItem> versions = new ArrayList<>();
@ -63,6 +68,9 @@ public class Net_GetChannelMessages_Response extends Net_Response {
public int getLikesCount() { return likesCount; } public int getLikesCount() { return likesCount; }
public void setLikesCount(int likesCount) { this.likesCount = 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 int getRepliesCount() { return repliesCount; }
public void setRepliesCount(int repliesCount) { this.repliesCount = repliesCount; } public void setRepliesCount(int repliesCount) { this.repliesCount = repliesCount; }

View File

@ -3,11 +3,15 @@ package server.logic.ws_protocol.JSON.handlers.channels.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request; import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_GetMessageThread_Request extends Net_Request { public class Net_GetMessageThread_Request extends Net_Request {
private String login;
private MessageSelector message; private MessageSelector message;
private Integer depthUp; private Integer depthUp;
private Integer depthDown; private Integer depthDown;
private Integer limitChildrenPerNode; private Integer limitChildrenPerNode;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public MessageSelector getMessage() { return message; } public MessageSelector getMessage() { return message; }
public void setMessage(MessageSelector message) { this.message = message; } public void setMessage(MessageSelector message) { this.message = message; }

View File

@ -42,6 +42,7 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
private String ownerLogin; private String ownerLogin;
private String ownerBlockchainName; private String ownerBlockchainName;
private String channelName; private String channelName;
private String channelDescription;
private boolean personal; private boolean personal;
private BlockRef channelRoot; private BlockRef channelRoot;
@ -54,6 +55,9 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
public String getChannelName() { return channelName; } public String getChannelName() { return channelName; }
public void setChannelName(String channelName) { this.channelName = 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 boolean isPersonal() { return personal; }
public void setPersonal(boolean personal) { this.personal = personal; } public void setPersonal(boolean personal) { this.personal = personal; }

View File

@ -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.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes; import server.logic.ws_protocol.WireCodes;
import shine.db.MsgSubType; import shine.db.MsgSubType;
import shine.db.dao.ConnectionsStateDAO;
import java.sql.Connection; import java.sql.Connection;
import java.sql.PreparedStatement; import java.sql.PreparedStatement;
@ -42,15 +41,10 @@ public class Net_AddCloseFriend_Handler implements JsonMessageHandler {
return NetExceptionResponseFactory.error(req, 404, "BLOCKCHAIN_NOT_FOUND", "У пользователя нет blockchain"); return NetExceptionResponseFactory.error(req, 404, "BLOCKCHAIN_NOT_FOUND", "У пользователя нет blockchain");
} }
ConnectionsStateDAO.getInstance().upsertRelation( // Idempotent insert for close-friend relation.
c, // Using INSERT OR IGNORE avoids ON CONFLICT(column list) mismatches
from, // across DB instances with different UNIQUE schemas.
MsgSubType.CONNECTION_FRIEND, insertCloseFriendIgnoreDuplicate(c, from, canonicalTo, targetBch);
canonicalTo,
targetBch,
0,
new byte[32]
);
Net_AddCloseFriend_Response resp = new Net_AddCloseFriend_Response(); Net_AddCloseFriend_Response resp = new Net_AddCloseFriend_Response();
resp.setOp(req.getOp()); 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();
}
}
} }

View File

@ -23,9 +23,17 @@ public final class AuthKeyUtils {
public static byte[] parseEd25519PublicKey(String key, String fieldName) { public static byte[] parseEd25519PublicKey(String key, String fieldName) {
String normalized = normalize(key, 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('/'); int slash = normalized.indexOf('/');
if (slash < 0) { 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(); String algorithm = normalized.substring(0, slash).trim();