diff --git a/shine-UI/firebase-messaging-sw.js b/shine-UI/firebase-messaging-sw.js index ccec2c2..cc6f322 100644 --- a/shine-UI/firebase-messaging-sw.js +++ b/shine-UI/firebase-messaging-sw.js @@ -12,26 +12,42 @@ async function broadcastToClients(payload) { } self.addEventListener('push', (event) => { - let body = 'Новое сообщение SHiNE'; + let body = ''; let rawText = ''; + let kind = ''; + let fromLogin = ''; try { if (event.data) { const text = event.data.text(); rawText = text || ''; - body = rawText || body; + try { + const json = JSON.parse(rawText || '{}'); + kind = String(json.kind || ''); + body = String(json.text || ''); + fromLogin = String(json.fromLogin || ''); + } catch { + body = rawText || ''; + } } } catch { // ignore } - event.waitUntil(Promise.all([ - self.registration.showNotification('SHiNE: входящее сообщение', { - body, + const shouldNotify = kind === 'new_message' || (!kind && body); + const notifyPromise = shouldNotify + ? self.registration.showNotification('SHiNE: входящее сообщение', { + body: body || (fromLogin ? `Вам пришло сообщение от ${fromLogin}` : 'Вам пришло сообщение'), tag: 'shine-direct-message', renotify: true, - }), + }) + : Promise.resolve(); + + event.waitUntil(Promise.all([ + notifyPromise, broadcastToClients({ + kind, body, + fromLogin, rawText, receivedAt: Date.now(), }), diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 26c379b..076846a 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -7,13 +7,15 @@ import { authService, addAppLogEntry, authorizeSession, + hydrateMessagesFromStore, isSessionInvalidError, refreshSessions, setSessionAuthorizedHandler, setSessionResetHandler, state, terminateCurrentSession, - addIncomingMessage, + addSignedMessageToChat, + markOutgoingReadByBaseKey, setContacts, } from './state.js'; @@ -338,48 +340,94 @@ async function init() { await terminateCurrentSession({ infoMessage: 'Сессия закрыта с другого устройства.' }); }); - authService.onEvent('IncomingDirectMessage', async (evt) => { + authService.onEvent('SignedMessageArrived', async (evt) => { const payload = evt?.payload || {}; - const fromLogin = payload.fromLogin || 'unknown'; - const messageId = payload.messageId || ''; - const eventId = payload.eventId || evt?.requestId || ''; - 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) { + const messageKey = String(payload.messageKey || '').trim(); + const blobB64 = String(payload.blobB64 || '').trim(); + if (!messageKey || !blobB64) return; + + let parsed; + try { + parsed = authService.parseSignedMessageBlob(blobB64); + } catch (error) { addAppLogEntry({ - level: 'info', - source: 'incoming-dm', - message: `Входящее сообщение от ${fromLogin}`, - details: { messageId, text }, + level: 'warn', + source: 'signed-dm', + message: 'Не удалось распарсить входящий signed message', + details: { messageKey, error: error?.message || 'unknown' }, }); + return; } - if (added && Notification.permission === 'granted') { - try { - new Notification(`Сообщение от ${fromLogin}`, { body: text || '' }); - } catch {} - } - if (eventId) { - try { - await authService.ackIncomingMessage(eventId, messageId); - } catch (error) { + + const myLogin = String(state.session.login || '').trim().toLowerCase(); + const fromLogin = parsed.fromLogin || ''; + const toLogin = parsed.toLogin || ''; + const chatId = String(fromLogin || '').toLowerCase() === myLogin ? toLogin : fromLogin; + const messageType = Number(parsed.messageType || 0); + const text = (messageType === 1 || messageType === 2) + ? new TextDecoder().decode(parsed.payloadBytes || new Uint8Array(0)) + : ''; + + if (messageType === 1 || messageType === 2) { + const isIncomingForCurrent = messageType === 1; + const added = addSignedMessageToChat({ + chatId, + messageKey, + baseKey: parsed.baseKey, + from: isIncomingForCurrent ? 'in' : 'out', + text, + messageType, + unread: isIncomingForCurrent, + rawBlobB64: blobB64, + }); + if (added) { addAppLogEntry({ - level: 'warn', - source: 'incoming-dm', - message: 'Не удалось отправить ACK на входящее сообщение', - details: { eventId, messageId, error: error?.message || 'unknown' }, + level: 'info', + source: 'signed-dm', + message: isIncomingForCurrent + ? `Новое входящее сообщение от ${fromLogin}` + : `Синхронизирована исходящая копия в чате ${chatId}`, + details: { messageKey, baseKey: parsed.baseKey, messageType }, }); } + if (added && isIncomingForCurrent && Notification.permission === 'granted' && !payload.backlog) { + try { + new Notification(`Сообщение от ${fromLogin}`, { body: text || '' }); + } catch {} + } + } else if (messageType === 3 || messageType === 4) { + const refBaseKey = String(payload.receiptRefBaseKey || '').trim(); + if (refBaseKey) { + markOutgoingReadByBaseKey(refBaseKey); + } else { + try { + const ref = authService.parseReadReceiptPayload(parsed.payloadBytes); + const fallbackRefBase = `${ref.refFromLogin}|${ref.refToLogin}|${ref.refTimeMs}|${ref.refNonce}`; + markOutgoingReadByBaseKey(fallbackRefBase); + } catch {} + } + addAppLogEntry({ + level: 'info', + source: 'signed-dm', + message: 'Получено подтверждение прочтения', + details: { messageKey, baseKey: parsed.baseKey, messageType }, + }); + } + + try { + await authService.ackSessionDelivery(messageKey); + } catch (error) { + addAppLogEntry({ + level: 'warn', + source: 'signed-dm', + message: 'Не удалось отправить ACK доставки по сессии', + details: { messageKey, error: error?.message || 'unknown' }, + }); + } + + const pageId = getRoute().pageId || ''; + if (pageId === 'chat-view' || pageId === 'messages-list') { + renderApp(); } }); @@ -392,6 +440,7 @@ async function init() { }); await tryAutoLogin(); + await hydrateMessagesFromStore(); await ensureSessionRuntimeStarted(); if (!window.location.hash) { diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index 8f7e1a3..38f227b 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -1,20 +1,59 @@ import { renderHeader } from '../components/header.js'; import { directMessages } from '../mock-data.js'; -import { addAppLogEntry, addChatMessage, getChatMessages, authService, state } from '../state.js'; +import { + addAppLogEntry, + addChatMessage, + addOutgoingPendingMessage, + getChatMessages, + markChatRead, + markOutgoingSent, + authService, + state, +} from '../state.js'; import { startOutgoingCall, hangupActiveCall } from '../services/call-service.js'; export const pageMeta = { id: 'chat-view', title: 'Чат' }; +function parseBaseKey(baseKey) { + const raw = String(baseKey || '').trim(); + const parts = raw.split('|'); + if (parts.length < 4) return null; + const fromLogin = parts[0] || ''; + const toLogin = parts[1] || ''; + const timeMs = Number(parts[2] || 0); + const nonce = Number(parts[3] || 0); + if (!fromLogin || !toLogin || !Number.isFinite(timeMs) || !Number.isFinite(nonce)) return null; + return { fromLogin, toLogin, timeMs, nonce }; +} + function renderLog(list, chatId) { list.innerHTML = ''; const messages = getChatMessages(chatId); + let unreadSeparatorInserted = false; messages.forEach((msg) => { + if (!unreadSeparatorInserted && msg?.from === 'in' && msg?.unread) { + const sep = document.createElement('div'); + sep.className = 'meta-muted'; + sep.style.textAlign = 'center'; + sep.style.margin = '8px 0'; + sep.textContent = 'Новые сообщения'; + list.append(sep); + unreadSeparatorInserted = true; + } + const bubble = document.createElement('div'); bubble.className = `bubble ${msg.from}`; - bubble.textContent = msg.text; + let text = msg.text || ''; + if (msg.from === 'out') { + if (msg.secondTick) text += ' ✓✓'; + else if (msg.firstTick) text += ' ✓'; + else text += ' …'; + } + bubble.textContent = text; list.append(bubble); }); list.scrollTop = list.scrollHeight; + markChatRead(chatId); } export function render({ navigate, route }) { @@ -27,6 +66,7 @@ export function render({ navigate, route }) { const screen = document.createElement('section'); screen.className = 'stack'; + const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase()); screen.append( renderHeader({ @@ -60,6 +100,37 @@ export function render({ navigate, route }) { }) ); + if (!isKnownContact) { + const card = document.createElement('div'); + card.className = 'card'; + const btn = document.createElement('button'); + btn.className = 'secondary-btn'; + btn.type = 'button'; + btn.textContent = 'Добавить в контакты'; + btn.addEventListener('click', async () => { + try { + await authService.addCloseFriend(chatId); + state.contacts = [...new Set([...(state.contacts || []), chatId])]; + addAppLogEntry({ + level: 'info', + source: 'contacts', + message: `Пользователь ${chatId} добавлен в контакты`, + }); + btn.disabled = true; + btn.textContent = 'Добавлено'; + } catch (e) { + addAppLogEntry({ + level: 'warn', + source: 'contacts', + message: 'Не удалось добавить пользователя в контакты', + details: { login: chatId, error: e?.message || 'unknown' }, + }); + } + }); + card.append(btn); + screen.append(card); + } + const wrap = document.createElement('div'); wrap.className = 'chat-wrap'; @@ -79,7 +150,7 @@ export function render({ navigate, route }) { const text = input.value.trim(); if (!text) return; - addChatMessage(chatId, text); + const tempId = addOutgoingPendingMessage(chatId, text); input.value = ''; renderLog(log, chatId); @@ -90,16 +161,20 @@ export function render({ navigate, route }) { text, storagePwd: state.session.storagePwdInMemory, }); + markOutgoingSent(tempId, { + messageKey: result?.outgoingKey || '', + baseKey: result?.baseKey || result?.localBaseKey || '', + }); + renderLog(log, chatId); addAppLogEntry({ level: 'info', source: 'outgoing-dm', message: `Сообщение отправлено для ${chatId}`, details: { toLogin: chatId, - messageId: result?.messageId || '', + messageId: result?.outgoingKey || '', deliveredWsSessions: Number(result?.deliveredWsSessions || 0), deliveredWebPushSessions: Number(result?.deliveredWebPushSessions || 0), - sessionNotFound: Boolean(result?.sessionNotFound), }, }); } catch (e) { @@ -118,7 +193,37 @@ export function render({ navigate, route }) { }); renderLog(log, chatId); + void sendReadReceiptsForVisible(); wrap.append(log, form); screen.append(wrap); return screen; } + async function sendReadReceiptsForVisible() { + const pending = getChatMessages(chatId) + .filter((row) => row?.from === 'in' && Number(row?.messageType) === 1 && !row?.readReceiptSent) + .slice(0, 50); + for (const row of pending) { + const ref = parseBaseKey(row.baseKey); + if (!ref) continue; + try { + await authService.sendReadReceipt({ + login: state.session.login, + toLogin: ref.fromLogin, + storagePwd: state.session.storagePwdInMemory, + refToLogin: ref.toLogin, + refFromLogin: ref.fromLogin, + refTimeMs: ref.timeMs, + refNonce: ref.nonce, + refType: 1, + }); + row.readReceiptSent = true; + } catch (e) { + addAppLogEntry({ + level: 'warn', + source: 'read-receipt', + message: 'Не удалось отправить подтверждение прочтения', + details: { chatId, messageKey: row.messageKey || '', error: e?.message || 'unknown' }, + }); + } + } + } diff --git a/shine-UI/js/pages/messages-list.js b/shine-UI/js/pages/messages-list.js index 72cd2df..58e3e65 100644 --- a/shine-UI/js/pages/messages-list.js +++ b/shine-UI/js/pages/messages-list.js @@ -1,6 +1,6 @@ import { renderHeader } from '../components/header.js'; import { directMessages } from '../mock-data.js'; -import { getChatMessages } from '../state.js'; +import { getChatMessages, setContacts, state } from '../state.js'; import { loadCurrentRelations } from '../services/user-connections.js'; export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' }; @@ -30,6 +30,7 @@ export function render({ navigate }) {
${item.name} + ${item.notInContacts ? 'не в контактах' : ''}

${item.lastMessage}

@@ -46,32 +47,57 @@ export function render({ navigate }) { try { const relations = await loadCurrentRelations(); const contacts = relations.outContacts || []; + setContacts(contacts); 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 contactRows = contacts.map((login) => { const preview = directMessages.find((item) => item.id.toLowerCase() === login.toLowerCase()); const chat = getChatMessages(login); const lastChat = chat[chat.length - 1]; + const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length; return { id: login, initials: (login[0] || '?').toUpperCase(), name: preview?.name || login, lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.', time: preview?.time || '—', - unread: Number(preview?.unread || 0), + unread, + notInContacts: false, }; }); + const allChatIds = Object.keys(state.chats || {}) + .filter((id) => id && id.toLowerCase() !== String(state.session.login || '').toLowerCase()) + .filter((id) => (getChatMessages(id) || []).length > 0); + + const contactKeys = new Set(contacts.map((x) => String(x || '').toLowerCase())); + const extraRows = allChatIds + .filter((login) => !contactKeys.has(String(login || '').toLowerCase())) + .map((login) => { + const chat = getChatMessages(login); + const lastChat = chat[chat.length - 1]; + const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length; + return { + id: login, + initials: (login[0] || '?').toUpperCase(), + name: login, + lastMessage: lastChat?.text || 'Диалог пока пуст.', + time: 'сейчас', + unread, + notInContacts: true, + }; + }); + + const rows = [...contactRows, ...extraRows]; + if (!rows.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; + } + rows.forEach((item) => list.append(renderRow(item))); status.className = 'status-line is-available'; status.textContent = `Загружено диалогов: ${rows.length}`; diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index bccd3c5..48d4930 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -200,6 +200,114 @@ function uint8Bytes(value) { return new Uint8Array([Number(value) & 0xff]); } +const DM2_PREFIX = utf8Bytes('SHiNE_dm2'); +const DM2_TYPE_INCOMING = 1; +const DM2_TYPE_OUTGOING_COPY = 2; +const DM2_TYPE_READ_INCOMING = 3; +const DM2_TYPE_READ_OUTGOING_COPY = 4; + +function ensureAsciiBytes(value, field, min = 1, max = 60) { + const text = String(value || '').trim(); + const bytes = utf8Bytes(text); + if (bytes.length < min || bytes.length > max) { + throw new Error(`${field} должен быть ${min}..${max} ASCII-символов`); + } + for (let i = 0; i < bytes.length; i += 1) { + const code = bytes[i]; + if (code < 0x20 || code > 0x7e) throw new Error(`${field} должен быть ASCII`); + } + return bytes; +} + +function dm2BaseKey({ toLogin, fromLogin, timeMs, nonce }) { + return `${fromLogin}|${toLogin}|${Number(timeMs)}|${Number(nonce)}`; +} + +function dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType }) { + return `${dm2BaseKey({ toLogin, fromLogin, timeMs, nonce })}|${Number(messageType)}`; +} + +function buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce, refType }) { + const toBytes = ensureAsciiBytes(refToLogin, 'receipt.refToLogin'); + const fromBytes = ensureAsciiBytes(refFromLogin, 'receipt.refFromLogin'); + return concatBytes( + uint8Bytes(toBytes.length), toBytes, + uint8Bytes(fromBytes.length), fromBytes, + uint64Bytes(refTimeMs), + uint32Bytes(refNonce), + uint16Bytes(refType), + ); +} + +function parseSignedMessageBlockBytes(bytes) { + if (!(bytes instanceof Uint8Array)) throw new Error('Expected Uint8Array'); + let o = 0; + const read = (n) => { + if (o + n > bytes.length) throw new Error('BAD_LEN'); + const out = bytes.slice(o, o + n); + o += n; + return out; + }; + const readU8 = () => read(1)[0]; + const readU16 = () => { + const part = read(2); + const view = new DataView(part.buffer, part.byteOffset, 2); + return view.getUint16(0, false); + }; + const readU32 = () => { + const part = read(4); + const view = new DataView(part.buffer, part.byteOffset, 4); + return view.getUint32(0, false); + }; + const readU64 = () => { + const part = read(8); + const view = new DataView(part.buffer, part.byteOffset, 8); + return Number(view.getBigUint64(0, false)); + }; + const readAscii = () => { + const len = readU8(); + const part = read(len); + const text = new TextDecoder().decode(part); + for (let i = 0; i < part.length; i += 1) { + const c = part[i]; + if (c < 0x20 || c > 0x7e) throw new Error('BAD_ASCII'); + } + return text; + }; + + const prefix = read(DM2_PREFIX.length); + for (let i = 0; i < DM2_PREFIX.length; i += 1) { + if (prefix[i] !== DM2_PREFIX[i]) throw new Error('BAD_PREFIX'); + } + + const toLogin = readAscii(); + const fromLogin = readAscii(); + const timeMs = readU64(); + const nonce = readU32(); + const messageType = readU16(); + const payloadLen = readU16(); + const payloadBytes = read(payloadLen); + const signatureBytes = read(64); + if (o !== bytes.length) throw new Error('BAD_LEN'); + + const signedBody = bytes.slice(0, bytes.length - 64); + const baseKey = dm2BaseKey({ toLogin, fromLogin, timeMs, nonce }); + const messageKey = dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType }); + return { + toLogin, + fromLogin, + timeMs, + nonce, + messageType, + payloadBytes, + signatureBytes, + signedBody, + rawBytes: bytes, + baseKey, + messageKey, + }; +} + function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, key, value }) { const keyBytes = utf8Bytes(String(key || '')); const valueBytes = utf8Bytes(String(value || '')); @@ -1118,11 +1226,18 @@ export class AuthService { return response.payload || {}; } - async sendDirectMessage({ login, toLogin, text, storagePwd, targetSessionId = null, messageType = 1 }) { + async buildSignedDm2Block({ + login, + toLogin, + storagePwd, + timeMs, + nonce, + messageType, + payloadBytes, + }) { const cleanFromLogin = String(login || '').trim(); const cleanToLogin = String(toLogin || '').trim(); - const cleanText = String(text || ''); - if (!cleanFromLogin || !cleanToLogin || !cleanText) throw new Error('Не передан login/toLogin/text'); + if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin'); if (!storagePwd) throw new Error('Не передан storagePwd для подписи'); const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd); @@ -1130,46 +1245,147 @@ export class AuthService { 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(cleanFromLogin); - 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 toBytes = ensureAsciiBytes(cleanToLogin, 'toLogin'); + const fromBytes = ensureAsciiBytes(cleanFromLogin, 'fromLogin'); + if (!(payloadBytes instanceof Uint8Array) || payloadBytes.length < 1 || payloadBytes.length > 4096) { + throw new Error('payload должен быть 1..4096 байт'); } - const bodyBytes = utf8Bytes(cleanText); const preimage = concatBytes( - prefix, - version, + DM2_PREFIX, uint8Bytes(toBytes.length), toBytes, uint8Bytes(fromBytes.length), fromBytes, - uint64Bytes(Date.now()), - uint32Bytes(Math.floor(Math.random() * 0x100000000)), + uint64Bytes(timeMs), + uint32Bytes(nonce), uint16Bytes(messageType), - uint8Bytes(mode), - mode === 1 ? concatBytes(uint8Bytes(targetBytes.length), targetBytes) : new Uint8Array(0), - uint16Bytes(bodyBytes.length), - bodyBytes, + uint16Bytes(payloadBytes.length), + payloadBytes, ); const signature = await signBytes(privateKey, preimage); - const packet = concatBytes(preimage, signature); - const blobB64 = bytesToBase64(packet); + return concatBytes(preimage, signature); + } - const response = await this.ws.request('SendDirectMessage', { blobB64 }); - if (response.status !== 200) throw opError('SendDirectMessage', response); + parseSignedMessageBlob(blobB64) { + const bytes = base64ToBytes(String(blobB64 || '').trim()); + return parseSignedMessageBlockBytes(bytes); + } + + parseReadReceiptPayload(payloadBytes) { + if (!(payloadBytes instanceof Uint8Array)) throw new Error('Expected Uint8Array'); + let o = 0; + const read = (n) => { + if (o + n > payloadBytes.length) throw new Error('BAD_RECEIPT_LEN'); + const out = payloadBytes.slice(o, o + n); + o += n; + return out; + }; + const readU8 = () => read(1)[0]; + const readU16 = () => { + const part = read(2); + return new DataView(part.buffer, part.byteOffset, 2).getUint16(0, false); + }; + const readU32 = () => { + const part = read(4); + return new DataView(part.buffer, part.byteOffset, 4).getUint32(0, false); + }; + const readU64 = () => { + const part = read(8); + return Number(new DataView(part.buffer, part.byteOffset, 8).getBigUint64(0, false)); + }; + const readAscii = () => { + const len = readU8(); + const part = read(len); + return new TextDecoder().decode(part); + }; + const refToLogin = readAscii(); + const refFromLogin = readAscii(); + const refTimeMs = readU64(); + const refNonce = readU32(); + const refType = readU16(); + if (o !== payloadBytes.length) throw new Error('BAD_RECEIPT_LEN'); + return { refToLogin, refFromLogin, refTimeMs, refNonce, refType }; + } + + async sendMessagePair({ incomingBlobB64, outgoingBlobB64 }) { + const response = await this.ws.request('SendMessagePair', { incomingBlobB64, outgoingBlobB64 }); + if (response.status !== 200) throw opError('SendMessagePair', response); return response.payload || {}; } - async ackIncomingMessage(eventId, messageId) { - const response = await this.ws.request('AckIncomingMessage', { eventId, messageId }); - if (response.status !== 200) throw opError('AckIncomingMessage', response); + async sendDirectMessage({ login, toLogin, text, storagePwd }) { + const cleanFromLogin = String(login || '').trim(); + const cleanToLogin = String(toLogin || '').trim(); + const cleanText = String(text || ''); + if (!cleanFromLogin || !cleanToLogin || !cleanText) throw new Error('Не передан login/toLogin/text'); + + const timeMs = Date.now(); + const nonce = Math.floor(Math.random() * 0x100000000); + const incomingPayload = utf8Bytes(cleanText); + const outgoingPayload = utf8Bytes(cleanText); + + const incomingBlock = await this.buildSignedDm2Block({ + login: cleanFromLogin, + toLogin: cleanToLogin, + storagePwd, + timeMs, + nonce, + messageType: DM2_TYPE_INCOMING, + payloadBytes: incomingPayload, + }); + const outgoingBlock = await this.buildSignedDm2Block({ + login: cleanFromLogin, + toLogin: cleanToLogin, + storagePwd, + timeMs, + nonce, + messageType: DM2_TYPE_OUTGOING_COPY, + payloadBytes: outgoingPayload, + }); + + const payload = await this.sendMessagePair({ + incomingBlobB64: bytesToBase64(incomingBlock), + outgoingBlobB64: bytesToBase64(outgoingBlock), + }); + return { + ...payload, + localIncomingBlobB64: bytesToBase64(incomingBlock), + localOutgoingBlobB64: bytesToBase64(outgoingBlock), + localBaseKey: dm2BaseKey({ toLogin: cleanToLogin, fromLogin: cleanFromLogin, timeMs, nonce }), + }; + } + + async sendReadReceipt({ login, toLogin, storagePwd, refToLogin, refFromLogin, refTimeMs, refNonce, refType = DM2_TYPE_INCOMING }) { + const timeMs = Date.now(); + const nonce = Math.floor(Math.random() * 0x100000000); + const payload = buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce, refType }); + + const type3 = await this.buildSignedDm2Block({ + login, + toLogin, + storagePwd, + timeMs, + nonce, + messageType: DM2_TYPE_READ_INCOMING, + payloadBytes: payload, + }); + const type4 = await this.buildSignedDm2Block({ + login, + toLogin, + storagePwd, + timeMs, + nonce, + messageType: DM2_TYPE_READ_OUTGOING_COPY, + payloadBytes: payload, + }); + return this.sendMessagePair({ + incomingBlobB64: bytesToBase64(type3), + outgoingBlobB64: bytesToBase64(type4), + }); + } + + async ackSessionDelivery(messageKey) { + const response = await this.ws.request('AckSessionDelivery', { messageKey }); + if (response.status !== 200) throw opError('AckSessionDelivery', response); return response.payload || {}; } diff --git a/shine-UI/js/services/message-store.js b/shine-UI/js/services/message-store.js new file mode 100644 index 0000000..70e42ff --- /dev/null +++ b/shine-UI/js/services/message-store.js @@ -0,0 +1,50 @@ +const DB_NAME = 'shine-ui-messages-v1'; +const DB_VERSION = 1; +const STORE_MESSAGES = 'messages'; + +function openDb() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_MESSAGES)) { + const store = db.createObjectStore(STORE_MESSAGES, { keyPath: 'messageKey' }); + store.createIndex('by_chat', 'chatId', { unique: false }); + store.createIndex('by_ts', 'ts', { unique: false }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error('IndexedDB open failed')); + }); +} + +async function withStore(mode, callback) { + const db = await openDb(); + try { + return await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_MESSAGES, mode); + const store = tx.objectStore(STORE_MESSAGES); + const result = callback(store, tx); + tx.oncomplete = () => resolve(result); + tx.onerror = () => reject(tx.error || new Error('IndexedDB transaction failed')); + tx.onabort = () => reject(tx.error || new Error('IndexedDB transaction aborted')); + }); + } finally { + db.close(); + } +} + +export async function putStoredMessage(record) { + if (!record || !record.messageKey) return; + await withStore('readwrite', (store) => { + store.put(record); + }); +} + +export async function listStoredMessages() { + return withStore('readonly', (store) => new Promise((resolve, reject) => { + const req = store.getAll(); + req.onsuccess = () => resolve(Array.isArray(req.result) ? req.result : []); + req.onerror = () => reject(req.error || new Error('IndexedDB getAll failed')); + })); +} diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index e26ac67..f7301b8 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -1,6 +1,7 @@ import { chatMessages, wallet } from './mock-data.js'; import { AuthService } from './services/auth-service.js'; import { clearClientAuthData } from './services/key-vault.js'; +import { listStoredMessages, putStoredMessage } from './services/message-store.js'; const clone = (value) => JSON.parse(JSON.stringify(value)); const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1'; @@ -130,6 +131,8 @@ function createInitialState({ withStoredSession = true } = {}) { contacts: [], appLog: [], incomingDedup: {}, + knownMessageKeys: {}, + outgoingTempSeq: 1, notificationsTab: 'replies', pageLabelCollapsed: false, session: { @@ -199,6 +202,55 @@ export const authService = new AuthService(state.entrySettings.shineServer); let onSessionReset = null; let onSessionAuthorized = null; +function persistMessageRecord(chatId, row) { + if (!chatId || !row?.messageKey) return; + void putStoredMessage({ + messageKey: row.messageKey, + chatId, + from: row.from || 'in', + text: String(row.text || ''), + baseKey: String(row.baseKey || ''), + messageType: Number(row.messageType || 0), + rawBlobB64: String(row.rawBlobB64 || ''), + unread: Boolean(row.unread), + firstTick: Boolean(row.firstTick), + secondTick: Boolean(row.secondTick), + readReceiptSent: Boolean(row.readReceiptSent), + refBaseKey: String(row.refBaseKey || ''), + ts: Date.now(), + }).catch(() => {}); +} + +export async function hydrateMessagesFromStore() { + try { + const rows = await listStoredMessages(); + rows + .sort((a, b) => Number(a?.ts || 0) - Number(b?.ts || 0)) + .forEach((row) => { + const chatId = String(row?.chatId || '').trim(); + const messageKey = String(row?.messageKey || '').trim(); + if (!chatId || !messageKey) return; + if (state.knownMessageKeys[messageKey]) return; + state.knownMessageKeys[messageKey] = true; + getChatMessages(chatId).push({ + from: row.from === 'out' ? 'out' : 'in', + text: String(row.text || ''), + messageKey, + baseKey: String(row.baseKey || ''), + messageType: Number(row.messageType || 0), + rawBlobB64: String(row.rawBlobB64 || ''), + unread: Boolean(row.unread), + firstTick: Boolean(row.firstTick), + secondTick: Boolean(row.secondTick), + readReceiptSent: Boolean(row.readReceiptSent), + refBaseKey: String(row.refBaseKey || ''), + }); + }); + } catch { + // ignore broken storage + } +} + export function getChatMessages(chatId) { if (!state.chats[chatId]) { state.chats[chatId] = []; @@ -209,7 +261,7 @@ export function getChatMessages(chatId) { export function addChatMessage(chatId, text) { const message = text.trim(); if (!message) return; - getChatMessages(chatId).push({ from: 'out', text: message }); + getChatMessages(chatId).push({ from: 'out', text: message, firstTick: false, secondTick: false, unread: false }); } @@ -218,10 +270,100 @@ export function addIncomingMessage(chatId, text, messageId = '') { if (!msg) return false; if (messageId && state.incomingDedup[messageId]) return false; if (messageId) state.incomingDedup[messageId] = true; - getChatMessages(chatId).push({ from: 'in', text: msg, messageId }); + getChatMessages(chatId).push({ from: 'in', text: msg, messageId, unread: true }); return true; } +export function addOutgoingPendingMessage(chatId, text) { + const msg = String(text || '').trim(); + if (!msg) return null; + const tempId = `tmp-${Date.now()}-${state.outgoingTempSeq++}`; + getChatMessages(chatId).push({ + from: 'out', + text: msg, + tempId, + firstTick: false, + secondTick: false, + unread: false, + }); + return tempId; +} + +export function markOutgoingSent(tempId, { messageKey = '', baseKey = '' } = {}) { + if (!tempId) return; + const keys = Object.keys(state.chats || {}); + keys.forEach((chatId) => { + const list = getChatMessages(chatId); + const row = list.find((item) => item?.tempId === tempId); + if (!row) return; + row.firstTick = true; + row.messageKey = messageKey || row.messageKey || ''; + row.baseKey = baseKey || row.baseKey || ''; + if (messageKey) { + state.knownMessageKeys[messageKey] = true; + persistMessageRecord(chatId, row); + } + }); +} + +export function markOutgoingReadByBaseKey(baseKey) { + if (!baseKey) return; + const keys = Object.keys(state.chats || {}); + keys.forEach((chatId) => { + const list = getChatMessages(chatId); + list.forEach((row) => { + if (row?.from !== 'out') return; + if (row.baseKey === baseKey) { + row.secondTick = true; + persistMessageRecord(chatId, row); + } + }); + }); +} + +export function addSignedMessageToChat({ + chatId, + messageKey, + baseKey = '', + from = 'in', + text = '', + messageType = 1, + unread = false, + rawBlobB64 = '', + refBaseKey = '', +} = {}) { + const id = String(messageKey || '').trim(); + if (!chatId || !id) return false; + if (state.knownMessageKeys[id]) return false; + state.knownMessageKeys[id] = true; + + const row = { + from: from === 'out' ? 'out' : 'in', + text: String(text || ''), + messageKey: id, + baseKey: String(baseKey || ''), + messageType: Number(messageType || 0), + rawBlobB64: String(rawBlobB64 || ''), + unread: Boolean(unread), + refBaseKey: String(refBaseKey || ''), + firstTick: from === 'out', + secondTick: false, + }; + getChatMessages(chatId).push(row); + persistMessageRecord(chatId, row); + return true; +} + +export function markChatRead(chatId) { + const list = getChatMessages(chatId); + list.forEach((row) => { + if (row?.from === 'in') { + row.unread = false; + persistMessageRecord(chatId, row); + } + }); +} + export function setContacts(list) { state.contacts = Array.isArray(list) ? [...list] : []; } diff --git a/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index 37c0f68..25888db 100644 --- a/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -496,6 +496,56 @@ public final class DatabaseInitializer { ON signed_direct_messages_history (to_login, created_at_ms); """); + // 13) signed_messages_v2 (универсальное хранилище блоков типов 1/2/3/4) + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS signed_messages_v2 ( + message_key TEXT NOT NULL PRIMARY KEY, + base_key TEXT NOT NULL, + target_login TEXT NOT NULL, + from_login TEXT NOT NULL, + to_login TEXT NOT NULL, + time_ms INTEGER NOT NULL, + nonce INTEGER NOT NULL, + message_type INTEGER NOT NULL, + raw_block BLOB NOT NULL, + created_at_ms INTEGER NOT NULL, + source_api TEXT NOT NULL, + origin_session_id TEXT, + receipt_ref_base_key TEXT, + receipt_ref_type INTEGER, + 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_messages_v2_target + ON signed_messages_v2 (target_login, time_ms, created_at_ms); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_signed_messages_v2_base + ON signed_messages_v2 (base_key, message_type); + """); + + // 14) signed_message_session_delivery (доставка по сессиям) + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS signed_message_session_delivery ( + message_key TEXT NOT NULL, + session_id TEXT NOT NULL, + delivered INTEGER NOT NULL DEFAULT 0, + delivered_at_ms INTEGER, + created_at_ms INTEGER NOT NULL, + PRIMARY KEY (message_key, session_id), + FOREIGN KEY (message_key) REFERENCES signed_messages_v2(message_key) + ); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session + ON signed_message_session_delivery (session_id, delivered); + """); + DatabaseTriggersInstaller.createAllTriggers(st); } } diff --git a/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java b/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java new file mode 100644 index 0000000..c0205b7 --- /dev/null +++ b/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java @@ -0,0 +1,182 @@ +package shine.db.dao; + +import shine.db.SqliteDbController; +import shine.db.entities.SignedMessageV2Entry; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.List; + +public final class SignedMessagesV2DAO { + private static volatile SignedMessagesV2DAO instance; + private final SqliteDbController db = SqliteDbController.getInstance(); + + private SignedMessagesV2DAO() {} + + public static SignedMessagesV2DAO getInstance() { + if (instance == null) { + synchronized (SignedMessagesV2DAO.class) { + if (instance == null) instance = new SignedMessagesV2DAO(); + } + } + return instance; + } + + public boolean insertIfAbsent(SignedMessageV2Entry e) throws Exception { + try (Connection c = db.getConnection()) { + String sql = """ + INSERT OR IGNORE INTO signed_messages_v2 ( + message_key, base_key, target_login, from_login, to_login, + time_ms, nonce, message_type, raw_block, created_at_ms, + source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, e.getMessageKey()); + ps.setString(2, e.getBaseKey()); + ps.setString(3, e.getTargetLogin()); + ps.setString(4, e.getFromLogin()); + ps.setString(5, e.getToLogin()); + ps.setLong(6, e.getTimeMs()); + ps.setLong(7, e.getNonce()); + ps.setInt(8, e.getMessageType()); + ps.setBytes(9, e.getRawBlock()); + ps.setLong(10, e.getCreatedAtMs()); + ps.setString(11, e.getSourceApi()); + ps.setString(12, e.getOriginSessionId()); + ps.setString(13, e.getReceiptRefBaseKey()); + if (e.getReceiptRefType() == null) ps.setObject(14, null); + else ps.setInt(14, e.getReceiptRefType()); + return ps.executeUpdate() > 0; + } + } + } + + public SignedMessageV2Entry getByMessageKey(String messageKey) throws Exception { + try (Connection c = db.getConnection()) { + String sql = """ + SELECT + message_key, base_key, target_login, from_login, to_login, + time_ms, nonce, message_type, raw_block, created_at_ms, + source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type + FROM signed_messages_v2 + WHERE message_key = ? + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, messageKey); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return mapRow(rs); + } + } + } + } + + public void ensureDeliveryRow(String messageKey, String sessionId, long nowMs) throws Exception { + try (Connection c = db.getConnection()) { + String sql = """ + INSERT OR IGNORE INTO signed_message_session_delivery ( + message_key, session_id, delivered, delivered_at_ms, created_at_ms + ) VALUES (?, ?, 0, NULL, ?) + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, messageKey); + ps.setString(2, sessionId); + ps.setLong(3, nowMs); + ps.executeUpdate(); + } + } + } + + public void markDelivered(String messageKey, String sessionId, long deliveredAtMs) throws Exception { + try (Connection c = db.getConnection()) { + String insertSql = """ + INSERT OR IGNORE INTO signed_message_session_delivery ( + message_key, session_id, delivered, delivered_at_ms, created_at_ms + ) VALUES (?, ?, 0, NULL, ?) + """; + try (PreparedStatement ps = c.prepareStatement(insertSql)) { + ps.setString(1, messageKey); + ps.setString(2, sessionId); + ps.setLong(3, deliveredAtMs); + ps.executeUpdate(); + } + + String updateSql = """ + UPDATE signed_message_session_delivery + SET delivered = 1, delivered_at_ms = ? + WHERE message_key = ? AND session_id = ? + """; + try (PreparedStatement ps = c.prepareStatement(updateSql)) { + ps.setLong(1, deliveredAtMs); + ps.setString(2, messageKey); + ps.setString(3, sessionId); + ps.executeUpdate(); + } + } + } + + public List listPendingForSession(String login, String sessionId, int limit) throws Exception { + try (Connection c = db.getConnection()) { + String fillSql = """ + INSERT OR IGNORE INTO signed_message_session_delivery ( + message_key, session_id, delivered, delivered_at_ms, created_at_ms + ) + SELECT m.message_key, ?, 0, NULL, ? + FROM signed_messages_v2 m + WHERE m.target_login = ? COLLATE NOCASE + """; + long now = System.currentTimeMillis(); + try (PreparedStatement ps = c.prepareStatement(fillSql)) { + ps.setString(1, sessionId); + ps.setLong(2, now); + ps.setString(3, login); + ps.executeUpdate(); + } + + String sql = """ + SELECT + m.message_key, m.base_key, m.target_login, m.from_login, m.to_login, + m.time_ms, m.nonce, m.message_type, m.raw_block, m.created_at_ms, + m.source_api, m.origin_session_id, m.receipt_ref_base_key, m.receipt_ref_type + FROM signed_messages_v2 m + JOIN signed_message_session_delivery d + ON d.message_key = m.message_key + WHERE d.session_id = ? AND d.delivered = 0 + ORDER BY m.time_ms ASC, m.created_at_ms ASC + LIMIT ? + """; + List out = new ArrayList<>(); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, sessionId); + ps.setInt(2, Math.max(1, limit)); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) out.add(mapRow(rs)); + } + } + return out; + } + } + + private SignedMessageV2Entry mapRow(ResultSet rs) throws Exception { + SignedMessageV2Entry e = new SignedMessageV2Entry(); + e.setMessageKey(rs.getString("message_key")); + e.setBaseKey(rs.getString("base_key")); + e.setTargetLogin(rs.getString("target_login")); + e.setFromLogin(rs.getString("from_login")); + e.setToLogin(rs.getString("to_login")); + e.setTimeMs(rs.getLong("time_ms")); + e.setNonce(rs.getLong("nonce")); + e.setMessageType(rs.getInt("message_type")); + e.setRawBlock(rs.getBytes("raw_block")); + e.setCreatedAtMs(rs.getLong("created_at_ms")); + e.setSourceApi(rs.getString("source_api")); + e.setOriginSessionId(rs.getString("origin_session_id")); + e.setReceiptRefBaseKey(rs.getString("receipt_ref_base_key")); + int maybeRefType = rs.getInt("receipt_ref_type"); + e.setReceiptRefType(rs.wasNull() ? null : maybeRefType); + return e; + } +} diff --git a/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java b/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java new file mode 100644 index 0000000..5ded20b --- /dev/null +++ b/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java @@ -0,0 +1,47 @@ +package shine.db.entities; + +public class SignedMessageV2Entry { + private String messageKey; + private String baseKey; + private String targetLogin; + private String fromLogin; + private String toLogin; + private long timeMs; + private long nonce; + private int messageType; + private byte[] rawBlock; + private long createdAtMs; + private String sourceApi; + private String originSessionId; + private String receiptRefBaseKey; + private Integer receiptRefType; + + public String getMessageKey() { return messageKey; } + public void setMessageKey(String messageKey) { this.messageKey = messageKey; } + public String getBaseKey() { return baseKey; } + public void setBaseKey(String baseKey) { this.baseKey = baseKey; } + public String getTargetLogin() { return targetLogin; } + public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; } + 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 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 int getMessageType() { return messageType; } + public void setMessageType(int messageType) { this.messageType = messageType; } + public byte[] getRawBlock() { return rawBlock; } + public void setRawBlock(byte[] rawBlock) { this.rawBlock = rawBlock; } + public long getCreatedAtMs() { return createdAtMs; } + public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; } + public String getSourceApi() { return sourceApi; } + public void setSourceApi(String sourceApi) { this.sourceApi = sourceApi; } + public String getOriginSessionId() { return originSessionId; } + public void setOriginSessionId(String originSessionId) { this.originSessionId = originSessionId; } + public String getReceiptRefBaseKey() { return receiptRefBaseKey; } + public void setReceiptRefBaseKey(String receiptRefBaseKey) { this.receiptRefBaseKey = receiptRefBaseKey; } + public Integer getReceiptRefType() { return receiptRefType; } + public void setReceiptRefType(Integer receiptRefType) { this.receiptRefType = receiptRefType; } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index c0e2e9c..3d8051e 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -59,14 +59,20 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserCo import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request; import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request; import server.logic.ws_protocol.JSON.messages.Net_AckIncomingMessage_Handler; +import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler; import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler; import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler; +import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler; import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler; +import server.logic.ws_protocol.JSON.messages.Net_SendMessagePair_Handler; import server.logic.ws_protocol.JSON.messages.Net_UpsertPushToken_Handler; +import server.logic.ws_protocol.JSON.messages.entyties.Net_AckSessionDelivery_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_AckIncomingMessage_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Request; // --- NEW: Ping --- @@ -124,7 +130,10 @@ public final class JsonHandlerRegistry { // --- direct messages / push --- Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()), Map.entry("SendDirectMessage", new Net_SendDirectMessage_Handler()), + Map.entry("SendMessagePair", new Net_SendMessagePair_Handler()), + Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()), Map.entry("AckIncomingMessage", new Net_AckIncomingMessage_Handler()), + Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()), Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()), Map.entry("CallSignalToSession", new Net_CallSignalToSession_Handler()), @@ -172,7 +181,10 @@ public final class JsonHandlerRegistry { // --- direct messages / push --- Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class), Map.entry("SendDirectMessage", Net_SendDirectMessage_Request.class), + Map.entry("SendMessagePair", Net_SendMessagePair_Request.class), + Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class), Map.entry("AckIncomingMessage", Net_AckIncomingMessage_Request.class), + Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class), Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class), Map.entry("CallSignalToSession", Net_CallSignalToSession_Request.class), diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java index 947a6a2..fb6759c 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java @@ -10,6 +10,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Response; +import server.logic.ws_protocol.JSON.messages.SignedMessagesRealtime; import server.logic.ws_protocol.JSON.utils.AuthKeyUtils; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; @@ -378,6 +379,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER); ActiveConnectionsRegistry.getInstance().register(ctx); + SignedMessagesRealtime.dispatchPendingForSession(ctx); // --- формируем ответ --- Net_CreateAuthSession_Response resp = new Net_CreateAuthSession_Response(); diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java index f04cd18..8adac28 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java @@ -10,6 +10,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Response; +import server.logic.ws_protocol.JSON.messages.SignedMessagesRealtime; import server.logic.ws_protocol.JSON.utils.AuthKeyUtils; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; @@ -261,6 +262,7 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler { ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER); ActiveConnectionsRegistry.getInstance().register(ctx); + SignedMessagesRealtime.dispatchPendingForSession(ctx); // ответ Net_SessionLogin_Response resp = new Net_SessionLogin_Response(); diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_AckSessionDelivery_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_AckSessionDelivery_Handler.java new file mode 100644 index 0000000..4ada25e --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_AckSessionDelivery_Handler.java @@ -0,0 +1,34 @@ +package server.logic.ws_protocol.JSON.messages; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +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_AckSessionDelivery_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_AckSessionDelivery_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.SignedMessagesV2DAO; + +public class Net_AckSessionDelivery_Handler implements JsonMessageHandler { + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { + Net_AckSessionDelivery_Request req = (Net_AckSessionDelivery_Request) baseRequest; + if (ctx == null || !ctx.isAuthenticatedUser()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация"); + } + if (req.getMessageKey() == null || req.getMessageKey().isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "messageKey обязателен"); + } + + String messageKey = req.getMessageKey().trim(); + SignedMessagesV2DAO.getInstance().markDelivered(messageKey, ctx.getSessionId(), System.currentTimeMillis()); + + Net_AckSessionDelivery_Response resp = new Net_AckSessionDelivery_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setMessageKey(messageKey); + return resp; + } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java new file mode 100644 index 0000000..9ae3cbf --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java @@ -0,0 +1,63 @@ +package server.logic.ws_protocol.JSON.messages; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +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_ReceiveIncomingMessage_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.entities.SignedMessageV2Entry; + +public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler { + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { + Net_ReceiveIncomingMessage_Request req = (Net_ReceiveIncomingMessage_Request) baseRequest; + if (isBlank(req.getIncomingBlobB64())) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "incomingBlobB64 обязателен"); + } + + final SignedMessageBlock incoming; + try { + incoming = SignedMessagesCore.parseFromB64(req.getIncomingBlobB64()); + } catch (IllegalArgumentException ex) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный формат входящего блока"); + } + if (!incoming.isIncomingType()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_MESSAGE_TYPE", "API принимает только входящие типы 1/3"); + } + + try { + SignedMessagesCore.verifyUsersAndSignature(incoming); + } catch (IllegalArgumentException ex) { + String code = ex.getMessage(); + int status = "USER_NOT_FOUND".equals(code) ? 404 : WireCodes.Status.UNVERIFIED; + return NetExceptionResponseFactory.error(req, status, code, "Сообщение не прошло проверку"); + } + + final SignedMessageV2Entry entry; + try { + entry = SignedMessagesCore.toEntry(incoming, "ReceiveIncomingMessage", null); + } catch (IllegalArgumentException ex) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения"); + } + + SignedMessagesCore.saveIfAbsent(entry); + SignedMessagesRealtime.DeliveryCounters counters = SignedMessagesRealtime.deliverToTargetSessions(entry, null); + + Net_ReceiveIncomingMessage_Response resp = new Net_ReceiveIncomingMessage_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setMessageKey(entry.getMessageKey()); + resp.setBaseKey(entry.getBaseKey()); + resp.setDeliveredWsSessions(counters.wsDelivered); + resp.setDeliveredWebPushSessions(counters.pushDelivered); + return resp; + } + + private boolean isBlank(String s) { + return s == null || s.isBlank(); + } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java new file mode 100644 index 0000000..4ee26da --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java @@ -0,0 +1,81 @@ +package server.logic.ws_protocol.JSON.messages; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +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_SendMessagePair_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.entities.SignedMessageV2Entry; + +public class Net_SendMessagePair_Handler implements JsonMessageHandler { + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { + Net_SendMessagePair_Request req = (Net_SendMessagePair_Request) baseRequest; + if (ctx == null || !ctx.isAuthenticatedUser()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация"); + } + if (isBlank(req.getIncomingBlobB64()) || isBlank(req.getOutgoingBlobB64())) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "incomingBlobB64/outgoingBlobB64 обязательны"); + } + + final SignedMessageBlock incoming; + final SignedMessageBlock outgoing; + try { + incoming = SignedMessagesCore.parseFromB64(req.getIncomingBlobB64()); + outgoing = SignedMessagesCore.parseFromB64(req.getOutgoingBlobB64()); + SignedMessagesCore.validatePair(incoming, outgoing); + } catch (IllegalArgumentException ex) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный формат пары сообщений"); + } + + if (!incoming.fromLogin.equalsIgnoreCase(ctx.getLogin())) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "SENDER_MISMATCH", "fromLogin должен совпадать с авторизованной сессией"); + } + + try { + SignedMessagesCore.verifyUsersAndSignature(incoming); + SignedMessagesCore.verifyUsersAndSignature(outgoing); + } catch (IllegalArgumentException ex) { + String code = ex.getMessage(); + int status = "USER_NOT_FOUND".equals(code) ? 404 : WireCodes.Status.UNVERIFIED; + return NetExceptionResponseFactory.error(req, status, code, "Сообщение не прошло проверку"); + } + + SignedMessageV2Entry incomingEntry; + SignedMessageV2Entry outgoingEntry; + try { + incomingEntry = SignedMessagesCore.toEntry(incoming, "SendMessagePair", ctx.getSessionId()); + outgoingEntry = SignedMessagesCore.toEntry(outgoing, "SendMessagePair", ctx.getSessionId()); + } catch (IllegalArgumentException ex) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения"); + } + + SignedMessagesCore.saveIfAbsent(incomingEntry); + SignedMessagesCore.saveIfAbsent(outgoingEntry); + + SignedMessagesRealtime.DeliveryCounters inCounters = + SignedMessagesRealtime.deliverToTargetSessions(incomingEntry, null); + + String excludeSessionId = outgoingEntry.getTargetLogin().equalsIgnoreCase(ctx.getLogin()) ? ctx.getSessionId() : null; + SignedMessagesRealtime.DeliveryCounters outCounters = + SignedMessagesRealtime.deliverToTargetSessions(outgoingEntry, excludeSessionId); + + Net_SendMessagePair_Response resp = new Net_SendMessagePair_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setBaseKey(incomingEntry.getBaseKey()); + resp.setIncomingKey(incomingEntry.getMessageKey()); + resp.setOutgoingKey(outgoingEntry.getMessageKey()); + resp.setDeliveredWsSessions(inCounters.wsDelivered + outCounters.wsDelivered); + resp.setDeliveredWebPushSessions(inCounters.pushDelivered + outCounters.pushDelivered); + return resp; + } + + private boolean isBlank(String s) { + return s == null || s.isBlank(); + } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/ReadReceiptPayload.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/ReadReceiptPayload.java new file mode 100644 index 0000000..9808453 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/ReadReceiptPayload.java @@ -0,0 +1,57 @@ +package server.logic.ws_protocol.JSON.messages; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; + +final class ReadReceiptPayload { + final String refToLogin; + final String refFromLogin; + final long refTimeMs; + final long refNonce; + final int refType; + + private ReadReceiptPayload(String refToLogin, String refFromLogin, long refTimeMs, long refNonce, int refType) { + this.refToLogin = refToLogin; + this.refFromLogin = refFromLogin; + this.refTimeMs = refTimeMs; + this.refNonce = refNonce; + this.refType = refType; + } + + static ReadReceiptPayload parse(byte[] payload) { + if (payload == null || payload.length < 1 + 1 + 8 + 4 + 2) { + throw new IllegalArgumentException("BAD_RECEIPT_PAYLOAD_LEN"); + } + ByteBuffer bb = ByteBuffer.wrap(payload).order(ByteOrder.BIG_ENDIAN); + String refTo = readAscii(bb, 1, 60, "BAD_RECEIPT_TO_LOGIN"); + String refFrom = readAscii(bb, 1, 60, "BAD_RECEIPT_FROM_LOGIN"); + long refTimeMs = bb.getLong(); + if (refTimeMs < 0) throw new IllegalArgumentException("BAD_RECEIPT_TIME"); + long refNonce = Integer.toUnsignedLong(bb.getInt()); + int refType = Short.toUnsignedInt(bb.getShort()); + if (refType < SignedMessageBlock.TYPE_INCOMING_TEXT || refType > SignedMessageBlock.TYPE_READ_OUTGOING_COPY) { + throw new IllegalArgumentException("BAD_RECEIPT_REF_TYPE"); + } + if (bb.hasRemaining()) { + throw new IllegalArgumentException("BAD_RECEIPT_PAYLOAD_LEN"); + } + return new ReadReceiptPayload(refTo, refFrom, refTimeMs, refNonce, refType); + } + + String refBaseKey() { + return SignedMessageKeys.baseKey(refToLogin, refFromLogin, refTimeMs, refNonce); + } + + 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); + } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageBlock.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageBlock.java new file mode 100644 index 0000000..87967a4 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageBlock.java @@ -0,0 +1,118 @@ +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 SignedMessageBlock { + static final byte[] PREFIX = "SHiNE_dm2".getBytes(StandardCharsets.US_ASCII); + static final int TYPE_INCOMING_TEXT = 1; + static final int TYPE_OUTGOING_COPY = 2; + static final int TYPE_READ_INCOMING = 3; + static final int TYPE_READ_OUTGOING_COPY = 4; + + final String toLogin; + final String fromLogin; + final long timeMs; + final long nonce; + final int messageType; + final byte[] payloadBytes; + final byte[] signedBody; + final byte[] signature64; + final byte[] rawPacket; + + private SignedMessageBlock( + String toLogin, + String fromLogin, + long timeMs, + long nonce, + int messageType, + byte[] payloadBytes, + byte[] signedBody, + byte[] signature64, + byte[] rawPacket + ) { + this.toLogin = toLogin; + this.fromLogin = fromLogin; + this.timeMs = timeMs; + this.nonce = nonce; + this.messageType = messageType; + this.payloadBytes = payloadBytes; + this.signedBody = signedBody; + this.signature64 = signature64; + this.rawPacket = rawPacket; + } + + static SignedMessageBlock parse(byte[] raw, int maxPayloadBytes) { + if (raw == null || raw.length < PREFIX.length + 1 + 1 + 8 + 4 + 2 + 2 + 64) { + throw new IllegalArgumentException("BAD_LEN"); + } + if (raw.length > 8192) { + throw new IllegalArgumentException("PAYLOAD_TOO_LARGE"); + } + + ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN); + byte[] prefix = new byte[PREFIX.length]; + bb.get(prefix); + if (!Arrays.equals(prefix, PREFIX)) { + throw new IllegalArgumentException("BAD_PREFIX"); + } + + String toLogin = readAscii(bb, 1, 60, "BAD_TO_LOGIN"); + String fromLogin = readAscii(bb, 1, 60, "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 < TYPE_INCOMING_TEXT || messageType > TYPE_READ_OUTGOING_COPY) { + throw new IllegalArgumentException("BAD_MESSAGE_TYPE"); + } + + int payloadLen = Short.toUnsignedInt(bb.getShort()); + if (payloadLen < 1 || payloadLen > maxPayloadBytes) { + throw new IllegalArgumentException("BAD_MESSAGE_LEN"); + } + if (bb.remaining() != payloadLen + 64) { + throw new IllegalArgumentException("BAD_LEN"); + } + + byte[] payload = new byte[payloadLen]; + bb.get(payload); + byte[] signature64 = new byte[64]; + bb.get(signature64); + byte[] signedBody = Arrays.copyOf(raw, raw.length - 64); + + return new SignedMessageBlock( + toLogin, fromLogin, timeMs, nonce, messageType, payload, signedBody, signature64, raw + ); + } + + boolean isIncomingType() { + return messageType == TYPE_INCOMING_TEXT || messageType == TYPE_READ_INCOMING; + } + + boolean isOutgoingCopyType() { + return messageType == TYPE_OUTGOING_COPY || messageType == TYPE_READ_OUTGOING_COPY; + } + + String targetLogin() { + return isIncomingType() ? toLogin : fromLogin; + } + + 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); + } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageKeys.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageKeys.java new file mode 100644 index 0000000..4814549 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageKeys.java @@ -0,0 +1,13 @@ +package server.logic.ws_protocol.JSON.messages; + +final class SignedMessageKeys { + private SignedMessageKeys() {} + + static String baseKey(String toLogin, String fromLogin, long timeMs, long nonce) { + return fromLogin + "|" + toLogin + "|" + timeMs + "|" + nonce; + } + + static String messageKey(String toLogin, String fromLogin, long timeMs, long nonce, int messageType) { + return baseKey(toLogin, fromLogin, timeMs, nonce) + "|" + messageType; + } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java new file mode 100644 index 0000000..e666781 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java @@ -0,0 +1,89 @@ +package server.logic.ws_protocol.JSON.messages; + +import shine.db.dao.SignedMessagesV2DAO; +import shine.db.dao.SolanaUsersDAO; +import shine.db.entities.SignedMessageV2Entry; +import shine.db.entities.SolanaUserEntry; +import utils.crypto.Ed25519Util; + +import java.util.Base64; + +final class SignedMessagesCore { + private static final int MAX_PAYLOAD_BYTES = 4096; + + private SignedMessagesCore() {} + + static SignedMessageBlock parseFromB64(String blobB64) { + try { + byte[] raw = Base64.getDecoder().decode(blobB64.trim()); + return SignedMessageBlock.parse(raw, MAX_PAYLOAD_BYTES); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("BAD_BLOCK_FORMAT"); + } + } + + static void verifyUsersAndSignature(SignedMessageBlock block) throws Exception { + SolanaUserEntry from = SolanaUsersDAO.getInstance().getByLogin(block.fromLogin); + SolanaUserEntry to = SolanaUsersDAO.getInstance().getByLogin(block.toLogin); + if (from == null || to == null) { + throw new IllegalArgumentException("USER_NOT_FOUND"); + } + byte[] pubKey32 = Ed25519Util.keyFromBase64(from.getDeviceKey()); + if (!Ed25519Util.verify(block.signedBody, block.signature64, pubKey32)) { + throw new IllegalArgumentException("BAD_SIGNATURE"); + } + } + + static void validatePair(SignedMessageBlock incoming, SignedMessageBlock outgoing) { + if (incoming.messageType % 2 == 0) throw new IllegalArgumentException("BAD_PAIR_TYPES"); + if (outgoing.messageType != incoming.messageType + 1) throw new IllegalArgumentException("BAD_PAIR_TYPES"); + if (!incoming.toLogin.equalsIgnoreCase(outgoing.toLogin)) throw new IllegalArgumentException("BAD_PAIR_KEYS"); + if (!incoming.fromLogin.equalsIgnoreCase(outgoing.fromLogin)) throw new IllegalArgumentException("BAD_PAIR_KEYS"); + if (incoming.timeMs != outgoing.timeMs) throw new IllegalArgumentException("BAD_PAIR_KEYS"); + if (incoming.nonce != outgoing.nonce) throw new IllegalArgumentException("BAD_PAIR_KEYS"); + + if (incoming.messageType == SignedMessageBlock.TYPE_READ_INCOMING) { + ReadReceiptPayload inRef = ReadReceiptPayload.parse(incoming.payloadBytes); + ReadReceiptPayload outRef = ReadReceiptPayload.parse(outgoing.payloadBytes); + if (!inRef.refToLogin.equalsIgnoreCase(outRef.refToLogin) + || !inRef.refFromLogin.equalsIgnoreCase(outRef.refFromLogin) + || inRef.refTimeMs != outRef.refTimeMs + || inRef.refNonce != outRef.refNonce + || inRef.refType != outRef.refType) { + throw new IllegalArgumentException("BAD_RECEIPT_REF"); + } + } + } + + static SignedMessageV2Entry toEntry(SignedMessageBlock block, String sourceApi, String originSessionId) { + String baseKey = SignedMessageKeys.baseKey(block.toLogin, block.fromLogin, block.timeMs, block.nonce); + String messageKey = SignedMessageKeys.messageKey(block.toLogin, block.fromLogin, block.timeMs, block.nonce, block.messageType); + + SignedMessageV2Entry entry = new SignedMessageV2Entry(); + entry.setMessageKey(messageKey); + entry.setBaseKey(baseKey); + entry.setTargetLogin(block.targetLogin()); + entry.setFromLogin(block.fromLogin); + entry.setToLogin(block.toLogin); + entry.setTimeMs(block.timeMs); + entry.setNonce(block.nonce); + entry.setMessageType(block.messageType); + entry.setRawBlock(block.rawPacket); + entry.setCreatedAtMs(System.currentTimeMillis()); + entry.setSourceApi(sourceApi); + entry.setOriginSessionId(originSessionId); + + if (block.messageType == SignedMessageBlock.TYPE_READ_INCOMING + || block.messageType == SignedMessageBlock.TYPE_READ_OUTGOING_COPY) { + ReadReceiptPayload ref = ReadReceiptPayload.parse(block.payloadBytes); + entry.setReceiptRefBaseKey(ref.refBaseKey()); + entry.setReceiptRefType(ref.refType); + } + + return entry; + } + + static void saveIfAbsent(SignedMessageV2Entry entry) throws Exception { + SignedMessagesV2DAO.getInstance().insertIfAbsent(entry); + } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java new file mode 100644 index 0000000..97d482d --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java @@ -0,0 +1,136 @@ +package server.logic.ws_protocol.JSON.messages; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.push.WebPushSender; +import server.logic.ws_protocol.JSON.push.WsEventSender; +import shine.db.dao.ActiveSessionsDAO; +import shine.db.dao.SignedMessagesV2DAO; +import shine.db.entities.ActiveSessionEntry; +import shine.db.entities.SignedMessageV2Entry; + +import java.util.Base64; +import java.util.List; + +public final class SignedMessagesRealtime { + private static final Logger log = LoggerFactory.getLogger(SignedMessagesRealtime.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final int LOGIN_BACKLOG_LIMIT = 500; + + private SignedMessagesRealtime() {} + + static DeliveryCounters deliverToTargetSessions( + SignedMessageV2Entry message, + String excludeSessionId + ) throws Exception { + DeliveryCounters counters = new DeliveryCounters(); + List sessions = ActiveSessionsDAO.getInstance().getByLogin(message.getTargetLogin()); + long now = System.currentTimeMillis(); + for (ActiveSessionEntry s : sessions) { + String sessionId = s.getSessionId(); + if (excludeSessionId != null && excludeSessionId.equals(sessionId)) { + continue; + } + SignedMessagesV2DAO.getInstance().ensureDeliveryRow(message.getMessageKey(), sessionId, now); + boolean deliveredOnline = sendEventToSessionIfOnline(sessionId, message, false); + if (deliveredOnline) { + counters.wsDelivered++; + continue; + } + if (message.getMessageType() == SignedMessageBlock.TYPE_INCOMING_TEXT) { + boolean pushed = pushNewMessageNotification(s, message); + if (pushed) counters.pushDelivered++; + } + } + return counters; + } + + public static void dispatchPendingForSession(ConnectionContext ctx) { + if (ctx == null || !ctx.isAuthenticatedUser()) return; + String login = ctx.getLogin(); + String sessionId = ctx.getSessionId(); + if (isBlank(login) || isBlank(sessionId)) return; + + try { + List pending = SignedMessagesV2DAO.getInstance() + .listPendingForSession(login, sessionId, LOGIN_BACKLOG_LIMIT); + for (SignedMessageV2Entry e : pending) { + sendEventToSessionIfOnline(sessionId, e, true); + } + } catch (Exception e) { + log.warn("Failed to dispatch pending messages for sessionId={}", sessionId, e); + } + } + + private static boolean sendEventToSessionIfOnline(String sessionId, SignedMessageV2Entry message, boolean backlog) { + ConnectionContext targetCtx = ActiveConnectionsRegistry.getInstance().getBySessionId(sessionId); + if (targetCtx == null) return false; + + String blobB64 = Base64.getEncoder().encodeToString(message.getRawBlock()); + ObjectNode payload = MAPPER.createObjectNode(); + payload.put("messageKey", message.getMessageKey()); + payload.put("baseKey", message.getBaseKey()); + payload.put("fromLogin", message.getFromLogin()); + payload.put("toLogin", message.getToLogin()); + payload.put("targetLogin", message.getTargetLogin()); + payload.put("messageType", message.getMessageType()); + payload.put("timeMs", message.getTimeMs()); + payload.put("nonce", message.getNonce()); + payload.put("blobB64", blobB64); + payload.put("backlog", backlog); + if (message.getReceiptRefBaseKey() != null) { + payload.put("receiptRefBaseKey", message.getReceiptRefBaseKey()); + } + if (message.getReceiptRefType() != null) { + payload.put("receiptRefType", message.getReceiptRefType()); + } + return WsEventSender.sendEvent(targetCtx, "SignedMessageArrived", message.getMessageKey(), payload); + } + + private static boolean pushNewMessageNotification(ActiveSessionEntry session, SignedMessageV2Entry message) { + try { + if (session == null) return false; + if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) { + return false; + } + String text = "Вам пришло сообщение от " + message.getFromLogin() + ". Откройте для прочтения."; + String payload = "{\"kind\":\"new_message\",\"fromLogin\":\"" + jsonEscape(message.getFromLogin()) + "\",\"text\":\"" + jsonEscape(text) + "\"}"; + return WebPushSender.sendBase64Payload( + session.getPushEndpoint(), + session.getPushP256dhKey(), + session.getPushAuthKey(), + payload + ); + } catch (Exception e) { + return false; + } + } + + private static String jsonEscape(String s) { + if (s == null) return ""; + StringBuilder out = new StringBuilder(); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '\\') out.append("\\\\"); + else if (c == '"') out.append("\\\""); + else if (c == '\n') out.append("\\n"); + else if (c == '\r') out.append("\\r"); + else if (c == '\t') out.append("\\t"); + else out.append(c); + } + return out.toString(); + } + + private static boolean isBlank(String s) { + return s == null || s.isBlank(); + } + + static final class DeliveryCounters { + int wsDelivered; + int pushDelivered; + } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_AckSessionDelivery_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_AckSessionDelivery_Request.java new file mode 100644 index 0000000..8d07214 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_AckSessionDelivery_Request.java @@ -0,0 +1,10 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_AckSessionDelivery_Request extends Net_Request { + private String messageKey; + + public String getMessageKey() { return messageKey; } + public void setMessageKey(String messageKey) { this.messageKey = messageKey; } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_AckSessionDelivery_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_AckSessionDelivery_Response.java new file mode 100644 index 0000000..743d435 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_AckSessionDelivery_Response.java @@ -0,0 +1,10 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_AckSessionDelivery_Response extends Net_Response { + private String messageKey; + + public String getMessageKey() { return messageKey; } + public void setMessageKey(String messageKey) { this.messageKey = messageKey; } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_ReceiveIncomingMessage_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_ReceiveIncomingMessage_Request.java new file mode 100644 index 0000000..5b67429 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_ReceiveIncomingMessage_Request.java @@ -0,0 +1,10 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_ReceiveIncomingMessage_Request extends Net_Request { + private String incomingBlobB64; + + public String getIncomingBlobB64() { return incomingBlobB64; } + public void setIncomingBlobB64(String incomingBlobB64) { this.incomingBlobB64 = incomingBlobB64; } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_ReceiveIncomingMessage_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_ReceiveIncomingMessage_Response.java new file mode 100644 index 0000000..3e4f7f5 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_ReceiveIncomingMessage_Response.java @@ -0,0 +1,19 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_ReceiveIncomingMessage_Response extends Net_Response { + private String messageKey; + private String baseKey; + private int deliveredWsSessions; + private int deliveredWebPushSessions; + + public String getMessageKey() { return messageKey; } + public void setMessageKey(String messageKey) { this.messageKey = messageKey; } + public String getBaseKey() { return baseKey; } + public void setBaseKey(String baseKey) { this.baseKey = baseKey; } + public int getDeliveredWsSessions() { return deliveredWsSessions; } + public void setDeliveredWsSessions(int deliveredWsSessions) { this.deliveredWsSessions = deliveredWsSessions; } + public int getDeliveredWebPushSessions() { return deliveredWebPushSessions; } + public void setDeliveredWebPushSessions(int deliveredWebPushSessions) { this.deliveredWebPushSessions = deliveredWebPushSessions; } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendMessagePair_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendMessagePair_Request.java new file mode 100644 index 0000000..d291722 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendMessagePair_Request.java @@ -0,0 +1,13 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_SendMessagePair_Request extends Net_Request { + private String incomingBlobB64; + private String outgoingBlobB64; + + public String getIncomingBlobB64() { return incomingBlobB64; } + public void setIncomingBlobB64(String incomingBlobB64) { this.incomingBlobB64 = incomingBlobB64; } + public String getOutgoingBlobB64() { return outgoingBlobB64; } + public void setOutgoingBlobB64(String outgoingBlobB64) { this.outgoingBlobB64 = outgoingBlobB64; } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendMessagePair_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendMessagePair_Response.java new file mode 100644 index 0000000..298e4d3 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendMessagePair_Response.java @@ -0,0 +1,22 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_SendMessagePair_Response extends Net_Response { + private String baseKey; + private String incomingKey; + private String outgoingKey; + private int deliveredWsSessions; + private int deliveredWebPushSessions; + + public String getBaseKey() { return baseKey; } + public void setBaseKey(String baseKey) { this.baseKey = baseKey; } + public String getIncomingKey() { return incomingKey; } + public void setIncomingKey(String incomingKey) { this.incomingKey = incomingKey; } + public String getOutgoingKey() { return outgoingKey; } + public void setOutgoingKey(String outgoingKey) { this.outgoingKey = outgoingKey; } + public int getDeliveredWsSessions() { return deliveredWsSessions; } + public void setDeliveredWsSessions(int deliveredWsSessions) { this.deliveredWsSessions = deliveredWsSessions; } + public int getDeliveredWebPushSessions() { return deliveredWebPushSessions; } + public void setDeliveredWebPushSessions(int deliveredWebPushSessions) { this.deliveredWebPushSessions = deliveredWebPushSessions; } +}