merge: add-web-push into main

# Conflicts:
#	shine-UI/js/router.js
#	shine-UI/js/services/auth-service.js
#	shine-UI/js/state.js
This commit is contained in:
AidarKC 2026-04-14 22:35:28 +03:00
commit 24be1d0c1f
35 changed files with 2051 additions and 336 deletions

1
.gitignore vendored
View File

@ -4,6 +4,7 @@ logs/
logs
.gradle
.gradle-home/
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/

View File

@ -0,0 +1,153 @@
# Задача 02: Web Push + подписанный API отправки личных сообщений
## Контекст (по текущему состоянию проекта)
- Уже есть JSON WebSocket API для личных сообщений: `SendDirectMessage`, `AckIncomingMessage`, `UpsertPushToken`.
- Сейчас серверный fallback-пуш реализован через FCM (`FcmPushSender`) и ключ `fcm.server.key`.
- Клиент уже регистрирует service worker и токен Firebase, затем аплоадит push token на сервер.
## Цель
Добавить полностью рабочий сценарий доставки личных сообщений с приоритетом:
1) онлайн-доставка в активную WebSocket-сессию;
2) если не подтверждено — Web Push;
3) поддержать отдельный API отправки без авторизации, где доступ проверяется цифровой подписью Ed25519 по `deviceKey` отправителя.
---
## Предварительная спецификация подписанного пакета (v1)
> ВАЖНО: финально фиксируется после уточнений по endian/кодировкам/лимитам.
Пакет (binary):
1. `prefix` — ASCII-константа, например `SHINE_MESSAGE`.
2. `toLoginLen` — 1 байт.
3. `toLogin` — ASCII, длина = `toLoginLen`.
4. `fromLoginLen` — 1 байт.
5. `fromLogin` — ASCII, длина = `fromLoginLen`.
6. `timeMs` — 8 байт (unix ms).
7. `nonce32` — 4 байта случайное число.
8. `messageType` — 4 байта.
9. `targetMode` — 1 байт:
- `0` = всем сессиям пользователя,
- `1` = конкретной сессии.
10. Если `targetMode=1`:
- `sessionIdLen` — 1 байт,
- `sessionId` — ASCII.
11. `messageLen` — 2 байта.
12. `messageBytes` — бинарные данные длиной `messageLen`.
13. `signature64` — 64 байта, Ed25519 подпись всего блока **без** `signature64`.
Ограничения (первичный draft):
- общий размер пакета ≤ 4000 байт;
- логины/префикс/идентификатор сессии — ASCII;
- повторы отсекаются по `(fromLogin, timeMs, nonce32)` в окне TTL.
---
## Сервер: что доработать
### 1) Новый endpoint без авторизации
Операция (через WS JSON обертку) условно `SendSignedDirectMessage`:
- принимает пакет (base64 binary blob);
- парсит и валидирует формат;
- достает `fromLogin`, поднимает `deviceKey` пользователя;
- проверяет подпись Ed25519;
- проверяет анти-replay (time window + nonce);
- отправляет сообщение по правилам маршрутизации;
- пишет результат (messageId, каналы доставки, причины недоставки).
### 2) Маршрутизация доставки
Для `targetMode=1`:
- если целевая сессия онлайн и ACK пришел вовремя — успех;
- иначе отправка в Web Push этой сессии (если есть subscription).
Для `targetMode=0`:
- обход всех сессий пользователя;
- сначала online delivery + ACK;
- для непринятых/офлайн — Web Push по соответствующим subscription;
- если subscription отсутствует — тихий skip.
### 3) Миграция от FCM к Web Push
- добавить конфиг VAPID (`webpush.public.key`, `webpush.private.key`, `webpush.subject`);
- хранить на сервере не только token, а web-push subscription (endpoint + keys);
- сделать отправщик Web Push и заменить/расширить текущий `FcmPushSender`.
### 4) Безопасность
- строгая ASCII-валидация логинов/sessionId;
- лимиты длины всех полей;
- rate limit на endpoint;
- audit-лог неуспешных проверок подписи/формата;
- защита от replay.
---
## Клиент (shine-UI): что доработать
1. Перейти на стандартный Web Push flow:
- регистрация service worker;
- `PushManager.subscribe(...)` с VAPID public key;
- отправка subscription на сервер (`UpsertPushSubscription` или расширение `UpsertPushToken`).
2. Service worker:
- `push` handler получает payload целиком;
- показывает системное уведомление;
- при клике открывает/фокусирует нужный чат.
3. Online-сообщения:
- сохранить текущий event-канал `IncomingDirectMessage`;
- обязателен ACK (`AckIncomingMessage` уже есть).
4. Keep-alive:
- UI отправляет `Ping` раз в 60 секунд при активной сессии.
---
## Документация
Сделать отдельный документ настройки Web Push:
- как сгенерировать VAPID ключи;
- какие параметры прописать на сервере и в UI;
- как проверить локально e2e (онлайн + офлайн пуш);
- ограничения payload и рекомендации по ретраям.
---
## Этапы реализации (предложение)
1. Зафиксировать бинарный формат + валидации.
2. Реализовать серверный parser/validator/signature verify/replay guard.
3. Реализовать Web Push sender + storage subscription.
4. Подключить новый endpoint и маршрутизацию доставки.
5. Обновить UI (subscription + service worker + ping timer).
6. Добавить интеграционные тесты (online ACK / offline push / bad signature / replay / oversize).
7. Добавить документацию.
---
## Что нужно уточнить до разработки
1. Endian для `timeMs/nonce/messageType/messageLen` (big-endian или little-endian).
2. Что именно подписывается: строго весь префикс..messageBytes (без подписи) — подтвердить.
3. Диапазон допустимых `messageType`.
4. TTL окна для анти-replay (например 5 минут / 15 минут).
5. Лимиты длин для login/session/message.
6. Можно ли временно оставить FCM как fallback, пока не готов Web Push в проде.
7. Формат сообщения в `messageBytes`: opaque bytes или UTF-8 строка.
## Статус реализации (12.04.2026)
### Что уже внедрено в коде
- `SendDirectMessage` переведён на signed-binary payload (`blobB64`) без обязательной авторизации WS-сессии.
- Внедрён бинарный парсер пакета формата `SHiNE_msg + version(1) + ... + signature64`.
- Проверка подписи Ed25519 делается по `deviceKey` отправителя через `shine-server-crypto` (`Ed25519Util`).
- Добавлен anti-replay guard `(from_login, time_ms, nonce)` с TTL 15 минут.
- Добавлено историческое хранилище `signed_direct_messages_history` с сырым пакетом `raw_packet`.
- Логика доставки: сначала WS+ACK, затем fallback на Web Push (по подписке конкретной session).
- Поле типа сообщения переведено на `uint16`, пока поддерживается только `1`.
- Для `targetMode=1` при несуществующей сессии возвращается `success` с `sessionNotFound=true` и `delivered=0`.
- UI переведён с Firebase/FCM на браузерный `PushManager.subscribe` + Service Worker `push`.
- Добавлен keep-alive ping из UI раз в 60 секунд при авторизованной сессии.
### Что настроить в окружении
- В `application.properties` задать:
- `webpush.vapid.public`
- `webpush.vapid.private`
- `webpush.vapid.subject`
- В `shine-UI/index.html` задать публичный VAPID ключ в `window.__SHINE_WEBPUSH_VAPID_PUBLIC_KEY__`.

View File

