feat(dm): implement signed direct messaging with web push fallback

This commit is contained in:
ai5590
2026-04-12 19:34:55 +03:00
parent 1ee2a1cf62
commit 62e55dbaec
21 changed files with 875 additions and 189 deletions
+22 -2
View File
@@ -213,10 +213,22 @@ 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 && Notification.permission === 'granted') {
try {
new Notification(`Сообщение от ${fromLogin}`, { body: payload.text || '' });
new Notification(`Сообщение от ${fromLogin}`, { body: text || '' });
} catch {}
}
if (eventId) {
@@ -226,6 +238,14 @@ async function init() {
await tryAutoLogin();
if (state.session.isAuthorized) {
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) {
+6 -2
View File
@@ -1,6 +1,6 @@
import { renderHeader } from '../components/header.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: 'Чат' };
@@ -66,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);
+69 -4
View File
@@ -1,5 +1,6 @@
import { WsJsonClient } from './ws-client.js';
import {
base64ToBytes,
bytesToBase64,
deriveEd25519FromPassword,
exportEd25519PublicKeyB64,
@@ -95,6 +96,27 @@ 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]);
}
@@ -354,14 +376,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 || {};
}
+35 -33
View File
@@ -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 }) {
if (!('serviceWorker' in navigator)) return;
try {
await navigator.serviceWorker.register('./firebase-messaging-sw.js');
} catch {
return;
}
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
if (!window.firebase || !window.firebase.messaging) return;
const vapidPublicKey = window.__SHINE_WEBPUSH_VAPID_PUBLIC_KEY__ || '';
if (!vapidPublicKey) 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');
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
const vapidKey = window.__SHINE_FIREBASE_VAPID_KEY__ || '';
const token = await messaging.getToken({ vapidKey });
if (!token) return;
let sub = await registration.pushManager.getSubscription();
if (!sub) {
sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
}
const prev = localStorage.getItem(LS_KEY);
if (prev === token) return;
const serialized = JSON.stringify(sub);
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({
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 {}
});
} catch {
// silent for MVP
}