feat(dm): implement signed direct messaging with web push fallback
This commit is contained in:
parent
1ee2a1cf62
commit
62e55dbaec
@ -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__`.
|
||||||
|
|
||||||
@ -1,30 +1,20 @@
|
|||||||
/* 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('install', () => self.skipWaiting());
|
||||||
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
|
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
|
||||||
|
|
||||||
// Заполните теми же значениями, что и в shine-UI/index.html
|
self.addEventListener('push', (event) => {
|
||||||
const FIREBASE_CONFIG = {
|
let body = 'Новое сообщение SHiNE';
|
||||||
apiKey: '',
|
try {
|
||||||
authDomain: '',
|
if (event.data) {
|
||||||
projectId: '',
|
const text = event.data.text();
|
||||||
messagingSenderId: '',
|
body = text || body;
|
||||||
appId: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (FIREBASE_CONFIG.apiKey && firebase && firebase.messaging) {
|
|
||||||
if (!firebase.apps.length) {
|
|
||||||
firebase.initializeApp(FIREBASE_CONFIG);
|
|
||||||
}
|
}
|
||||||
const messaging = firebase.messaging();
|
} catch {
|
||||||
messaging.onBackgroundMessage((payload) => {
|
// ignore
|
||||||
const title = payload?.notification?.title || 'Новое сообщение';
|
}
|
||||||
const options = {
|
|
||||||
body: payload?.notification?.body || '',
|
event.waitUntil(self.registration.showNotification('SHiNE: входящее сообщение', {
|
||||||
data: payload?.data || {},
|
body,
|
||||||
};
|
tag: 'shine-direct-message',
|
||||||
self.registration.showNotification(title, options);
|
renotify: true,
|
||||||
});
|
}));
|
||||||
}
|
});
|
||||||
|
|||||||
@ -27,17 +27,9 @@
|
|||||||
<div id="toolbar-slot" class="toolbar-slot"></div>
|
<div id="toolbar-slot" class="toolbar-slot"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="modal-root"></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>
|
<script>
|
||||||
window.__SHINE_FIREBASE_CONFIG__ = {
|
// Public VAPID key for Web Push (Base64URL)
|
||||||
apiKey: '',
|
window.__SHINE_WEBPUSH_VAPID_PUBLIC_KEY__ = '';
|
||||||
authDomain: '',
|
|
||||||
projectId: '',
|
|
||||||
messagingSenderId: '',
|
|
||||||
appId: ''
|
|
||||||
};
|
|
||||||
window.__SHINE_FIREBASE_VAPID_KEY__ = '';
|
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
(function attachAppWithBuildHash() {
|
(function attachAppWithBuildHash() {
|
||||||
|
|||||||
@ -213,10 +213,22 @@ async function init() {
|
|||||||
const fromLogin = payload.fromLogin || 'unknown';
|
const fromLogin = payload.fromLogin || 'unknown';
|
||||||
const messageId = payload.messageId || '';
|
const messageId = payload.messageId || '';
|
||||||
const eventId = payload.eventId || evt?.requestId || '';
|
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 && Notification.permission === 'granted') {
|
if (added && Notification.permission === 'granted') {
|
||||||
try {
|
try {
|
||||||
new Notification(`Сообщение от ${fromLogin}`, { body: payload.text || '' });
|
new Notification(`Сообщение от ${fromLogin}`, { body: text || '' });
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
@ -226,6 +238,14 @@ async function init() {
|
|||||||
await tryAutoLogin();
|
await tryAutoLogin();
|
||||||
if (state.session.isAuthorized) {
|
if (state.session.isAuthorized) {
|
||||||
await initPwaPush({ authService });
|
await initPwaPush({ authService });
|
||||||
|
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) {
|
if (!window.location.hash) {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { directMessages } from '../mock-data.js';
|
import { directMessages } from '../mock-data.js';
|
||||||
import { addChatMessage, getChatMessages, authService } from '../state.js';
|
import { addChatMessage, getChatMessages, authService, state } from '../state.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||||
|
|
||||||
@ -66,7 +66,11 @@ export function render({ navigate, route }) {
|
|||||||
renderLog(log, chatId);
|
renderLog(log, chatId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await authService.sendDirectMessage(chatId, text);
|
await authService.sendDirectMessage({
|
||||||
|
toLogin: chatId,
|
||||||
|
text,
|
||||||
|
storagePwd: state.session.storagePwdInMemory,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
addChatMessage(chatId, `Ошибка отправки: ${e.message || 'unknown'}`);
|
addChatMessage(chatId, `Ошибка отправки: ${e.message || 'unknown'}`);
|
||||||
renderLog(log, chatId);
|
renderLog(log, chatId);
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { WsJsonClient } from './ws-client.js';
|
import { WsJsonClient } from './ws-client.js';
|
||||||
import {
|
import {
|
||||||
|
base64ToBytes,
|
||||||
bytesToBase64,
|
bytesToBase64,
|
||||||
deriveEd25519FromPassword,
|
deriveEd25519FromPassword,
|
||||||
exportEd25519PublicKeyB64,
|
exportEd25519PublicKeyB64,
|
||||||
@ -95,6 +96,27 @@ function int64Bytes(value) {
|
|||||||
return bytes;
|
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) {
|
function uint8Bytes(value) {
|
||||||
return new Uint8Array([Number(value) & 0xff]);
|
return new Uint8Array([Number(value) & 0xff]);
|
||||||
}
|
}
|
||||||
@ -354,14 +376,57 @@ export class AuthService {
|
|||||||
return this.ws.onEvent(op, handler);
|
return this.ws.onEvent(op, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertPushToken({ tokenId, token, provider = 'fcm', platform = 'web', userAgent = navigator.userAgent || '' }) {
|
async upsertPushToken({ endpoint, p256dhKey, authKey, sessionId, platform = 'web', userAgent = navigator.userAgent || '' }) {
|
||||||
const response = await this.ws.request('UpsertPushToken', { tokenId, token, provider, platform, userAgent });
|
const response = await this.ws.request('UpsertPushToken', { endpoint, p256dhKey, authKey, sessionId, platform, userAgent });
|
||||||
if (response.status !== 200) throw opError('UpsertPushToken', response);
|
if (response.status !== 200) throw opError('UpsertPushToken', response);
|
||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendDirectMessage(toLogin, text) {
|
async sendDirectMessage({ toLogin, text, storagePwd, targetSessionId = null, messageType = 1 }) {
|
||||||
const response = await this.ws.request('SendDirectMessage', { toLogin, text });
|
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);
|
if (response.status !== 200) throw opError('SendDirectMessage', response);
|
||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,50 +1,52 @@
|
|||||||
const LS_KEY = 'shine-ui-fcm-token-v1';
|
const LS_KEY = 'shine-ui-webpush-subscription-v1';
|
||||||
|
|
||||||
|
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 }) {
|
export async function initPwaPush({ authService }) {
|
||||||
if (!('serviceWorker' in navigator)) return;
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
||||||
try {
|
|
||||||
await navigator.serviceWorker.register('./firebase-messaging-sw.js');
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!window.firebase || !window.firebase.messaging) return;
|
const vapidPublicKey = window.__SHINE_WEBPUSH_VAPID_PUBLIC_KEY__ || '';
|
||||||
|
if (!vapidPublicKey) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = window.__SHINE_FIREBASE_CONFIG__ || null;
|
const registration = await navigator.serviceWorker.register('./firebase-messaging-sw.js');
|
||||||
if (!config) return;
|
|
||||||
if (!window.firebase.apps.length) {
|
|
||||||
window.firebase.initializeApp(config);
|
|
||||||
}
|
|
||||||
const messaging = window.firebase.messaging();
|
|
||||||
|
|
||||||
const permission = await Notification.requestPermission();
|
const permission = await Notification.requestPermission();
|
||||||
if (permission !== 'granted') return;
|
if (permission !== 'granted') return;
|
||||||
|
|
||||||
const vapidKey = window.__SHINE_FIREBASE_VAPID_KEY__ || '';
|
let sub = await registration.pushManager.getSubscription();
|
||||||
const token = await messaging.getToken({ vapidKey });
|
if (!sub) {
|
||||||
if (!token) return;
|
sub = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const prev = localStorage.getItem(LS_KEY);
|
const serialized = JSON.stringify(sub);
|
||||||
if (prev === token) return;
|
if (localStorage.getItem(LS_KEY) === serialized) 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) return;
|
||||||
|
|
||||||
localStorage.setItem(LS_KEY, token);
|
|
||||||
const tokenId = `tok-${new Date().toISOString().replace(/[-:.TZ]/g, '')}-${Math.random().toString(36).slice(2, 12)}`;
|
|
||||||
await authService.upsertPushToken({
|
await authService.upsertPushToken({
|
||||||
tokenId,
|
endpoint,
|
||||||
token,
|
p256dhKey,
|
||||||
provider: 'fcm',
|
authKey,
|
||||||
platform: 'web',
|
platform: 'web',
|
||||||
userAgent: navigator.userAgent || '',
|
userAgent: navigator.userAgent || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
messaging.onMessage((payload) => {
|
|
||||||
const title = payload?.notification?.title || 'Новое сообщение';
|
|
||||||
const body = payload?.notification?.body || '';
|
|
||||||
try {
|
|
||||||
new Notification(title, { body });
|
|
||||||
} catch {}
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
// silent for MVP
|
// silent for MVP
|
||||||
}
|
}
|
||||||
|
|||||||
@ -376,6 +376,45 @@ public final class DatabaseInitializer {
|
|||||||
ON user_push_tokens (login, session_id);
|
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);
|
DatabaseTriggersInstaller.createAllTriggers(st);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 --------------------
|
// -------------------- DELETE --------------------
|
||||||
|
|
||||||
public void deleteBySessionId(Connection c, String sessionId) throws SQLException {
|
public void deleteBySessionId(Connection c, String sessionId) throws SQLException {
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
@ -23,6 +23,7 @@ dependencies {
|
|||||||
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1' // json
|
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1' // json
|
||||||
|
|
||||||
implementation "org.slf4j:slf4j-api:2.0.16" // вызов логгера
|
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-config') // модуль с настройками
|
||||||
implementation project(":shine-server-log") // модуль логирования и уведомления админов
|
implementation project(":shine-server-log") // модуль логирования и уведомления админов
|
||||||
@ -40,4 +41,3 @@ java {
|
|||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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.handlers.JsonMessageHandler;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request;
|
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.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.push.WsEventSender;
|
||||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
import server.logic.ws_protocol.JSON.utils.NetIdGenerator;
|
import server.logic.ws_protocol.JSON.utils.NetIdGenerator;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.dao.ActiveSessionsDAO;
|
||||||
import shine.db.dao.DirectMessagesDAO;
|
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.dao.SolanaUsersDAO;
|
||||||
|
import shine.db.entities.ActiveSessionEntry;
|
||||||
import shine.db.entities.DirectMessageEntry;
|
import shine.db.entities.DirectMessageEntry;
|
||||||
import shine.db.entities.PushTokenEntry;
|
import shine.db.entities.SignedDirectMessageHistoryEntry;
|
||||||
import shine.db.entities.SolanaUserEntry;
|
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.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@ -29,102 +35,174 @@ import java.util.concurrent.TimeUnit;
|
|||||||
|
|
||||||
public class Net_SendDirectMessage_Handler implements JsonMessageHandler {
|
public class Net_SendDirectMessage_Handler implements JsonMessageHandler {
|
||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
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
|
@Override
|
||||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
||||||
Net_SendDirectMessage_Request req = (Net_SendDirectMessage_Request) baseRequest;
|
Net_SendDirectMessage_Request req = (Net_SendDirectMessage_Request) baseRequest;
|
||||||
if (ctx == null || !ctx.isAuthenticatedUser()) {
|
if (req.getBlobB64() == null || req.getBlobB64().isBlank()) {
|
||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "blobB64 обязателен");
|
||||||
}
|
|
||||||
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 обязательны");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String from = ctx.getLogin();
|
final byte[] raw;
|
||||||
String toRequest = req.getToLogin().trim();
|
final SignedDirectMessagePacket packet;
|
||||||
String text = req.getText().trim();
|
try {
|
||||||
|
raw = Base64.getDecoder().decode(req.getBlobB64().trim());
|
||||||
SolanaUserEntry targetUser = SolanaUsersDAO.getInstance().getByLogin(toRequest);
|
packet = SignedDirectMessagePacket.parse(raw, MAX_MESSAGE_BYTES);
|
||||||
if (targetUser == null) {
|
} catch (IllegalArgumentException ex) {
|
||||||
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден");
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный формат пакета");
|
||||||
}
|
}
|
||||||
String to = targetUser.getLogin();
|
|
||||||
|
|
||||||
if (!canSend(from, to)) {
|
SolanaUserEntry fromUser = SolanaUsersDAO.getInstance().getByLogin(packet.fromLogin);
|
||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NO_PERMISSION", "Можно писать только контактам или тем, кто уже писал вам");
|
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.UNPROCESSABLE, "BAD_DEVICE_KEY", "Некорректный deviceKey отправителя");
|
||||||
|
}
|
||||||
|
if (!Ed25519Util.verify(packet.signedBody, packet.signature64, publicKey32)) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNPROCESSABLE, "BAD_SIGNATURE", "Подпись не прошла проверку");
|
||||||
|
}
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (Math.abs(now - packet.timeMs) > REPLAY_TTL_MS) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNPROCESSABLE, "BAD_TIME_WINDOW", "Время сообщения вышло за окно 15 минут");
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean replayOk = SignedDmReplayDAO.getInstance().registerUnique(packet.fromLogin, packet.timeMs, packet.nonce, now);
|
||||||
|
if (!replayOk) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNPROCESSABLE, "REPLAY", "Повторное сообщение заблокировано");
|
||||||
}
|
}
|
||||||
|
|
||||||
String messageId = NetIdGenerator.eventId("msg");
|
String messageId = NetIdGenerator.eventId("msg");
|
||||||
|
String textForUi = new String(packet.messageBytes, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
DirectMessageEntry entry = new DirectMessageEntry();
|
DirectMessageEntry entry = new DirectMessageEntry();
|
||||||
entry.setMessageId(messageId);
|
entry.setMessageId(messageId);
|
||||||
entry.setFromLogin(from);
|
entry.setFromLogin(packet.fromLogin);
|
||||||
entry.setToLogin(to);
|
entry.setToLogin(packet.toLogin);
|
||||||
entry.setText(text);
|
entry.setText(textForUi);
|
||||||
entry.setCreatedAtMs(System.currentTimeMillis());
|
entry.setCreatedAtMs(now);
|
||||||
DirectMessagesDAO.getInstance().insert(entry);
|
DirectMessagesDAO.getInstance().insert(entry);
|
||||||
|
|
||||||
Set<ConnectionContext> activeSessions = ActiveConnectionsRegistry.getInstance().getByLogin(to);
|
SignedDirectMessageHistoryEntry history = new SignedDirectMessageHistoryEntry();
|
||||||
List<PushTokenEntry> tokens = PushTokensDAO.getInstance().listByLogin(to);
|
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;
|
DeliveryResult delivery = deliver(packet, req.getBlobB64().trim(), messageId, now);
|
||||||
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++;
|
|
||||||
}
|
|
||||||
|
|
||||||
Net_SendDirectMessage_Response resp = new Net_SendDirectMessage_Response();
|
Net_SendDirectMessage_Response resp = new Net_SendDirectMessage_Response();
|
||||||
resp.setOp(req.getOp());
|
resp.setOp(req.getOp());
|
||||||
resp.setRequestId(req.getRequestId());
|
resp.setRequestId(req.getRequestId());
|
||||||
resp.setStatus(WireCodes.Status.OK);
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
resp.setMessageId(messageId);
|
resp.setMessageId(messageId);
|
||||||
resp.setDeliveredWsSessions(wsDelivered);
|
resp.setDeliveredWsSessions(delivery.wsDelivered);
|
||||||
resp.setDeliveredFcmSessions(fcmDelivered);
|
resp.setDeliveredWebPushSessions(delivery.webPushDelivered);
|
||||||
|
resp.setSessionNotFound(delivery.sessionNotFound);
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean canSend(String from, String to) {
|
private DeliveryResult deliver(SignedDirectMessagePacket packet, String blobB64, String messageId, long createdAtMs) throws Exception {
|
||||||
return from != null && !from.isBlank() && to != null && !to.isBlank();
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.messages.entyties.Net_UpsertPushToken_Response;
|
||||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
import server.logic.ws_protocol.WireCodes;
|
||||||
import shine.db.dao.PushTokensDAO;
|
import shine.db.dao.ActiveSessionsDAO;
|
||||||
import shine.db.entities.PushTokenEntry;
|
|
||||||
|
|
||||||
public class Net_UpsertPushToken_Handler implements JsonMessageHandler {
|
public class Net_UpsertPushToken_Handler implements JsonMessageHandler {
|
||||||
@Override
|
@Override
|
||||||
@ -18,27 +17,27 @@ public class Net_UpsertPushToken_Handler implements JsonMessageHandler {
|
|||||||
if (ctx == null || !ctx.isAuthenticatedUser()) {
|
if (ctx == null || !ctx.isAuthenticatedUser()) {
|
||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
|
||||||
}
|
}
|
||||||
if (req.getTokenId() == null || req.getTokenId().isBlank() || req.getToken() == null || req.getToken().isBlank()) {
|
if (req.getEndpoint() == null || req.getEndpoint().isBlank()
|
||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "tokenId и token обязательны");
|
|| 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();
|
String sessionId = (req.getSessionId() == null || req.getSessionId().isBlank()) ? ctx.getSessionId() : req.getSessionId().trim();
|
||||||
e.setTokenId(req.getTokenId().trim());
|
long now = System.currentTimeMillis();
|
||||||
e.setLogin(ctx.getLogin());
|
ActiveSessionsDAO.getInstance().updatePushSubscription(
|
||||||
e.setSessionId((req.getSessionId() == null || req.getSessionId().isBlank()) ? ctx.getSessionId() : req.getSessionId().trim());
|
sessionId,
|
||||||
e.setProvider(req.getProvider() == null || req.getProvider().isBlank() ? "fcm" : req.getProvider().trim());
|
req.getEndpoint().trim(),
|
||||||
e.setToken(req.getToken().trim());
|
req.getP256dhKey().trim(),
|
||||||
e.setPlatform(req.getPlatform());
|
req.getAuthKey().trim()
|
||||||
e.setUserAgent(req.getUserAgent());
|
);
|
||||||
e.setUpdatedAtMs(System.currentTimeMillis());
|
|
||||||
PushTokensDAO.getInstance().upsert(e);
|
|
||||||
|
|
||||||
Net_UpsertPushToken_Response resp = new Net_UpsertPushToken_Response();
|
Net_UpsertPushToken_Response resp = new Net_UpsertPushToken_Response();
|
||||||
resp.setOp(req.getOp());
|
resp.setOp(req.getOp());
|
||||||
resp.setRequestId(req.getRequestId());
|
resp.setRequestId(req.getRequestId());
|
||||||
resp.setStatus(WireCodes.Status.OK);
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
resp.setTokenId(e.getTokenId());
|
resp.setTokenId(sessionId);
|
||||||
resp.setUpdatedAtMs(e.getUpdatedAtMs());
|
resp.setUpdatedAtMs(now);
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,11 +3,8 @@ package server.logic.ws_protocol.JSON.messages.entyties;
|
|||||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
public class Net_SendDirectMessage_Request extends Net_Request {
|
public class Net_SendDirectMessage_Request extends Net_Request {
|
||||||
private String toLogin;
|
private String blobB64;
|
||||||
private String text;
|
|
||||||
|
|
||||||
public String getToLogin() { return toLogin; }
|
public String getBlobB64() { return blobB64; }
|
||||||
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
public void setBlobB64(String blobB64) { this.blobB64 = blobB64; }
|
||||||
public String getText() { return text; }
|
|
||||||
public void setText(String text) { this.text = text; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,12 +5,15 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
|||||||
public class Net_SendDirectMessage_Response extends Net_Response {
|
public class Net_SendDirectMessage_Response extends Net_Response {
|
||||||
private String messageId;
|
private String messageId;
|
||||||
private int deliveredWsSessions;
|
private int deliveredWsSessions;
|
||||||
private int deliveredFcmSessions;
|
private int deliveredWebPushSessions;
|
||||||
|
private boolean sessionNotFound;
|
||||||
|
|
||||||
public String getMessageId() { return messageId; }
|
public String getMessageId() { return messageId; }
|
||||||
public void setMessageId(String messageId) { this.messageId = messageId; }
|
public void setMessageId(String messageId) { this.messageId = messageId; }
|
||||||
public int getDeliveredWsSessions() { return deliveredWsSessions; }
|
public int getDeliveredWsSessions() { return deliveredWsSessions; }
|
||||||
public void setDeliveredWsSessions(int deliveredWsSessions) { this.deliveredWsSessions = deliveredWsSessions; }
|
public void setDeliveredWsSessions(int deliveredWsSessions) { this.deliveredWsSessions = deliveredWsSessions; }
|
||||||
public int getDeliveredFcmSessions() { return deliveredFcmSessions; }
|
public int getDeliveredWebPushSessions() { return deliveredWebPushSessions; }
|
||||||
public void setDeliveredFcmSessions(int deliveredFcmSessions) { this.deliveredFcmSessions = deliveredFcmSessions; }
|
public void setDeliveredWebPushSessions(int deliveredWebPushSessions) { this.deliveredWebPushSessions = deliveredWebPushSessions; }
|
||||||
|
public boolean isSessionNotFound() { return sessionNotFound; }
|
||||||
|
public void setSessionNotFound(boolean sessionNotFound) { this.sessionNotFound = sessionNotFound; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,21 +3,21 @@ package server.logic.ws_protocol.JSON.messages.entyties;
|
|||||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
public class Net_UpsertPushToken_Request extends Net_Request {
|
public class Net_UpsertPushToken_Request extends Net_Request {
|
||||||
private String tokenId;
|
|
||||||
private String sessionId;
|
private String sessionId;
|
||||||
private String provider;
|
private String endpoint;
|
||||||
private String token;
|
private String p256dhKey;
|
||||||
|
private String authKey;
|
||||||
private String platform;
|
private String platform;
|
||||||
private String userAgent;
|
private String userAgent;
|
||||||
|
|
||||||
public String getTokenId() { return tokenId; }
|
|
||||||
public void setTokenId(String tokenId) { this.tokenId = tokenId; }
|
|
||||||
public String getSessionId() { return sessionId; }
|
public String getSessionId() { return sessionId; }
|
||||||
public void setSessionId(String sessionId) { this.sessionId = sessionId; }
|
public void setSessionId(String sessionId) { this.sessionId = sessionId; }
|
||||||
public String getProvider() { return provider; }
|
public String getEndpoint() { return endpoint; }
|
||||||
public void setProvider(String provider) { this.provider = provider; }
|
public void setEndpoint(String endpoint) { this.endpoint = endpoint; }
|
||||||
public String getToken() { return token; }
|
public String getP256dhKey() { return p256dhKey; }
|
||||||
public void setToken(String token) { this.token = token; }
|
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 String getPlatform() { return platform; }
|
||||||
public void setPlatform(String platform) { this.platform = platform; }
|
public void setPlatform(String platform) { this.platform = platform; }
|
||||||
public String getUserAgent() { return userAgent; }
|
public String getUserAgent() { return userAgent; }
|
||||||
|
|||||||
@ -0,0 +1,57 @@
|
|||||||
|
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.nio.charset.StandardCharsets;
|
||||||
|
import java.security.GeneralSecurityException;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
byte[] payloadBytes = Base64.getDecoder().decode(payloadB64);
|
||||||
|
Notification notification = new Notification(subscription, payloadBytes);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,5 +13,7 @@ server.info.description=
|
|||||||
server.info.origin=
|
server.info.origin=
|
||||||
server.info.extraInfo=
|
server.info.extraInfo=
|
||||||
|
|
||||||
# FCM (legacy HTTP)
|
# Web Push (VAPID)
|
||||||
fcm.server.key=
|
webpush.vapid.public=
|
||||||
|
webpush.vapid.private=
|
||||||
|
webpush.vapid.subject=mailto:admin@shine.local
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user