@ -1,30 +1,39 @@
/* global importScripts, firebase */
importScripts('https://www.gstatic.com/firebasejs/10.12.2/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/10.12.2/firebase-messaging-compat.js');
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
// Заполните теми же значениями, что и в shine-UI/index.html
const FIREBASE_CONFIG = {
apiKey: '',
authDomain: '',
projectId: '',
messagingSenderId: '',
appId: '',
};
if (FIREBASE_CONFIG.apiKey && firebase && firebase.messaging) {
if (!firebase.apps.length) {
firebase.initializeApp(FIREBASE_CONFIG);
}
const messaging = firebase.messaging();
messaging.onBackgroundMessage((payload) => {
const title = payload?.notification?.title || 'Новое сообщение';
const options = {
body: payload?.notification?.body || '',
data: payload?.data || {},
};
self.registration.showNotification(title, options);
async function broadcastToClients(payload) {
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
clients.forEach((client) => {
client.postMessage({
type: 'SHINE_WEB_PUSH_EVENT',
payload,
});
});
}
self.addEventListener('push', (event) => {
let body = 'Новое сообщение SHiNE';
let rawText = '';
try {
if (event.data) {
const text = event.data.text();
rawText = text || '';
body = rawText || body;
}
} catch {
// ignore
}
event.waitUntil(Promise.all([
self.registration.showNotification('SHiNE: входящее сообщение', {
body,
tag: 'shine-direct-message',
renotify: true,
}),
broadcastToClients({
body,
rawText,
receivedAt: Date.now(),
}),
]));
});

View File

@ -27,17 +27,9 @@
<div id="toolbar-slot" class="toolbar-slot"></div>
</div>
<div id="modal-root"></div>
<script src="https://www.gstatic.com/firebasejs/10.12.2/firebase-app-compat.js"></script>
<script src="https://www.gstatic.com/firebasejs/10.12.2/firebase-messaging-compat.js"></script>
<script>
window.__SHINE_FIREBASE_CONFIG__ = {
apiKey: '',
authDomain: '',
projectId: '',
messagingSenderId: '',
appId: ''
};
window.__SHINE_FIREBASE_VAPID_KEY__ = '';
// Public VAPID key for Web Push (Base64URL)
window.__SHINE_WEBPUSH_VAPID_PUBLIC_KEY__ = 'BOdoWZndZRaNe9kyUFsJ5-xEfFABXNKennAKg15Z7ycAwUIQ7yDV_sIWWYJCwJriN4g9oU-CyJPrn1U6lfxuDbI';
</script>
<script>
(function attachAppWithBuildHash() {

View File

@ -4,6 +4,7 @@ import { captureClientError, setClientErrorTransport } from './services/client-e
import { initPwaPush } from './services/pwa-push-service.js';
import {
authService,
addAppLogEntry,
authorizeSession,
isSessionInvalidError,
refreshSessions,
@ -36,9 +37,11 @@ import * as deviceCameraView from './pages/device-camera-view.js';
import * as showKeysView from './pages/show-keys-view.js';
import * as deviceSessionView from './pages/device-session-view.js';
import * as languageView from './pages/language-view.js';
import * as appLogView from './pages/app-log-view.js';
import * as messagesList from './pages/messages-list.js';
import * as contactSearchView from './pages/contact-search-view.js';
import * as chatView from './pages/chat-view.js';
import * as userProfileView from './pages/user-profile-view.js';
import * as channelsList from './pages/channels-list.js';
import * as channelView from './pages/channel-view.js';
import * as channelThreadView from './pages/channel-thread-view.js';
@ -68,9 +71,11 @@ const routes = {
'show-keys-view': showKeysView,
'device-session-view': deviceSessionView,
'language-view': languageView,
'app-log-view': appLogView,
'messages-list': messagesList,
'contact-search-view': contactSearchView,
'chat-view': chatView,
'user-profile-view': userProfileView,
'channels-list': channelsList,
'channel-view': channelView,
'channel-thread-view': channelThreadView,
@ -100,6 +105,18 @@ function showGlobalErrorAlert(title, details = {}) {
window.addEventListener('error', (event) => {
const pageId = getRoute().pageId || '';
addAppLogEntry({
level: 'error',
source: 'global_error',
message: event.message || 'Global JS error',
details: {
pageId,
sourceUrl: event.filename || '',
line: event.lineno,
column: event.colno,
stack: event.error?.stack || '',
},
});
captureClientError({
kind: 'global_error',
message: event.message || 'Global JS error',
@ -125,6 +142,16 @@ window.addEventListener('error', (event) => {
window.addEventListener('unhandledrejection', (event) => {
const reason = event.reason;
const pageId = getRoute().pageId || '';
addAppLogEntry({
level: 'error',
source: 'unhandled_rejection',
message: reason?.message || String(reason || 'Unhandled promise rejection'),
details: {
pageId,
reasonType: reason?.constructor?.name || typeof reason,
stack: reason?.stack || '',
},
});
captureClientError({
kind: 'unhandled_rejection',
message: reason?.message || String(reason || 'Unhandled promise rejection'),
@ -247,10 +274,30 @@ async function tryAutoLogin() {
}
async function init() {
addAppLogEntry({
level: 'info',
source: 'app',
message: 'Инициализация UI запущена',
});
setSessionResetHandler(() => {
navigate('start-view');
});
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
const data = event?.data || {};
if (data.type !== 'SHINE_WEB_PUSH_EVENT') return;
const payload = data.payload || {};
addAppLogEntry({
level: 'info',
source: 'web-push',
message: 'Получено push-событие в service worker',
details: payload,
});
});
}
authService.onEvent('SessionRevoked', async () => {
await terminateCurrentSession({ infoMessage: 'Сессия закрыта с другого устройства.' });
});
@ -260,19 +307,59 @@ async function init() {
const fromLogin = payload.fromLogin || 'unknown';
const messageId = payload.messageId || '';
const eventId = payload.eventId || evt?.requestId || '';
const added = addIncomingMessage(fromLogin, payload.text || '', messageId);
let text = payload.text || '';
if (!text && payload.blobB64) {
try {
const bytes = Uint8Array.from(atob(payload.blobB64), (ch) => ch.charCodeAt(0));
const msgLen = (bytes[bytes.length - 66] << 8) | bytes[bytes.length - 65];
const msgStart = bytes.length - 64 - msgLen;
const msgBytes = bytes.slice(msgStart, msgStart + msgLen);
text = new TextDecoder().decode(msgBytes);
} catch {
text = '[binary message]';
}
}
const added = addIncomingMessage(fromLogin, text, messageId);
if (added) {
addAppLogEntry({
level: 'info',
source: 'incoming-dm',
message: `Входящее сообщение от ${fromLogin}`,
details: { messageId, text },
});
}
if (added && Notification.permission === 'granted') {
try {
new Notification(`Сообщение от ${fromLogin}`, { body: payload.text || '' });
new Notification(`Сообщение от ${fromLogin}`, { body: text || '' });
} catch {}
}
if (eventId) {
try { await authService.ackIncomingMessage(eventId, messageId); } catch {}
try {
await authService.ackIncomingMessage(eventId, messageId);
} catch (error) {
addAppLogEntry({
level: 'warn',
source: 'incoming-dm',
message: 'Не удалось отправить ACK на входящее сообщение',
details: { eventId, messageId, error: error?.message || 'unknown' },
});
}
}
});
await tryAutoLogin();
if (state.session.isAuthorized) {
await initPwaPush({ authService });
await initPwaPush({
authService,
onLog: (entry) => addAppLogEntry(entry),
});
window.setInterval(async () => {
if (!state.session.isAuthorized) return;
try {
await authService.ws.request('Ping', { timeMs: Date.now() });
} catch {
// silent keep-alive
}
}, 60_000);
}
if (!window.location.hash) {

View File

@ -0,0 +1,96 @@
import { renderHeader } from '../components/header.js';
import { clearAppLogEntries, getAppLogEntries } from '../state.js';
export const pageMeta = { id: 'app-log-view', title: 'Лог приложения' };
function formatTime(ts) {
try {
return new Date(ts).toLocaleTimeString('ru-RU');
} catch {
return String(ts || '');
}
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
title: 'Лог приложения',
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
);
const controls = document.createElement('div');
controls.className = 'card row';
controls.style.justifyContent = 'space-between';
controls.style.gap = '8px';
controls.innerHTML = `
<button class="ghost-btn" type="button" data-action="refresh">Обновить</button>
<button class="ghost-btn" type="button" data-action="clear">Очистить</button>
`;
const status = document.createElement('div');
status.className = 'status-line';
status.textContent = 'Загрузка лога...';
const list = document.createElement('div');
list.className = 'stack';
function renderEntries() {
const entries = getAppLogEntries();
list.innerHTML = '';
if (!entries.length) {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Лог пока пуст.';
list.append(empty);
status.className = 'status-line is-available';
status.textContent = 'Записей: 0';
return;
}
entries.slice().reverse().forEach((entry) => {
const row = document.createElement('div');
row.className = 'card stack';
const level = String(entry.level || 'info').toUpperCase();
const source = String(entry.source || 'ui');
const details = String(entry.details || '').trim();
const head = document.createElement('div');
head.className = 'meta-muted';
head.textContent = `[${formatTime(entry.ts)}] ${level} ${source}`;
const message = document.createElement('div');
message.textContent = entry.message || '';
row.append(head, message);
if (details) {
const detailsNode = document.createElement('pre');
detailsNode.className = 'meta-muted';
detailsNode.style.whiteSpace = 'pre-wrap';
detailsNode.style.margin = '0';
detailsNode.textContent = details;
row.append(detailsNode);
}
list.append(row);
});
status.className = 'status-line is-available';
status.textContent = `Записей: ${entries.length}`;
}
controls.querySelector('[data-action="refresh"]').addEventListener('click', renderEntries);
controls.querySelector('[data-action="clear"]').addEventListener('click', () => {
clearAppLogEntries();
renderEntries();
});
screen.append(controls, status, list);
renderEntries();
return screen;
}

View File

@ -31,26 +31,17 @@ export function render({ navigate, route }) {
renderHeader({
title: `Чат: ${contact.name}`,
leftAction: { label: '←', onClick: () => navigate('messages-list') },
rightActions: [{
label: 'Позвонить',
onClick: () => {
const confirmed = window.confirm('Позвонить этому пользователю?');
if (!confirmed) return;
window.alert('Функция пока не реализована');
},
}],
})
);
const isContact = state.contacts.includes(chatId);
if (!isContact) {
const warning = document.createElement('div');
warning.className = 'card stack';
warning.innerHTML = '<p class="meta-muted">Пользователь не в контактах. Можно писать ему сразу (MVP).</p>';
const btn = document.createElement('button');
btn.className = 'primary-btn';
btn.type = 'button';
btn.textContent = 'Добавить в контакты';
btn.addEventListener('click', () => {
state.contacts = [...state.contacts, chatId];
warning.remove();
});
warning.append(btn);
screen.append(warning);
}
const wrap = document.createElement('div');
wrap.className = 'chat-wrap';
@ -75,7 +66,11 @@ export function render({ navigate, route }) {
renderLog(log, chatId);
try {
await authService.sendDirectMessage(chatId, text);
await authService.sendDirectMessage({
toLogin: chatId,
text,
storagePwd: state.session.storagePwdInMemory,
});
} catch (e) {
addChatMessage(chatId, `Ошибка отправки: ${e.message || 'unknown'}`);
renderLog(log, chatId);

View File

@ -1,6 +1,5 @@
import { renderHeader } from '../components/header.js';
import { directMessages } from '../mock-data.js';
import { authService, ensureChat, setContacts, state } from '../state.js';
import { authService } from '../state.js';
export const pageMeta = { id: 'contact-search-view', title: 'Поиск контактов' };
@ -26,10 +25,7 @@ export function render({ navigate }) {
const resultsList = document.createElement('div');
resultsList.className = 'stack';
let latestMatches = [];
const renderResults = (matches, query) => {
latestMatches = matches;
resultsList.innerHTML = '';
resultsCard.hidden = false;
@ -56,6 +52,9 @@ export function render({ navigate }) {
</div>
<div class="meta-muted">Профиль</div>
`;
row.addEventListener('click', () => {
navigate(`user-profile-view/${encodeURIComponent(login)}/contact-search-view`);
});
resultsList.append(row);
});
};
@ -65,51 +64,24 @@ export function render({ navigate }) {
searchButton.type = 'button';
searchButton.textContent = 'Поиск';
searchButton.addEventListener('click', async () => {
const query = input.value.trim();
if (!query) {
renderResults([], '');
return;
}
try {
const logins = await authService.searchUsers(input.value.trim());
renderResults(logins, input.value);
const logins = await authService.searchUsers(query);
renderResults((logins || []).slice(0, 5), query);
} catch (e) {
status.textContent = `Ошибка поиска: ${e.message || 'unknown'}`;
resultsCard.hidden = false;
}
});
const addButton = document.createElement('button');
addButton.className = 'ghost-btn';
addButton.type = 'button';
addButton.textContent = 'Открыть чат';
addButton.addEventListener('click', () => {
if (!latestMatches.length) {
status.textContent = 'Сначала выполните поиск.';
resultsCard.hidden = false;
return;
}
const login = latestMatches[0];
const exists = directMessages.some((item) => item.id === login);
if (!exists) {
directMessages.unshift({
id: login,
name: login,
initials: (login[0] || '?').toUpperCase(),
lastMessage: 'Диалог создан. Пользователь пока не в контактах.',
time: 'сейчас',
unread: 0,
});
}
if (!state.contacts.includes(login)) {
setContacts([...state.contacts, login]);
}
ensureChat(login);
navigate(`chat-view/${login}`);
});
const controls = document.createElement('div');
controls.className = 'contact-search-actions';
controls.append(searchButton, addButton);
controls.append(searchButton);
const formCard = document.createElement('section');
formCard.className = 'card stack';

View File

@ -1,5 +1,7 @@
import { renderHeader } from '../components/header.js';
import { directMessages } from '../mock-data.js';
import { getChatMessages } from '../state.js';
import { loadCurrentRelations } from '../services/user-connections.js';
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
@ -16,8 +18,11 @@ export function render({ navigate }) {
const list = document.createElement('div');
list.className = 'stack';
const status = document.createElement('div');
status.className = 'status-line';
status.textContent = 'Загрузка списка сообщений...';
directMessages.forEach((item) => {
function renderRow(item) {
const row = document.createElement('article');
row.className = 'list-item';
row.innerHTML = `
@ -33,10 +38,55 @@ export function render({ navigate }) {
${item.unread ? `<span class="unread">${item.unread}</span>` : '<span></span>'}
</div>
`;
row.addEventListener('click', () => navigate(`chat-view/${item.id}`));
list.append(row);
row.addEventListener('click', () => navigate(`chat-view/${encodeURIComponent(item.id)}`));
return row;
}
async function loadList() {
try {
const relations = await loadCurrentRelations();
const contacts = relations.outContacts || [];
list.innerHTML = '';
if (!contacts.length) {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Ваш список контактов пока пуст';
list.append(empty);
status.className = 'status-line is-available';
status.textContent = 'Нет контактов.';
return;
}
const rows = contacts.map((login) => {
const preview = directMessages.find((item) => item.id.toLowerCase() === login.toLowerCase());
const chat = getChatMessages(login);
const lastChat = chat[chat.length - 1];
return {
id: login,
initials: (login[0] || '?').toUpperCase(),
name: preview?.name || login,
lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.',
time: preview?.time || '—',
unread: Number(preview?.unread || 0),
};
});
screen.append(list);
rows.forEach((item) => list.append(renderRow(item)));
status.className = 'status-line is-available';
status.textContent = `Загружено диалогов: ${rows.length}`;
} catch (error) {
list.innerHTML = '';
const fail = document.createElement('div');
fail.className = 'card meta-muted';
fail.textContent = `Не удалось загрузить сообщения: ${error.message || 'unknown'}`;
list.append(fail);
status.className = 'status-line is-unavailable';
status.textContent = 'Список недоступен.';
}
}
screen.append(status, list);
loadList();
return screen;
}

View File

@ -6,70 +6,16 @@ export const pageMeta = { id: 'network-view', title: 'Связи' };
function makeNode(name, cls = '') {
const n = document.createElement('div');
n.className = `node ${cls}`.trim();
n.dataset.nodeLogin = name;
n.innerHTML = `<div class="node-dot">${(name[0] || '?').toUpperCase()}</div><div class="node-label">${name}</div>`;
return n;
}
function showAddCloseFriendModal({ onAdded }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="close-friend-modal">
<div class="modal-card stack">
<h3 style="font-size:18px;">Добавить близкого друга</h3>
<input class="input" id="close-friend-query" placeholder="Логин или начало логина" maxlength="80" />
<div class="row" style="gap:8px;">
<button class="primary-btn" id="close-friend-search">Поиск</button>
<button class="ghost-btn" id="close-friend-back">Назад</button>
</div>
<div class="stack" id="close-friend-results"></div>
</div>
</div>
`;
const close = () => { root.innerHTML = ''; };
root.querySelector('#close-friend-back').addEventListener('click', close);
root.querySelector('#close-friend-search').addEventListener('click', async () => {
const query = root.querySelector('#close-friend-query').value.trim();
const holder = root.querySelector('#close-friend-results');
holder.innerHTML = '<p class="meta-muted">Поиск...</p>';
try {
const logins = await authService.searchUsers(query);
holder.innerHTML = '';
if (!logins.length) {
holder.innerHTML = '<p class="meta-muted">Пользователи не найдены.</p>';
return;
function unique(list) {
return [...new Set((Array.isArray(list) ? list : []).filter(Boolean))];
}
logins.forEach((login) => {
const row = document.createElement('article');
row.className = 'list-item';
row.innerHTML = `
<div class="avatar">${(login[0] || '?').toUpperCase()}</div>
<div><strong>${login}</strong><p class="meta-muted" style="margin-top:4px;">Пользователь</p></div>
<div class="meta-muted">Добавить</div>
`;
row.addEventListener('click', async () => {
const yes = window.confirm(`Добавить ${login} в близкие друзья?`);
if (!yes) return;
try {
await authService.addCloseFriend(login);
close();
if (typeof onAdded === 'function') await onAdded();
} catch (e) {
window.alert(`Ошибка добавления: ${e.message || 'unknown'}`);
}
});
holder.append(row);
});
} catch (e) {
holder.innerHTML = `<p class="meta-muted">Ошибка поиска: ${e.message || 'unknown'}</p>`;
}
});
}
export function render() {
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
@ -81,16 +27,99 @@ export function render() {
note.className = 'meta-muted';
note.textContent = 'Загрузка связей...';
const load = async (centerLogin) => {
let activeMenu = null;
let centerLogin = state.session.login || '';
function closeNodeMenu() {
if (!activeMenu) return;
activeMenu.remove();
activeMenu = null;
}
function openNodeMenu(node, login) {
closeNodeMenu();
const menu = document.createElement('div');
menu.className = 'node-menu card';
menu.innerHTML = '<button class="ghost-btn" type="button">Показать информацию о пользователе</button>';
const rect = node.getBoundingClientRect();
const boardRect = board.getBoundingClientRect();
const x = rect.left + rect.width / 2 - boardRect.left;
const y = rect.bottom - boardRect.top + 8;
menu.style.left = `${Math.max(8, Math.min(x - 120, boardRect.width - 248))}px`;
menu.style.top = `${Math.max(8, Math.min(y, boardRect.height - 58))}px`;
const btn = menu.querySelector('button');
btn.addEventListener('click', () => {
navigate(`user-profile-view/${encodeURIComponent(login)}/network-view`);
closeNodeMenu();
});
board.append(menu);
activeMenu = menu;
}
function bindNodeInteraction(node, login, onLongPress) {
let timerId = 0;
let startX = 0;
let startY = 0;
let longPressTriggered = false;
const clearTimer = () => {
if (timerId) {
window.clearTimeout(timerId);
timerId = 0;
}
};
node.addEventListener('pointerdown', (event) => {
if (event.button !== 0) return;
startX = event.clientX;
startY = event.clientY;
longPressTriggered = false;
clearTimer();
timerId = window.setTimeout(async () => {
longPressTriggered = true;
closeNodeMenu();
await onLongPress(login);
}, 500);
});
node.addEventListener('pointermove', (event) => {
if (!timerId) return;
const dx = Math.abs(event.clientX - startX);
const dy = Math.abs(event.clientY - startY);
if (dx > 8 || dy > 8) clearTimer();
});
node.addEventListener('pointerleave', clearTimer);
node.addEventListener('pointercancel', clearTimer);
node.addEventListener('pointerup', (event) => {
if (event.button !== 0) return;
clearTimer();
if (longPressTriggered) return;
openNodeMenu(node, login);
});
}
async function load(nextCenterLogin = '') {
const targetCenter = nextCenterLogin || centerLogin || state.session.login;
centerLogin = targetCenter;
closeNodeMenu();
note.textContent = 'Загрузка связей...';
try {
const graph = await authService.getUserConnectionsGraph(centerLogin || state.session.login);
const graph = await authService.getUserConnectionsGraph(targetCenter);
board.innerHTML = '';
const center = makeNode(graph.login || state.session.login, 'center');
const center = makeNode(graph.login || targetCenter, 'center');
center.style.left = '50%';
center.style.top = '50%';
board.append(center);
const all = [...new Set([...(graph.outFriends || []), ...(graph.inFriends || [])])];
const all = unique([...(graph.outFriends || []), ...(graph.inFriends || [])]);
const left = all.slice(0, Math.ceil(all.length / 2));
const right = all.slice(Math.ceil(all.length / 2));
@ -99,7 +128,7 @@ export function render() {
const y = 15 + ((idx + 1) * 70) / (Math.max(total, 1) + 1);
node.style.left = side === 'left' ? '20%' : '80%';
node.style.top = `${y}%`;
node.addEventListener('click', () => load(name));
bindNodeInteraction(node, name, load);
board.append(node);
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
@ -121,20 +150,33 @@ export function render() {
right.forEach((name, i) => svg.append(mk(name, 'right', i, right.length)));
board.prepend(svg);
note.textContent = 'Нажмите на узел, чтобы перестроить связи вокруг выбранного пользователя.';
note.textContent = 'Тап по узлу: информация о пользователе. Долгое нажатие: центрировать граф.';
} catch (e) {
note.textContent = `Ошибка загрузки связей: ${e.message || 'unknown'}`;
}
}
const outsideTapHandler = (event) => {
if (!activeMenu) return;
if (!(event.target instanceof Node)) return;
if (activeMenu.contains(event.target)) return;
closeNodeMenu();
};
document.addEventListener('pointerdown', outsideTapHandler, true);
screen.cleanup = () => {
document.removeEventListener('pointerdown', outsideTapHandler, true);
};
const addBtn = document.createElement('button');
addBtn.className = 'primary-btn';
addBtn.type = 'button';
addBtn.textContent = 'Добавить близкого друга';
addBtn.addEventListener('click', () => showAddCloseFriendModal({ onAdded: () => load() }));
board.addEventListener('pointerdown', (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (target.closest('.node')) return;
if (target.closest('.node-menu')) return;
closeNodeMenu();
});
load();
screen.append(renderHeader({ title: 'Связи' }), addBtn, board, note);
screen.append(renderHeader({ title: 'Связи' }), board, note);
return screen;
}

View File

@ -6,6 +6,7 @@ import {
saveProfileParamBlock,
saveProfileToggle,
} from '../services/user-profile-params.js';
import { buildIdentityLines } from '../services/user-connections.js';
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
@ -19,9 +20,17 @@ function showLocalErrorAlert(prefix, error) {
window.alert(`${prefix}: ${message}${stack}`);
}
function escapeHtml(text) {
return String(text || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
export function render({ navigate }) {
const login = state.session.login || profile.login;
const displayLogin = String(login || '').toUpperCase();
const screen = document.createElement('section');
screen.className = 'stack';
@ -44,8 +53,8 @@ export function render({ navigate }) {
topRow.innerHTML = `
<div class="row" style="gap:12px; align-items:center;">
<div class="avatar large">${profile.avatarInitials}</div>
<div>
<h2 style="font-size:22px; margin-bottom:2px;" data-profile-login="true">${displayLogin}</h2>
<div class="profile-identity-lines" data-profile-identity="true">
<div class="profile-identity-line profile-identity-login">${String(login || '').trim() || 'unknown'}</div>
</div>
</div>
<button class="primary-btn" type="button" data-reload="true">Обновить</button>
@ -71,6 +80,17 @@ export function render({ navigate }) {
let currentFields = [];
let currentToggles = [];
const identityEl = topRow.querySelector('[data-profile-identity="true"]');
function syncIdentity() {
if (!identityEl) return;
const firstName = currentFields.find((field) => field.key === 'first_name')?.value || '';
const lastName = currentFields.find((field) => field.key === 'last_name')?.value || '';
const lines = buildIdentityLines({ login, firstName, lastName });
identityEl.innerHTML = lines.map((line, idx) => (
`<div class="profile-identity-line${idx === lines.length - 1 ? ' profile-identity-login' : ''}">${escapeHtml(line)}</div>`
)).join('');
}
function updateToggleButton(button, prefix, enabled) {
button.textContent = `${prefix}: ${toggleText(enabled)}`;
@ -123,6 +143,7 @@ export function render({ navigate }) {
currentFields = snapshot.fields;
currentToggles = snapshot.toggles;
syncIdentity();
renderFields(currentFields);
updateTogglesUi();

View File

@ -19,11 +19,13 @@ export function render({ navigate }) {
<button class="text-btn" type="button" id="settings-device">Устройства</button>
<button class="text-btn" type="button" id="settings-servers">Настройки серверов</button>
<button class="text-btn" type="button" id="settings-language">Язык / Language</button>
<button class="text-btn" type="button" id="settings-app-log">Лог приложения</button>
`;
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
card.querySelector('#settings-servers').addEventListener('click', () => navigate('server-settings-view'));
card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
card.querySelector('#settings-app-log').addEventListener('click', () => navigate('app-log-view'));
screen.append(card);
return screen;

View File

@ -0,0 +1,243 @@
import { renderHeader } from '../components/header.js';
import { authService, state } from '../state.js';
import {
buildAvatarInitials,
buildIdentityLines,
loadRelationsForPair,
loadUserProfileCard,
} from '../services/user-connections.js';
export const pageMeta = { id: 'user-profile-view', title: 'Чужой профиль' };
function escapeHtml(text) {
return String(text || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function boolText(flag) {
return flag ? 'Да' : 'Нет';
}
function relationButtonLabel(kind, flags) {
if (kind === 'follow') return flags.outFollow ? 'Отписаться' : 'Подписаться';
if (kind === 'friend') return flags.outFriend ? 'Убрать из друзей' : 'Добавить в друзья';
return flags.outContact ? 'Убрать из контактов' : 'Добавить в контакты';
}
function relationNextState(kind, flags) {
if (kind === 'follow') return !flags.outFollow;
if (kind === 'friend') return !flags.outFriend;
return !flags.outContact;
}
function relationConfirmLabel(kind) {
if (kind === 'follow') return 'подписку';
if (kind === 'friend') return 'дружбу';
return 'контакт';
}
function renderIdentity(card) {
const lines = buildIdentityLines({
login: card.login,
firstName: card.firstName,
lastName: card.lastName,
});
return `
<div class="row" style="gap:12px; align-items:center;">
<div class="avatar large">${escapeHtml(buildAvatarInitials(card))}</div>
<div class="profile-identity-lines">
${lines.map((line, idx) => (
`<div class="profile-identity-line${idx === lines.length - 1 ? ' profile-identity-login' : ''}">${escapeHtml(line)}</div>`
)).join('')}
</div>
</div>
`;
}
function renderReadOnlyBadges(card) {
return `
<div class="row wrap-row">
<span class="badge ${card.official ? 'is-yes-official' : 'is-no'}">Официальный: ${card.official ? 'Yes' : 'No'}</span>
<span class="badge ${card.shine ? 'is-yes-shine' : 'is-no'}">Сияющий: ${card.shine ? 'Yes' : 'No'}</span>
</div>
`;
}
function renderRelations(flags) {
return `
<div class="card stack user-relations-list">
<div class="user-rel-row"><span>Вы подписаны:</span><strong>${boolText(flags.outFollow)}</strong></div>
<div class="user-rel-row"><span>Подписан на вас:</span><strong>${boolText(flags.inFollow)}</strong></div>
<div class="user-rel-row"><span>Вы добавили в друзья:</span><strong>${boolText(flags.outFriend)}</strong></div>
<div class="user-rel-row"><span>Добавил вас в друзья:</span><strong>${boolText(flags.inFriend)}</strong></div>
<div class="user-rel-row"><span>Вы добавили в контакты:</span><strong>${boolText(flags.outContact)}</strong></div>
<div class="user-rel-row"><span>Добавил вас в контакты:</span><strong>${boolText(flags.inContact)}</strong></div>
</div>
`;
}
function renderReadOnlyParams(card) {
const rows = [
{ label: 'Имя', value: card.firstName },
{ label: 'Фамилия', value: card.lastName },
{ label: 'Адрес', value: card.address },
{ label: 'Web', value: card.web },
{ label: 'Телефон', value: card.phone },
];
return `
<div class="card stack profile-param-list">
${rows.map((row) => `
<div class="card profile-param-item row">
<div class="profile-param-value"><b>${row.label}</b>: ${escapeHtml(String(row.value || '').trim() || 'не заполнено')}</div>
</div>
`).join('')}
</div>
`;
}
export function render({ navigate, route }) {
const requestedLogin = String(route.params.login || '').trim();
const fromPage = String(route.params.fromPage || 'messages-list').trim() || 'messages-list';
const sessionLogin = String(state.session.login || '').trim();
const screen = document.createElement('section');
screen.className = 'stack';
const status = document.createElement('div');
status.className = 'status-line';
status.textContent = 'Загрузка профиля...';
const body = document.createElement('div');
body.className = 'stack';
screen.append(
renderHeader({
title: 'Профиль пользователя',
leftAction: { label: '←', onClick: () => navigate(fromPage) },
rightActions: [{ label: 'Обновить', onClick: () => refresh() }],
}),
status,
body,
);
let currentCard = null;
let currentFlags = null;
let isBusy = false;
function syncActionButtons() {
const followBtn = body.querySelector('[data-relation-action="follow"]');
const friendBtn = body.querySelector('[data-relation-action="friend"]');
const contactBtn = body.querySelector('[data-relation-action="contact"]');
if (!followBtn || !friendBtn || !contactBtn || !currentFlags) return;
const isSelf = currentCard && currentCard.login.toLowerCase() === sessionLogin.toLowerCase();
followBtn.textContent = relationButtonLabel('follow', currentFlags);
friendBtn.textContent = relationButtonLabel('friend', currentFlags);
contactBtn.textContent = relationButtonLabel('contact', currentFlags);
followBtn.disabled = Boolean(isSelf);
friendBtn.disabled = Boolean(isSelf);
contactBtn.disabled = Boolean(isSelf);
}
async function refresh() {
if (!requestedLogin) {
status.className = 'status-line is-unavailable';
status.textContent = 'Не передан login пользователя.';
return;
}
isBusy = true;
status.className = 'status-line';
status.textContent = 'Загрузка профиля...';
try {
const card = await loadUserProfileCard(requestedLogin);
const flags = await loadRelationsForPair({
currentLogin: sessionLogin,
targetLogin: card.login,
});
currentCard = card;
currentFlags = flags;
body.innerHTML = `
<div class="card stack">
${renderIdentity(card)}
</div>
${renderReadOnlyBadges(card)}
${renderRelations(flags)}
${renderReadOnlyParams(card)}
<div class="stack">
<button class="primary-btn" type="button" data-relation-action="follow"></button>
<button class="ghost-btn" type="button" data-relation-action="friend"></button>
<button class="ghost-btn" type="button" data-relation-action="contact"></button>
</div>
`;
syncActionButtons();
status.className = 'status-line is-available';
status.textContent = 'Профиль обновлён.';
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = `Ошибка загрузки профиля: ${error.message || 'unknown'}`;
window.alert(`Не удалось загрузить профиль: ${error.message || 'unknown'}`);
} finally {
isBusy = false;
}
}
async function onRelationAction(kind) {
if (isBusy || !currentCard || !currentFlags) return;
if (!sessionLogin) {
window.alert('Для изменения связей нужен активный вход.');
return;
}
if (!state.session.storagePwdInMemory) {
window.alert('Нет storagePwd в памяти сессии. Выполните вход заново.');
return;
}
const nextEnabled = relationNextState(kind, currentFlags);
const confirmed = window.confirm(
`Изменить ${relationConfirmLabel(kind)} с пользователем ${currentCard.login}?\n` +
'Будет отправлен AddBlock CONNECTION.',
);
if (!confirmed) return;
isBusy = true;
status.className = 'status-line';
status.textContent = 'Сохранение отношения в блокчейн...';
try {
await authService.setUserRelation({
login: sessionLogin,
toLogin: currentCard.login,
kind,
enabled: nextEnabled,
storagePwd: state.session.storagePwdInMemory,
});
await refresh();
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = `Ошибка изменения связи: ${error.message || 'unknown'}`;
window.alert(`Не удалось изменить связь: ${error.message || 'unknown'}`);
isBusy = false;
}
}
body.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
const kind = target.dataset.relationAction;
if (!kind) return;
onRelationAction(kind);
});
refresh();
return screen;
}

View File

@ -32,7 +32,7 @@ export function getRoute() {
};
if (pageId === 'chat-view') {
return { pageId, params: { chatId: dynamicId || '' } };
return { pageId, params: { chatId: dynamicId ? decodeURIComponent(dynamicId) : '' } };
}
if (pageId === 'channel-view') {
@ -65,7 +65,17 @@ export function getRoute() {
}
if (pageId === 'device-session-view') {
return { pageId, params: { sessionId: dynamicId || '' } };
return { pageId, params: { sessionId: dynamicId ? decodeURIComponent(dynamicId) : '' } };
}
if (pageId === 'user-profile-view') {
return {
pageId,
params: {
login: dynamicId ? decodeURIComponent(dynamicId) : '',
fromPage: segments[2] ? decodeURIComponent(segments[2]) : 'messages-list',
},
};
}
return { pageId, params: {} };
@ -87,11 +97,13 @@ export function resolveToolbarActive(pageId) {
pageId === 'device-camera-view' ||
pageId === 'show-keys-view' ||
pageId === 'device-session-view' ||
pageId === 'language-view'
pageId === 'language-view' ||
pageId === 'app-log-view'
) {
return 'profile-view';
}
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
if (pageId === 'channel-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view') return 'channels-list';
if (pageId === 'user-profile-view') return 'messages-list';
return 'profile-view';
}

View File

@ -1,5 +1,6 @@
import { WsJsonClient } from './ws-client.js';
import {
base64ToBytes,
bytesToBase64,
deriveEd25519FromPassword,
exportEd25519PublicKeyB64,
@ -27,6 +28,7 @@ import {
const BCH_SUFFIX = '001';
const ZERO64 = '0'.repeat(64);
const ZERO_HASH_HEX = ZERO64;
const MSG_TYPE_TECH = 0;
const MSG_TYPE_TEXT = 1;
@ -42,6 +44,12 @@ const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
const MSG_SUBTYPE_CONNECTION_UNFOLLOW = 31;
const CREATE_CHANNEL_BODY_VERSION = 2;
const CONNECTION_SUBTYPES = Object.freeze({
friend: { on: 10, off: 11 },
contact: { on: 20, off: 21 },
follow: { on: 30, off: 31 },
});
function normalizeServerUrl(url) {
const value = (url || '').trim();
if (!value) return 'wss://shineup.me/ws';
@ -162,6 +170,31 @@ function int64Bytes(value) {
return bytes;
}
function uint16Bytes(value) {
const bytes = new Uint8Array(2);
const view = new DataView(bytes.buffer);
view.setUint16(0, Number(value), false);
return bytes;
}
function uint32Bytes(value) {
const bytes = new Uint8Array(4);
const view = new DataView(bytes.buffer);
view.setUint32(0, Number(value), false);
return bytes;
}
function uint64Bytes(value) {
const bytes = new Uint8Array(8);
const view = new DataView(bytes.buffer);
view.setBigUint64(0, BigInt(value), false);
return bytes;
}
function uint8Bytes(value) {
return new Uint8Array([Number(value) & 0xff]);
}
function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, key, value }) {
const keyBytes = utf8Bytes(String(key || ''));
const valueBytes = utf8Bytes(String(value || ''));
@ -1076,14 +1109,57 @@ export class AuthService {
return this.ws.onEvent(op, handler);
}
async upsertPushToken({ tokenId, token, provider = 'fcm', platform = 'web', userAgent = navigator.userAgent || '' }) {
const response = await this.ws.request('UpsertPushToken', { tokenId, token, provider, platform, userAgent });
async upsertPushToken({ endpoint, p256dhKey, authKey, sessionId, platform = 'web', userAgent = navigator.userAgent || '' }) {
const response = await this.ws.request('UpsertPushToken', { endpoint, p256dhKey, authKey, sessionId, platform, userAgent });
if (response.status !== 200) throw opError('UpsertPushToken', response);
return response.payload || {};
}
async sendDirectMessage(toLogin, text) {
const response = await this.ws.request('SendDirectMessage', { toLogin, text });
async sendDirectMessage({ toLogin, text, storagePwd, targetSessionId = null, messageType = 1 }) {
const cleanToLogin = String(toLogin || '').trim();
const cleanText = String(text || '');
if (!cleanToLogin || !cleanText) throw new Error('Не передан toLogin/text');
if (!storagePwd) throw new Error('Не передан storagePwd для подписи');
if (!this.ws.login) throw new Error('Нет активной авторизованной сессии');
const secrets = await loadEncryptedUserSecrets(this.ws.login, storagePwd);
const devicePriv = secrets?.deviceKey;
if (!devicePriv) throw new Error('Не найден приватный deviceKey');
const privateKey = await importPkcs8Ed25519(devicePriv);
const prefix = utf8Bytes('SHiNE_msg');
const version = uint8Bytes(1);
const toBytes = utf8Bytes(cleanToLogin);
const fromBytes = utf8Bytes(this.ws.login);
if (toBytes.length < 1 || toBytes.length > 30) throw new Error('toLogin должен быть 1..30 ASCII-символов');
if (fromBytes.length < 1 || fromBytes.length > 30) throw new Error('fromLogin должен быть 1..30 ASCII-символов');
if (cleanText.length > 3000) throw new Error('Слишком длинное сообщение');
const mode = targetSessionId ? 1 : 0;
const targetBytes = targetSessionId ? utf8Bytes(String(targetSessionId)) : new Uint8Array(0);
if (mode === 1 && (targetBytes.length < 1 || targetBytes.length > 255)) {
throw new Error('targetSessionId должен быть 1..255 символов');
}
const bodyBytes = utf8Bytes(cleanText);
const preimage = concatBytes(
prefix,
version,
uint8Bytes(toBytes.length), toBytes,
uint8Bytes(fromBytes.length), fromBytes,
uint64Bytes(Date.now()),
uint32Bytes(Math.floor(Math.random() * 0x100000000)),
uint16Bytes(messageType),
uint8Bytes(mode),
mode === 1 ? concatBytes(uint8Bytes(targetBytes.length), targetBytes) : new Uint8Array(0),
uint16Bytes(bodyBytes.length),
bodyBytes,
);
const signature = await signBytes(privateKey, preimage);
const packet = concatBytes(preimage, signature);
const blobB64 = bytesToBase64(packet);
const response = await this.ws.request('SendDirectMessage', { blobB64 });
if (response.status !== 200) throw opError('SendDirectMessage', response);
return response.payload || {};
}
@ -1134,6 +1210,14 @@ export class AuthService {
throw opError('GetUserParam', response);
}
async setUserRelation({ login, toLogin, kind, enabled, storagePwd }) {
const cleanKind = String(kind || '').trim().toLowerCase();
const kinds = CONNECTION_SUBTYPES[cleanKind];
if (!kinds) throw new Error(`Неподдерживаемый тип связи: ${kind}`);
const subType = enabled ? kinds.on : kinds.off;
return this.addBlockConnection({ login, toLogin, subType, storagePwd });
}
async addBlockUserParam({ login, param, value, storagePwd }) {
const cleanLogin = (login || '').trim();
const cleanParam = (param || '').trim();
@ -1148,7 +1232,7 @@ export class AuthService {
const freshHash = String(user?.serverLastGlobalHash || '').trim().toLowerCase();
const freshCursor = {
serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1,
serverLastGlobalHash: freshHash.length === 64 ? freshHash : '0'.repeat(64),
serverLastGlobalHash: freshHash.length === 64 ? freshHash : ZERO_HASH_HEX,
};
const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
@ -1161,7 +1245,7 @@ export class AuthService {
const tryAdd = async (cursor) => {
const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1;
const prevBlockHash = String(cursor?.serverLastGlobalHash || '0'.repeat(64));
const prevBlockHash = String(cursor?.serverLastGlobalHash || ZERO_HASH_HEX);
// Для USER_PARAM отправляем старт новой line-цепочки:
// prevLineNumber=-1, prevLineHash=0x00..00, thisLineNumber=-1.
@ -1169,7 +1253,7 @@ export class AuthService {
const bodyBytes = makeUserParamBodyBytes({
lineCode: 0,
prevLineNumber: -1,
prevLineHashHex: '0'.repeat(64),
prevLineHashHex: ZERO_HASH_HEX,
thisLineNumber: -1,
key: cleanParam,
value: cleanValue,
@ -1216,6 +1300,94 @@ export class AuthService {
return response.payload || {};
}
async addBlockConnection({ login, toLogin, subType, storagePwd }) {
const cleanLogin = (login || '').trim();
const cleanToLogin = (toLogin || '').trim();
const cleanSubType = Number(subType);
if (!cleanLogin || !cleanToLogin) throw new Error('Не переданы login/toLogin для CONNECTION.');
if (!Number.isFinite(cleanSubType)) throw new Error('Не передан subType для CONNECTION.');
if (!storagePwd) throw new Error('Не передан storagePwd для подписи AddBlock.');
if (cleanLogin.toLowerCase() === cleanToLogin.toLowerCase()) {
throw new Error('Нельзя создать связь на самого себя.');
}
const user = await this.getUser(cleanLogin);
if (user?.exists === false) throw new Error('Текущий пользователь не найден.');
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
const freshNum = Number(user?.serverLastGlobalNumber);
const freshHash = String(user?.serverLastGlobalHash || '').trim().toLowerCase();
const freshCursor = {
serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1,
serverLastGlobalHash: freshHash.length === 64 ? freshHash : ZERO_HASH_HEX,
};
const targetUser = await this.getUser(cleanToLogin);
if (!targetUser?.exists) throw new Error('Пользователь цели не найден.');
const toBlockchainName = String(targetUser?.blockchainName || `${cleanToLogin}-${BCH_SUFFIX}`).trim();
const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
const blockchainPrivatePkcs8 = savedKeys?.blockchainKey;
if (!blockchainPrivatePkcs8) {
throw new Error('На устройстве нет сохраненного приватного blockchainKey');
}
const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8);
const tryAdd = async (cursor) => {
const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1;
const prevBlockHash = String(cursor?.serverLastGlobalHash || ZERO_HASH_HEX);
// Для CONNECTION в UI-MVP всегда стартуем новую line-цепочку:
// prevLineNumber=-1, prevLineHash=0x00..00, thisLineNumber=-1.
// target для user-связей указывает на HEADER пользователя (blockNumber=0).
const bodyBytes = makeConnectionBodyBytes({
lineCode: 0,
prevLineNumber: -1,
prevLineHashHex: ZERO_HASH_HEX,
thisLineNumber: -1,
toBlockchainName,
toBlockNumber: 0,
toBlockHashHex: ZERO_HASH_HEX,
});
const preimage = concatBytes(
int16Bytes(0),
hexToBytes(prevBlockHash),
int32Bytes(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.length),
int32Bytes(blockNumber),
int64Bytes(Math.floor(Date.now() / 1000)),
int16Bytes(3),
int16Bytes(cleanSubType),
int16Bytes(1),
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 || '').trim().toLowerCase();
if (Number.isFinite(knownNum) && knownHash.length === 64) {
cursor = { serverLastGlobalNumber: knownNum, serverLastGlobalHash: knownHash };
response = await tryAdd(cursor);
}
}
if (response.status !== 200) throw opError('AddBlock', response);
return response.payload || {};
}
async reportClientError(details) {
try {
const response = await this.ws.request('ClientErrorLog', details || {}, 3000);

View File

@ -1,51 +1,129 @@
const LS_KEY = 'shine-ui-fcm-token-v1';
const LS_KEY = 'shine-ui-webpush-subscription-v1';
export async function initPwaPush({ authService }) {
if (!('serviceWorker' in navigator)) return;
try {
await navigator.serviceWorker.register('./firebase-messaging-sw.js');
} catch {
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
export async function initPwaPush({ authService, onLog = null }) {
const log = (entry) => {
if (typeof onLog === 'function') onLog(entry);
};
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
log({
level: 'warn',
source: 'web-push',
message: 'Web Push недоступен: нет serviceWorker или PushManager',
});
return;
}
if (!window.firebase || !window.firebase.messaging) return;
const vapidPublicKey = window.__SHINE_WEBPUSH_VAPID_PUBLIC_KEY__ || '';
if (!vapidPublicKey) {
log({
level: 'warn',
source: 'web-push',
message: 'Web Push отключен: не задан публичный VAPID ключ',
});
return;
}
try {
const config = window.__SHINE_FIREBASE_CONFIG__ || null;
if (!config) return;
if (!window.firebase.apps.length) {
window.firebase.initializeApp(config);
}
const messaging = window.firebase.messaging();
const registration = await navigator.serviceWorker.register('./firebase-messaging-sw.js');
log({
level: 'info',
source: 'web-push',
message: 'Service Worker зарегистрирован',
details: { scope: registration.scope },
});
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
if (permission !== 'granted') {
log({
level: 'warn',
source: 'web-push',
message: `Разрешение на уведомления: ${permission}`,
});
return;
}
log({
level: 'info',
source: 'web-push',
message: 'Разрешение на уведомления получено',
});
const vapidKey = window.__SHINE_FIREBASE_VAPID_KEY__ || '';
const token = await messaging.getToken({ vapidKey });
if (!token) return;
let sub = await registration.pushManager.getSubscription();
let isNewSubscription = false;
if (!sub) {
sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
isNewSubscription = true;
}
log({
level: 'info',
source: 'web-push',
message: isNewSubscription ? 'Создана новая push-подписка' : 'Найдена существующая push-подписка',
});
const prev = localStorage.getItem(LS_KEY);
if (prev === token) return;
const serialized = JSON.stringify(sub);
const prevSerialized = localStorage.getItem(LS_KEY);
if (prevSerialized === serialized) {
log({
level: 'info',
source: 'web-push',
message: 'Push-подписка не изменилась, отправка на сервер не требуется',
});
return;
}
localStorage.setItem(LS_KEY, serialized);
const json = sub.toJSON();
const endpoint = json.endpoint || '';
const p256dhKey = json.keys?.p256dh || '';
const authKey = json.keys?.auth || '';
if (!endpoint || !p256dhKey || !authKey) {
log({
level: 'warn',
source: 'web-push',
message: 'Подписка неполная: endpoint/p256dh/auth отсутствуют',
});
return;
}
log({
level: 'info',
source: 'web-push',
message: 'Push-токен получен, отправка на сервер',
details: { endpoint },
});
localStorage.setItem(LS_KEY, token);
const tokenId = `tok-${new Date().toISOString().replace(/[-:.TZ]/g, '')}-${Math.random().toString(36).slice(2, 12)}`;
await authService.upsertPushToken({
tokenId,
token,
provider: 'fcm',
endpoint,
p256dhKey,
authKey,
platform: 'web',
userAgent: navigator.userAgent || '',
});
messaging.onMessage((payload) => {
const title = payload?.notification?.title || 'Новое сообщение';
const body = payload?.notification?.body || '';
try {
new Notification(title, { body });
} catch {}
log({
level: 'info',
source: 'web-push',
message: 'Push-подписка успешно отправлена на сервер',
});
} catch (error) {
log({
level: 'error',
source: 'web-push',
message: 'Ошибка инициализации Web Push',
details: error?.message || 'unknown',
});
} catch {
// silent for MVP
}
}

View File

@ -0,0 +1,215 @@
import { authService, state } from '../state.js';
import { loadProfileSnapshot } from './user-profile-params.js';
function normalizeLogin(value) {
return String(value || '').trim();
}
function normKey(value) {
return normalizeLogin(value).toLowerCase();
}
function uniqueLogins(list) {
const out = [];
const seen = new Set();
(Array.isArray(list) ? list : []).forEach((item) => {
const login = normalizeLogin(item);
if (!login) return;
const key = normKey(login);
if (seen.has(key)) return;
seen.add(key);
out.push(login);
});
return out;
}
function listContainsLogin(list, login) {
const targetKey = normKey(login);
if (!targetKey) return false;
return uniqueLogins(list).some((value) => normKey(value) === targetKey);
}
function toFieldMap(snapshot) {
const map = {};
(snapshot?.fields || []).forEach((field) => {
map[field.key] = String(field.value || '').trim();
});
return map;
}
function toToggleMap(snapshot) {
const map = {};
(snapshot?.toggles || []).forEach((toggle) => {
map[toggle.key] = Boolean(toggle.enabled);
});
return map;
}
function readArray(payload, key) {
const value = payload?.[key];
return Array.isArray(value) ? uniqueLogins(value) : null;
}
function feedOwnerLogins(feedPayload) {
const rows = Array.isArray(feedPayload?.followedUsersChannels) ? feedPayload.followedUsersChannels : [];
const owners = rows
.map((row) => normalizeLogin(row?.channel?.ownerLogin))
.filter(Boolean);
return uniqueLogins(owners);
}
async function buildRelationsModel(login) {
const cleanLogin = normalizeLogin(login);
if (!cleanLogin) {
return {
outFriends: [],
inFriends: [],
outContacts: [],
inContacts: [],
outFollows: [],
inFollows: [],
};
}
const graph = await authService.getUserConnectionsGraph(cleanLogin);
let outContacts = readArray(graph, 'outContacts');
let outFollows = readArray(graph, 'outFollows');
const isCurrentSessionLogin = normKey(cleanLogin) === normKey(state.session.login);
if (outContacts === null && isCurrentSessionLogin) {
try {
const contacts = await authService.listContacts();
outContacts = uniqueLogins(contacts?.contacts || []);
} catch {
outContacts = [];
}
}
if (outContacts === null) outContacts = [];
if (outFollows === null) {
try {
const feed = await authService.listSubscriptionsFeed(cleanLogin, 200);
outFollows = feedOwnerLogins(feed);
} catch {
outFollows = [];
}
}
return {
outFriends: readArray(graph, 'outFriends') || [],
inFriends: readArray(graph, 'inFriends') || [],
outContacts,
inContacts: readArray(graph, 'inContacts') || [],
outFollows,
inFollows: readArray(graph, 'inFollows') || [],
};
}
export function buildIdentityLines({ login, firstName, lastName }) {
const lines = [];
const first = String(firstName || '').trim();
const last = String(lastName || '').trim();
const cleanLogin = normalizeLogin(login);
if (first) lines.push(first);
if (last) lines.push(last);
lines.push(cleanLogin || 'unknown');
return lines;
}
export function buildAvatarInitials({ login, firstName, lastName }) {
const first = String(firstName || '').trim();
const last = String(lastName || '').trim();
if (first || last) {
const a = (first[0] || '').toUpperCase();
const b = (last[0] || '').toUpperCase();
const initials = `${a}${b}`.trim();
if (initials) return initials;
}
const cleanLogin = normalizeLogin(login);
return (cleanLogin[0] || '?').toUpperCase();
}
export async function loadCurrentRelations() {
const login = normalizeLogin(state.session.login);
if (!login) {
return {
outFriends: [],
inFriends: [],
outContacts: [],
inContacts: [],
outFollows: [],
inFollows: [],
};
}
return buildRelationsModel(login);
}
export function relationFlagsForTarget(relations, targetLogin) {
return {
outFriend: listContainsLogin(relations?.outFriends, targetLogin),
inFriend: listContainsLogin(relations?.inFriends, targetLogin),
outContact: listContainsLogin(relations?.outContacts, targetLogin),
inContact: listContainsLogin(relations?.inContacts, targetLogin),
outFollow: listContainsLogin(relations?.outFollows, targetLogin),
inFollow: listContainsLogin(relations?.inFollows, targetLogin),
};
}
export async function loadUserProfileCard(login) {
const cleanLogin = normalizeLogin(login);
if (!cleanLogin) throw new Error('Пустой login');
const [user, snapshot] = await Promise.all([
authService.getUser(cleanLogin),
loadProfileSnapshot(cleanLogin),
]);
if (!user?.exists) throw new Error('Пользователь не найден');
const canonicalLogin = normalizeLogin(user.login || cleanLogin);
const fields = toFieldMap(snapshot);
const toggles = toToggleMap(snapshot);
return {
login: canonicalLogin,
blockchainName: normalizeLogin(user.blockchainName),
firstName: fields.first_name || '',
lastName: fields.last_name || '',
address: fields.address || '',
web: fields.web || '',
phone: fields.phone || '',
official: Boolean(toggles.official),
shine: Boolean(toggles.shine),
};
}
export async function loadRelationsForPair({ currentLogin, targetLogin }) {
const cleanCurrent = normalizeLogin(currentLogin);
const cleanTarget = normalizeLogin(targetLogin);
const currentRelations = await buildRelationsModel(cleanCurrent);
let flags = relationFlagsForTarget(currentRelations, cleanTarget);
if (!flags.inContact || !flags.inFollow) {
try {
const targetRelations = await buildRelationsModel(cleanTarget);
const backFlags = relationFlagsForTarget(targetRelations, cleanCurrent);
flags = {
...flags,
inContact: flags.inContact || backFlags.outContact,
inFollow: flags.inFollow || backFlags.outFollow,
};
} catch {
// ignore fallback failures for incoming direction
}
}
return {
...flags,
source: currentRelations,
};
}

View File

@ -5,6 +5,7 @@ import { clearClientAuthData } from './services/key-vault.js';
const clone = (value) => JSON.parse(JSON.stringify(value));
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
const REACTIONS_STORAGE_KEY = 'shine-ui-message-reactions-v2';
const MAX_APP_LOG_ENTRIES = 500;
const INVALID_SESSION_CODES = new Set([
'NOT_AUTHENTICATED',
'SESSION_NOT_FOUND',
@ -127,6 +128,7 @@ function createInitialState({ withStoredSession = true } = {}) {
return {
chats: clone(chatMessages),
contacts: [],
appLog: [],
incomingDedup: {},
notificationsTab: 'replies',
pageLabelCollapsed: false,
@ -223,6 +225,49 @@ export function setContacts(list) {
state.contacts = Array.isArray(list) ? [...list] : [];
}
function toText(value) {
if (typeof value === 'string') return value;
if (value == null) return '';
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
export function addAppLogEntry({
level = 'info',
source = 'ui',
message = '',
details = '',
} = {}) {
const cleanMessage = String(message || '').trim();
if (!cleanMessage) return;
const cleanLevel = String(level || 'info').trim().toLowerCase();
const normalizedLevel = (cleanLevel === 'error' || cleanLevel === 'warn') ? cleanLevel : 'info';
state.appLog.push({
id: `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`,
ts: Date.now(),
level: normalizedLevel,
source: String(source || 'ui').trim() || 'ui',
message: cleanMessage,
details: toText(details),
});
if (state.appLog.length > MAX_APP_LOG_ENTRIES) {
state.appLog.splice(0, state.appLog.length - MAX_APP_LOG_ENTRIES);
}
}
export function getAppLogEntries() {
return [...state.appLog];
}
export function clearAppLogEntries() {
state.appLog = [];
}
export function togglePageLabel() {
state.pageLabelCollapsed = !state.pageLabelCollapsed;
}

View File

@ -135,6 +135,24 @@
background: rgba(83, 216, 251, 0.11);
}
.badge.is-no {
border-color: rgba(170, 180, 205, 0.3);
color: #c5cedd;
background: rgba(152, 164, 190, 0.14);
}
.badge.is-yes-official {
border-color: rgba(132, 244, 161, 0.5);
color: #ddffe7;
background: rgba(132, 244, 161, 0.2);
}
.badge.is-yes-shine {
border-color: rgba(183, 122, 255, 0.6);
color: #f4e7ff;
background: rgba(176, 102, 255, 0.22);
}
.badge.profile-toggle-btn.is-no {
border-color: rgba(170, 180, 205, 0.3);
color: #c5cedd;
@ -198,6 +216,22 @@
gap: 8px;
}
.profile-identity-lines {
display: grid;
gap: 4px;
}
.profile-identity-line {
line-height: 1.2;
color: #eef3ff;
font-size: 17px;
}
.profile-identity-login {
font-weight: 700;
font-size: 20px;
}
.profile-param-item {
padding: 10px;
gap: 6px;
@ -606,7 +640,7 @@
.contact-search-actions {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: 1fr;
gap: 8px;
}
@ -828,6 +862,25 @@ textarea.input {
color: #d6e2ff;
}
.node-menu {
position: absolute;
z-index: 3;
min-width: 240px;
padding: 8px;
}
.user-relations-list {
gap: 6px;
}
.user-rel-row {
display: flex;
justify-content: space-between;
gap: 10px;
color: #d8e3ff;
font-size: 14px;
}
.tabs {
display: grid;
grid-template-columns: repeat(2, 1fr);

View File

@ -445,6 +445,45 @@ public final class DatabaseInitializer {
ON user_push_tokens (login, session_id);
""");
// 11) signed_direct_message_replay (anti-replay window)
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS signed_direct_message_replay (
from_login TEXT NOT NULL,
time_ms INTEGER NOT NULL,
nonce INTEGER NOT NULL,
created_at_ms INTEGER NOT NULL,
UNIQUE (from_login, time_ms, nonce)
);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_signed_dm_replay_created
ON signed_direct_message_replay (created_at_ms);
""");
// 12) signed_direct_messages_history (сырой бинарный пакет + мета)
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS signed_direct_messages_history (
message_id TEXT NOT NULL PRIMARY KEY,
from_login TEXT NOT NULL,
to_login TEXT NOT NULL,
target_mode INTEGER NOT NULL,
target_session_id TEXT,
message_type INTEGER NOT NULL,
time_ms INTEGER NOT NULL,
nonce INTEGER NOT NULL,
raw_packet BLOB NOT NULL,
created_at_ms INTEGER NOT NULL,
FOREIGN KEY (from_login) REFERENCES solana_users(login),
FOREIGN KEY (to_login) REFERENCES solana_users(login)
);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_signed_dm_history_to
ON signed_direct_messages_history (to_login, created_at_ms);
""");
DatabaseTriggersInstaller.createAllTriggers(st);
}
}

View File

@ -217,6 +217,25 @@ public final class ActiveSessionsDAO {
}
}
public void updatePushSubscription(String sessionId, String endpoint, String p256dhKey, String authKey) throws SQLException {
try (Connection c = db.getConnection()) {
String sql = """
UPDATE active_sessions
SET push_endpoint = ?,
push_p256dh_key = ?,
push_auth_key = ?
WHERE session_id = ?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, endpoint);
ps.setString(2, p256dhKey);
ps.setString(3, authKey);
ps.setString(4, sessionId);
ps.executeUpdate();
}
}
}
// -------------------- DELETE --------------------
public void deleteBySessionId(Connection c, String sessionId) throws SQLException {

View File

@ -0,0 +1,47 @@
package shine.db.dao;
import shine.db.SqliteDbController;
import shine.db.entities.SignedDirectMessageHistoryEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
public final class SignedDirectMessagesHistoryDAO {
private static volatile SignedDirectMessagesHistoryDAO instance;
private final SqliteDbController db = SqliteDbController.getInstance();
private SignedDirectMessagesHistoryDAO() {}
public static SignedDirectMessagesHistoryDAO getInstance() {
if (instance == null) {
synchronized (SignedDirectMessagesHistoryDAO.class) {
if (instance == null) instance = new SignedDirectMessagesHistoryDAO();
}
}
return instance;
}
public void insert(SignedDirectMessageHistoryEntry e) throws Exception {
try (Connection c = db.getConnection()) {
String sql = """
INSERT INTO signed_direct_messages_history (
message_id, from_login, to_login, target_mode, target_session_id,
message_type, time_ms, nonce, raw_packet, created_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, e.getMessageId());
ps.setString(2, e.getFromLogin());
ps.setString(3, e.getToLogin());
ps.setInt(4, e.getTargetMode());
ps.setString(5, e.getTargetSessionId());
ps.setInt(6, e.getMessageType());
ps.setLong(7, e.getTimeMs());
ps.setLong(8, e.getNonce());
ps.setBytes(9, e.getRawPacket());
ps.setLong(10, e.getCreatedAtMs());
ps.executeUpdate();
}
}
}
}

View File

@ -0,0 +1,50 @@
package shine.db.dao;
import shine.db.SqliteDbController;
import java.sql.Connection;
import java.sql.PreparedStatement;
public final class SignedDmReplayDAO {
private static volatile SignedDmReplayDAO instance;
private final SqliteDbController db = SqliteDbController.getInstance();
private SignedDmReplayDAO() {}
public static SignedDmReplayDAO getInstance() {
if (instance == null) {
synchronized (SignedDmReplayDAO.class) {
if (instance == null) instance = new SignedDmReplayDAO();
}
}
return instance;
}
public boolean registerUnique(String fromLogin, long timeMs, long nonce, long nowMs) throws Exception {
cleanupExpired(nowMs - 15L * 60L * 1000L);
try (Connection c = db.getConnection()) {
String sql = """
INSERT OR IGNORE INTO signed_direct_message_replay (
from_login, time_ms, nonce, created_at_ms
) VALUES (?, ?, ?, ?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, fromLogin);
ps.setLong(2, timeMs);
ps.setLong(3, nonce);
ps.setLong(4, nowMs);
return ps.executeUpdate() > 0;
}
}
}
public void cleanupExpired(long minCreatedAtMs) throws Exception {
try (Connection c = db.getConnection()) {
String sql = "DELETE FROM signed_direct_message_replay WHERE created_at_ms < ?";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, minCreatedAtMs);
ps.executeUpdate();
}
}
}
}

View File

@ -0,0 +1,35 @@
package shine.db.entities;
public class SignedDirectMessageHistoryEntry {
private String messageId;
private String fromLogin;
private String toLogin;
private int targetMode;
private String targetSessionId;
private int messageType;
private long timeMs;
private long nonce;
private byte[] rawPacket;
private long createdAtMs;
public String getMessageId() { return messageId; }
public void setMessageId(String messageId) { this.messageId = messageId; }
public String getFromLogin() { return fromLogin; }
public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; }
public String getToLogin() { return toLogin; }
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
public int getTargetMode() { return targetMode; }
public void setTargetMode(int targetMode) { this.targetMode = targetMode; }
public String getTargetSessionId() { return targetSessionId; }
public void setTargetSessionId(String targetSessionId) { this.targetSessionId = targetSessionId; }
public int getMessageType() { return messageType; }
public void setMessageType(int messageType) { this.messageType = messageType; }
public long getTimeMs() { return timeMs; }
public void setTimeMs(long timeMs) { this.timeMs = timeMs; }
public long getNonce() { return nonce; }
public void setNonce(long nonce) { this.nonce = nonce; }
public byte[] getRawPacket() { return rawPacket; }
public void setRawPacket(byte[] rawPacket) { this.rawPacket = rawPacket; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
}

View File

@ -23,6 +23,7 @@ dependencies {
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1' // json
implementation "org.slf4j:slf4j-api:2.0.16" // вызов логгера
implementation 'nl.martijndwars:web-push:5.1.1'
implementation project(':shine-server-config') // модуль с настройками
implementation project(":shine-server-log") // модуль логирования и уведомления админов
@ -40,4 +41,3 @@ java {
targetCompatibility = JavaVersion.VERSION_17
}

View File

@ -31,16 +31,24 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден");
}
List<String> out = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FRIEND);
List<String> in = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FRIEND);
List<String> outFriends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FRIEND);
List<String> inFriends = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FRIEND);
List<String> outContacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CONTACT);
List<String> inContacts = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CONTACT);
List<String> outFollows = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FOLLOW);
List<String> inFollows = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FOLLOW);
Net_GetUserConnectionsGraph_Response resp = new Net_GetUserConnectionsGraph_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setLogin(canonicalLogin);
resp.setOutFriends(out);
resp.setInFriends(in);
resp.setOutFriends(outFriends);
resp.setInFriends(inFriends);
resp.setOutContacts(outContacts);
resp.setInContacts(inContacts);
resp.setOutFollows(outFollows);
resp.setInFollows(inFollows);
return resp;
}
}

View File

@ -9,6 +9,10 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
private String login;
private List<String> outFriends = new ArrayList<>();
private List<String> inFriends = new ArrayList<>();
private List<String> outContacts = new ArrayList<>();
private List<String> inContacts = new ArrayList<>();
private List<String> outFollows = new ArrayList<>();
private List<String> inFollows = new ArrayList<>();
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
@ -16,4 +20,12 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
public void setOutFriends(List<String> outFriends) { this.outFriends = outFriends; }
public List<String> getInFriends() { return inFriends; }
public void setInFriends(List<String> inFriends) { this.inFriends = inFriends; }
public List<String> getOutContacts() { return outContacts; }
public void setOutContacts(List<String> outContacts) { this.outContacts = outContacts; }
public List<String> getInContacts() { return inContacts; }
public void setInContacts(List<String> inContacts) { this.inContacts = inContacts; }
public List<String> getOutFollows() { return outFollows; }
public void setOutFollows(List<String> outFollows) { this.outFollows = outFollows; }
public List<String> getInFollows() { return inFollows; }
public void setInFollows(List<String> inFollows) { this.inFollows = inFollows; }
}

View File

@ -9,18 +9,24 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Response;
import server.logic.ws_protocol.JSON.push.FcmPushSender;
import server.logic.ws_protocol.JSON.push.WebPushSender;
import server.logic.ws_protocol.JSON.push.WsEventSender;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.JSON.utils.NetIdGenerator;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.ActiveSessionsDAO;
import shine.db.dao.DirectMessagesDAO;
import shine.db.dao.PushTokensDAO;
import shine.db.dao.SignedDirectMessagesHistoryDAO;
import shine.db.dao.SignedDmReplayDAO;
import shine.db.dao.SolanaUsersDAO;
import shine.db.entities.ActiveSessionEntry;
import shine.db.entities.DirectMessageEntry;
import shine.db.entities.PushTokenEntry;
import shine.db.entities.SignedDirectMessageHistoryEntry;
import shine.db.entities.SolanaUserEntry;
import utils.crypto.Ed25519Util;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@ -29,102 +35,174 @@ import java.util.concurrent.TimeUnit;
public class Net_SendDirectMessage_Handler implements JsonMessageHandler {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final long REPLAY_TTL_MS = 15L * 60L * 1000L;
private static final int MAX_MESSAGE_BYTES = 3000;
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
Net_SendDirectMessage_Request req = (Net_SendDirectMessage_Request) baseRequest;
if (ctx == null || !ctx.isAuthenticatedUser()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
}
if (req.getToLogin() == null || req.getToLogin().isBlank() || req.getText() == null || req.getText().isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin и text обязательны");
if (req.getBlobB64() == null || req.getBlobB64().isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "blobB64 обязателен");
}
String from = ctx.getLogin();
String toRequest = req.getToLogin().trim();
String text = req.getText().trim();
SolanaUserEntry targetUser = SolanaUsersDAO.getInstance().getByLogin(toRequest);
if (targetUser == null) {
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден");
final byte[] raw;
final SignedDirectMessagePacket packet;
try {
raw = Base64.getDecoder().decode(req.getBlobB64().trim());
packet = SignedDirectMessagePacket.parse(raw, MAX_MESSAGE_BYTES);
} catch (IllegalArgumentException ex) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный формат пакета");
}
String to = targetUser.getLogin();
if (!canSend(from, to)) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NO_PERMISSION", "Можно писать только контактам или тем, кто уже писал вам");
SolanaUserEntry fromUser = SolanaUsersDAO.getInstance().getByLogin(packet.fromLogin);
SolanaUserEntry toUser = SolanaUsersDAO.getInstance().getByLogin(packet.toLogin);
if (fromUser == null || toUser == null) {
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "from/to пользователь не найден");
}
byte[] publicKey32;
try {
publicKey32 = Ed25519Util.keyFromBase64(fromUser.getDeviceKey());
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "BAD_DEVICE_KEY", "Некорректный deviceKey отправителя");
}
if (!Ed25519Util.verify(packet.signedBody, packet.signature64, publicKey32)) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "BAD_SIGNATURE", "Подпись не прошла проверку");
}
long now = System.currentTimeMillis();
if (Math.abs(now - packet.timeMs) > REPLAY_TTL_MS) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "BAD_TIME_WINDOW", "Время сообщения вышло за окно 15 минут");
}
boolean replayOk = SignedDmReplayDAO.getInstance().registerUnique(packet.fromLogin, packet.timeMs, packet.nonce, now);
if (!replayOk) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "REPLAY", "Повторное сообщение заблокировано");
}
String messageId = NetIdGenerator.eventId("msg");
String textForUi = new String(packet.messageBytes, StandardCharsets.UTF_8);
DirectMessageEntry entry = new DirectMessageEntry();
entry.setMessageId(messageId);
entry.setFromLogin(from);
entry.setToLogin(to);
entry.setText(text);
entry.setCreatedAtMs(System.currentTimeMillis());
entry.setFromLogin(packet.fromLogin);
entry.setToLogin(packet.toLogin);
entry.setText(textForUi);
entry.setCreatedAtMs(now);
DirectMessagesDAO.getInstance().insert(entry);
Set<ConnectionContext> activeSessions = ActiveConnectionsRegistry.getInstance().getByLogin(to);
List<PushTokenEntry> tokens = PushTokensDAO.getInstance().listByLogin(to);
SignedDirectMessageHistoryEntry history = new SignedDirectMessageHistoryEntry();
history.setMessageId(messageId);
history.setFromLogin(packet.fromLogin);
history.setToLogin(packet.toLogin);
history.setTargetMode(packet.targetMode);
history.setTargetSessionId(packet.targetSessionId);
history.setMessageType(packet.messageType);
history.setTimeMs(packet.timeMs);
history.setNonce(packet.nonce);
history.setRawPacket(packet.rawPacket);
history.setCreatedAtMs(now);
SignedDirectMessagesHistoryDAO.getInstance().insert(history);
int wsDelivered = 0;
int fcmDelivered = 0;
Set<String> activeSessionIds = new HashSet<>();
for (ConnectionContext targetCtx : activeSessions) {
activeSessionIds.add(targetCtx.getSessionId());
String eventId = NetIdGenerator.eventId("evt");
CompletableFuture<Boolean> waiter = DeliveryTracker.getInstance().register(eventId);
ObjectNode payload = MAPPER.createObjectNode();
payload.put("eventId", eventId);
payload.put("messageId", messageId);
payload.put("fromLogin", from);
payload.put("toLogin", to);
payload.put("text", text);
payload.put("timeMs", entry.getCreatedAtMs());
boolean sent = WsEventSender.sendEvent(targetCtx, "IncomingDirectMessage", eventId, payload);
boolean acked = false;
if (sent) {
try {
acked = waiter.get(1200, TimeUnit.MILLISECONDS);
} catch (Exception ignored) {
acked = false;
}
}
DeliveryTracker.getInstance().remove(eventId);
if (acked) {
wsDelivered++;
continue;
}
for (PushTokenEntry token : tokens) {
if (!targetCtx.getSessionId().equals(token.getSessionId())) continue;
boolean pushed = FcmPushSender.sendNotification(token.getToken(), "Новое сообщение", text, messageId);
if (pushed) {
fcmDelivered++;
break;
}
}
}
for (PushTokenEntry token : tokens) {
if (activeSessionIds.contains(token.getSessionId())) continue;
boolean pushed = FcmPushSender.sendNotification(token.getToken(), "Новое сообщение", text, messageId);
if (pushed) fcmDelivered++;
}
DeliveryResult delivery = deliver(packet, req.getBlobB64().trim(), messageId, now);
Net_SendDirectMessage_Response resp = new Net_SendDirectMessage_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setMessageId(messageId);
resp.setDeliveredWsSessions(wsDelivered);
resp.setDeliveredFcmSessions(fcmDelivered);
resp.setDeliveredWsSessions(delivery.wsDelivered);
resp.setDeliveredWebPushSessions(delivery.webPushDelivered);
resp.setSessionNotFound(delivery.sessionNotFound);
return resp;
}
private boolean canSend(String from, String to) {
return from != null && !from.isBlank() && to != null && !to.isBlank();
private DeliveryResult deliver(SignedDirectMessagePacket packet, String blobB64, String messageId, long createdAtMs) throws Exception {
DeliveryResult result = new DeliveryResult();
Set<String> selectedSessionIds = new HashSet<>();
if (packet.targetMode == SignedDirectMessagePacket.TARGET_ONE_SESSION) {
ActiveSessionEntry byId = ActiveSessionsDAO.getInstance().getBySessionId(packet.targetSessionId);
if (byId == null || !packet.toLogin.equalsIgnoreCase(byId.getLogin())) {
result.sessionNotFound = true;
return result;
}
selectedSessionIds.add(byId.getSessionId());
deliverToSession(packet, blobB64, messageId, createdAtMs, byId.getSessionId(), result);
return result;
}
List<ActiveSessionEntry> sessions = ActiveSessionsDAO.getInstance().getByLogin(packet.toLogin);
for (ActiveSessionEntry s : sessions) {
selectedSessionIds.add(s.getSessionId());
deliverToSession(packet, blobB64, messageId, createdAtMs, s.getSessionId(), result);
}
return result;
}
private void deliverToSession(
SignedDirectMessagePacket packet,
String blobB64,
String messageId,
long createdAtMs,
String sessionId,
DeliveryResult result
) {
ConnectionContext targetCtx = ActiveConnectionsRegistry.getInstance().getBySessionId(sessionId);
boolean wsDelivered = false;
if (targetCtx != null) {
String eventId = NetIdGenerator.eventId("evt");
CompletableFuture<Boolean> waiter = DeliveryTracker.getInstance().register(eventId);
ObjectNode payload = MAPPER.createObjectNode();
payload.put("eventId", eventId);
payload.put("messageId", messageId);
payload.put("fromLogin", packet.fromLogin);
payload.put("toLogin", packet.toLogin);
payload.put("blobB64", blobB64);
payload.put("text", new String(packet.messageBytes, StandardCharsets.UTF_8));
payload.put("timeMs", createdAtMs);
boolean sent = WsEventSender.sendEvent(targetCtx, "IncomingDirectMessage", eventId, payload);
if (sent) {
try {
wsDelivered = waiter.get(1200, TimeUnit.MILLISECONDS);
} catch (Exception ignored) {
wsDelivered = false;
}
}
DeliveryTracker.getInstance().remove(eventId);
}
if (wsDelivered) {
result.wsDelivered++;
return;
}
try {
ActiveSessionEntry targetSession = ActiveSessionsDAO.getInstance().getBySessionId(sessionId);
if (targetSession == null) return;
if (isBlank(targetSession.getPushEndpoint()) || isBlank(targetSession.getPushP256dhKey()) || isBlank(targetSession.getPushAuthKey())) {
return;
}
boolean pushed = WebPushSender.sendBase64Payload(
targetSession.getPushEndpoint(),
targetSession.getPushP256dhKey(),
targetSession.getPushAuthKey(),
blobB64
);
if (pushed) result.webPushDelivered++;
} catch (Exception ignored) {
// ignore per-session push errors
}
}
private boolean isBlank(String s) {
return s == null || s.isBlank();
}
private static final class DeliveryResult {
int wsDelivered;
int webPushDelivered;
boolean sessionNotFound;
}
}

View File

@ -8,8 +8,7 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Reque
import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.PushTokensDAO;
import shine.db.entities.PushTokenEntry;
import shine.db.dao.ActiveSessionsDAO;
public class Net_UpsertPushToken_Handler implements JsonMessageHandler {
@Override
@ -18,27 +17,27 @@ public class Net_UpsertPushToken_Handler implements JsonMessageHandler {
if (ctx == null || !ctx.isAuthenticatedUser()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
}
if (req.getTokenId() == null || req.getTokenId().isBlank() || req.getToken() == null || req.getToken().isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "tokenId и token обязательны");
if (req.getEndpoint() == null || req.getEndpoint().isBlank()
|| req.getP256dhKey() == null || req.getP256dhKey().isBlank()
|| req.getAuthKey() == null || req.getAuthKey().isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "endpoint/p256dhKey/authKey обязательны");
}
PushTokenEntry e = new PushTokenEntry();
e.setTokenId(req.getTokenId().trim());
e.setLogin(ctx.getLogin());
e.setSessionId((req.getSessionId() == null || req.getSessionId().isBlank()) ? ctx.getSessionId() : req.getSessionId().trim());
e.setProvider(req.getProvider() == null || req.getProvider().isBlank() ? "fcm" : req.getProvider().trim());
e.setToken(req.getToken().trim());
e.setPlatform(req.getPlatform());
e.setUserAgent(req.getUserAgent());
e.setUpdatedAtMs(System.currentTimeMillis());
PushTokensDAO.getInstance().upsert(e);
String sessionId = (req.getSessionId() == null || req.getSessionId().isBlank()) ? ctx.getSessionId() : req.getSessionId().trim();
long now = System.currentTimeMillis();
ActiveSessionsDAO.getInstance().updatePushSubscription(
sessionId,
req.getEndpoint().trim(),
req.getP256dhKey().trim(),
req.getAuthKey().trim()
);
Net_UpsertPushToken_Response resp = new Net_UpsertPushToken_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setTokenId(e.getTokenId());
resp.setUpdatedAtMs(e.getUpdatedAtMs());
resp.setTokenId(sessionId);
resp.setUpdatedAtMs(now);
return resp;
}
}

View File

@ -0,0 +1,134 @@
package server.logic.ws_protocol.JSON.messages;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
final class SignedDirectMessagePacket {
static final byte[] PREFIX = "SHiNE_msg".getBytes(StandardCharsets.US_ASCII);
static final int VERSION = 1;
static final int MESSAGE_TYPE_DIRECT = 1;
static final int TARGET_ALL_SESSIONS = 0;
static final int TARGET_ONE_SESSION = 1;
final int version;
final String toLogin;
final String fromLogin;
final long timeMs;
final long nonce;
final int messageType;
final int targetMode;
final String targetSessionId;
final byte[] messageBytes;
final byte[] signedBody;
final byte[] signature64;
final byte[] rawPacket;
private SignedDirectMessagePacket(
int version,
String toLogin,
String fromLogin,
long timeMs,
long nonce,
int messageType,
int targetMode,
String targetSessionId,
byte[] messageBytes,
byte[] signedBody,
byte[] signature64,
byte[] rawPacket
) {
this.version = version;
this.toLogin = toLogin;
this.fromLogin = fromLogin;
this.timeMs = timeMs;
this.nonce = nonce;
this.messageType = messageType;
this.targetMode = targetMode;
this.targetSessionId = targetSessionId;
this.messageBytes = messageBytes;
this.signedBody = signedBody;
this.signature64 = signature64;
this.rawPacket = rawPacket;
}
static SignedDirectMessagePacket parse(byte[] raw, int maxMessageBytes) {
if (raw == null || raw.length < PREFIX.length + 1 + 1 + 1 + 8 + 4 + 2 + 1 + 2 + 64) {
throw new IllegalArgumentException("BAD_LEN");
}
if (raw.length > 4096) {
throw new IllegalArgumentException("PAYLOAD_TOO_LARGE");
}
ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN);
byte[] prefixBytes = new byte[PREFIX.length];
bb.get(prefixBytes);
if (!Arrays.equals(prefixBytes, PREFIX)) {
throw new IllegalArgumentException("BAD_PREFIX");
}
int version = Byte.toUnsignedInt(bb.get());
if (version != VERSION) {
throw new IllegalArgumentException("BAD_VERSION");
}
String toLogin = readAscii(bb, 1, 30, "BAD_TO_LOGIN");
String fromLogin = readAscii(bb, 1, 30, "BAD_FROM_LOGIN");
long timeMs = bb.getLong();
if (timeMs < 0) throw new IllegalArgumentException("BAD_TIME");
long nonce = Integer.toUnsignedLong(bb.getInt());
int messageType = Short.toUnsignedInt(bb.getShort());
if (messageType != MESSAGE_TYPE_DIRECT) {
throw new IllegalArgumentException("BAD_MESSAGE_TYPE");
}
int targetMode = Byte.toUnsignedInt(bb.get());
if (targetMode != TARGET_ALL_SESSIONS && targetMode != TARGET_ONE_SESSION) {
throw new IllegalArgumentException("BAD_TARGET_MODE");
}
String targetSessionId = null;
if (targetMode == TARGET_ONE_SESSION) {
targetSessionId = readAscii(bb, 1, 255, "BAD_SESSION_ID");
}
int msgLen = Short.toUnsignedInt(bb.getShort());
if (msgLen < 1 || msgLen > maxMessageBytes) {
throw new IllegalArgumentException("BAD_MESSAGE_LEN");
}
if (bb.remaining() != msgLen + 64) {
throw new IllegalArgumentException("BAD_LEN");
}
byte[] messageBytes = new byte[msgLen];
bb.get(messageBytes);
byte[] signature64 = new byte[64];
bb.get(signature64);
byte[] signedBody = Arrays.copyOf(raw, raw.length - 64);
return new SignedDirectMessagePacket(
version, toLogin, fromLogin, timeMs, nonce, messageType, targetMode,
targetSessionId, messageBytes, signedBody, signature64, raw
);
}
private static String readAscii(ByteBuffer bb, int minLen, int maxLen, String code) {
if (!bb.hasRemaining()) throw new IllegalArgumentException(code);
int len = Byte.toUnsignedInt(bb.get());
if (len < minLen || len > maxLen || bb.remaining() < len) {
throw new IllegalArgumentException(code);
}
byte[] bytes = new byte[len];
bb.get(bytes);
for (byte b : bytes) {
if (b < 0x20 || b > 0x7E) throw new IllegalArgumentException(code);
}
return new String(bytes, StandardCharsets.US_ASCII);
}
}

View File

@ -3,11 +3,8 @@ package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_SendDirectMessage_Request extends Net_Request {
private String toLogin;
private String text;
private String blobB64;
public String getToLogin() { return toLogin; }
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
public String getBlobB64() { return blobB64; }
public void setBlobB64(String blobB64) { this.blobB64 = blobB64; }
}

View File

@ -5,12 +5,15 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_SendDirectMessage_Response extends Net_Response {
private String messageId;
private int deliveredWsSessions;
private int deliveredFcmSessions;
private int deliveredWebPushSessions;
private boolean sessionNotFound;
public String getMessageId() { return messageId; }
public void setMessageId(String messageId) { this.messageId = messageId; }
public int getDeliveredWsSessions() { return deliveredWsSessions; }
public void setDeliveredWsSessions(int deliveredWsSessions) { this.deliveredWsSessions = deliveredWsSessions; }
public int getDeliveredFcmSessions() { return deliveredFcmSessions; }
public void setDeliveredFcmSessions(int deliveredFcmSessions) { this.deliveredFcmSessions = deliveredFcmSessions; }
public int getDeliveredWebPushSessions() { return deliveredWebPushSessions; }
public void setDeliveredWebPushSessions(int deliveredWebPushSessions) { this.deliveredWebPushSessions = deliveredWebPushSessions; }
public boolean isSessionNotFound() { return sessionNotFound; }
public void setSessionNotFound(boolean sessionNotFound) { this.sessionNotFound = sessionNotFound; }
}

View File

@ -3,21 +3,21 @@ package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_UpsertPushToken_Request extends Net_Request {
private String tokenId;
private String sessionId;
private String provider;
private String token;
private String endpoint;
private String p256dhKey;
private String authKey;
private String platform;
private String userAgent;
public String getTokenId() { return tokenId; }
public void setTokenId(String tokenId) { this.tokenId = tokenId; }
public String getSessionId() { return sessionId; }
public void setSessionId(String sessionId) { this.sessionId = sessionId; }
public String getProvider() { return provider; }
public void setProvider(String provider) { this.provider = provider; }
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }
public String getEndpoint() { return endpoint; }
public void setEndpoint(String endpoint) { this.endpoint = endpoint; }
public String getP256dhKey() { return p256dhKey; }
public void setP256dhKey(String p256dhKey) { this.p256dhKey = p256dhKey; }
public String getAuthKey() { return authKey; }
public void setAuthKey(String authKey) { this.authKey = authKey; }
public String getPlatform() { return platform; }
public void setPlatform(String platform) { this.platform = platform; }
public String getUserAgent() { return userAgent; }

View File

@ -0,0 +1,54 @@
package server.logic.ws_protocol.JSON.push;
import nl.martijndwars.webpush.Notification;
import nl.martijndwars.webpush.PushService;
import nl.martijndwars.webpush.Subscription;
import org.jose4j.lang.JoseException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import utils.config.AppConfig;
import java.security.GeneralSecurityException;
import java.security.NoSuchAlgorithmException;
public final class WebPushSender {
private static final Logger log = LoggerFactory.getLogger(WebPushSender.class);
private static volatile PushService service;
private WebPushSender() {}
private static PushService service() throws GeneralSecurityException, JoseException {
if (service != null) return service;
synchronized (WebPushSender.class) {
if (service != null) return service;
AppConfig cfg = AppConfig.getInstance();
String pub = cfg.getStringOrEmpty("webpush.vapid.public");
String priv = cfg.getStringOrEmpty("webpush.vapid.private");
String subject = cfg.getStringOrEmpty("webpush.vapid.subject");
if (pub.isBlank() || priv.isBlank() || subject.isBlank()) {
throw new IllegalStateException("webpush.vapid.* is not configured");
}
service = new PushService(pub, priv, subject);
return service;
}
}
public static boolean sendBase64Payload(String endpoint, String p256dhKey, String authKey, String payloadB64) {
try {
Subscription subscription = new Subscription(
endpoint,
new Subscription.Keys(p256dhKey, authKey)
);
Notification notification = new Notification(subscription, payloadB64);
var response = service().send(notification);
int code = response.getStatusLine().getStatusCode();
return code >= 200 && code < 300;
} catch (NoSuchAlgorithmException e) {
log.warn("WebPush crypto unsupported", e);
return false;
} catch (Exception e) {
log.warn("WebPush send failed: {}", e.getMessage());
return false;
}
}
}

View File

@ -13,5 +13,7 @@ server.info.description=
server.info.origin=
server.info.extraInfo=
# FCM (legacy HTTP)
fcm.server.key=
# Web Push (VAPID)
webpush.vapid.public=BOdoWZndZRaNe9kyUFsJ5-xEfFABXNKennAKg15Z7ycAwUIQ7yDV_sIWWYJCwJriN4g9oU-CyJPrn1U6lfxuDbI
webpush.vapid.private=3hCt7XxTvLzuoxinjT5QcKRQEBnGZHXn8ZilU31RPNE
webpush.vapid.subject=mailto:admin@shine.local