merge: add-web-push into main

# Conflicts:
#	shine-UI/js/router.js
#	shine-UI/js/services/auth-service.js
#	shine-UI/js/state.js
This commit is contained in:
AidarKC
2026-04-14 22:35:28 +03:00
35 changed files with 2051 additions and 336 deletions
+179 -7
View File
@@ -1,5 +1,6 @@
import { WsJsonClient } from './ws-client.js';
import {
base64ToBytes,
bytesToBase64,
deriveEd25519FromPassword,
exportEd25519PublicKeyB64,
@@ -27,6 +28,7 @@ import {
const BCH_SUFFIX = '001';
const ZERO64 = '0'.repeat(64);
const ZERO_HASH_HEX = ZERO64;
const MSG_TYPE_TECH = 0;
const MSG_TYPE_TEXT = 1;
@@ -42,6 +44,12 @@ const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
const MSG_SUBTYPE_CONNECTION_UNFOLLOW = 31;
const CREATE_CHANNEL_BODY_VERSION = 2;
const CONNECTION_SUBTYPES = Object.freeze({
friend: { on: 10, off: 11 },
contact: { on: 20, off: 21 },
follow: { on: 30, off: 31 },
});
function normalizeServerUrl(url) {
const value = (url || '').trim();
if (!value) return 'wss://shineup.me/ws';
@@ -162,6 +170,31 @@ function int64Bytes(value) {
return bytes;
}
function uint16Bytes(value) {
const bytes = new Uint8Array(2);
const view = new DataView(bytes.buffer);
view.setUint16(0, Number(value), false);
return bytes;
}
function uint32Bytes(value) {
const bytes = new Uint8Array(4);
const view = new DataView(bytes.buffer);
view.setUint32(0, Number(value), false);
return bytes;
}
function uint64Bytes(value) {
const bytes = new Uint8Array(8);
const view = new DataView(bytes.buffer);
view.setBigUint64(0, BigInt(value), false);
return bytes;
}
function uint8Bytes(value) {
return new Uint8Array([Number(value) & 0xff]);
}
function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, key, value }) {
const keyBytes = utf8Bytes(String(key || ''));
const valueBytes = utf8Bytes(String(value || ''));
@@ -1076,14 +1109,57 @@ export class AuthService {
return this.ws.onEvent(op, handler);
}
async upsertPushToken({ tokenId, token, provider = 'fcm', platform = 'web', userAgent = navigator.userAgent || '' }) {
const response = await this.ws.request('UpsertPushToken', { tokenId, token, provider, platform, userAgent });
async upsertPushToken({ endpoint, p256dhKey, authKey, sessionId, platform = 'web', userAgent = navigator.userAgent || '' }) {
const response = await this.ws.request('UpsertPushToken', { endpoint, p256dhKey, authKey, sessionId, platform, userAgent });
if (response.status !== 200) throw opError('UpsertPushToken', response);
return response.payload || {};
}
async sendDirectMessage(toLogin, text) {
const response = await this.ws.request('SendDirectMessage', { toLogin, text });
async sendDirectMessage({ toLogin, text, storagePwd, targetSessionId = null, messageType = 1 }) {
const cleanToLogin = String(toLogin || '').trim();
const cleanText = String(text || '');
if (!cleanToLogin || !cleanText) throw new Error('Не передан toLogin/text');
if (!storagePwd) throw new Error('Не передан storagePwd для подписи');
if (!this.ws.login) throw new Error('Нет активной авторизованной сессии');
const secrets = await loadEncryptedUserSecrets(this.ws.login, storagePwd);
const devicePriv = secrets?.deviceKey;
if (!devicePriv) throw new Error('Не найден приватный deviceKey');
const privateKey = await importPkcs8Ed25519(devicePriv);
const prefix = utf8Bytes('SHiNE_msg');
const version = uint8Bytes(1);
const toBytes = utf8Bytes(cleanToLogin);
const fromBytes = utf8Bytes(this.ws.login);
if (toBytes.length < 1 || toBytes.length > 30) throw new Error('toLogin должен быть 1..30 ASCII-символов');
if (fromBytes.length < 1 || fromBytes.length > 30) throw new Error('fromLogin должен быть 1..30 ASCII-символов');
if (cleanText.length > 3000) throw new Error('Слишком длинное сообщение');
const mode = targetSessionId ? 1 : 0;
const targetBytes = targetSessionId ? utf8Bytes(String(targetSessionId)) : new Uint8Array(0);
if (mode === 1 && (targetBytes.length < 1 || targetBytes.length > 255)) {
throw new Error('targetSessionId должен быть 1..255 символов');
}
const bodyBytes = utf8Bytes(cleanText);
const preimage = concatBytes(
prefix,
version,
uint8Bytes(toBytes.length), toBytes,
uint8Bytes(fromBytes.length), fromBytes,
uint64Bytes(Date.now()),
uint32Bytes(Math.floor(Math.random() * 0x100000000)),
uint16Bytes(messageType),
uint8Bytes(mode),
mode === 1 ? concatBytes(uint8Bytes(targetBytes.length), targetBytes) : new Uint8Array(0),
uint16Bytes(bodyBytes.length),
bodyBytes,
);
const signature = await signBytes(privateKey, preimage);
const packet = concatBytes(preimage, signature);
const blobB64 = bytesToBase64(packet);
const response = await this.ws.request('SendDirectMessage', { blobB64 });
if (response.status !== 200) throw opError('SendDirectMessage', response);
return response.payload || {};
}
@@ -1134,6 +1210,14 @@ export class AuthService {
throw opError('GetUserParam', response);
}
async setUserRelation({ login, toLogin, kind, enabled, storagePwd }) {
const cleanKind = String(kind || '').trim().toLowerCase();
const kinds = CONNECTION_SUBTYPES[cleanKind];
if (!kinds) throw new Error(`Неподдерживаемый тип связи: ${kind}`);
const subType = enabled ? kinds.on : kinds.off;
return this.addBlockConnection({ login, toLogin, subType, storagePwd });
}
async addBlockUserParam({ login, param, value, storagePwd }) {
const cleanLogin = (login || '').trim();
const cleanParam = (param || '').trim();
@@ -1148,7 +1232,7 @@ export class AuthService {
const freshHash = String(user?.serverLastGlobalHash || '').trim().toLowerCase();
const freshCursor = {
serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1,
serverLastGlobalHash: freshHash.length === 64 ? freshHash : '0'.repeat(64),
serverLastGlobalHash: freshHash.length === 64 ? freshHash : ZERO_HASH_HEX,
};
const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
@@ -1161,7 +1245,7 @@ export class AuthService {
const tryAdd = async (cursor) => {
const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1;
const prevBlockHash = String(cursor?.serverLastGlobalHash || '0'.repeat(64));
const prevBlockHash = String(cursor?.serverLastGlobalHash || ZERO_HASH_HEX);
// Для USER_PARAM отправляем старт новой line-цепочки:
// prevLineNumber=-1, prevLineHash=0x00..00, thisLineNumber=-1.
@@ -1169,7 +1253,7 @@ export class AuthService {
const bodyBytes = makeUserParamBodyBytes({
lineCode: 0,
prevLineNumber: -1,
prevLineHashHex: '0'.repeat(64),
prevLineHashHex: ZERO_HASH_HEX,
thisLineNumber: -1,
key: cleanParam,
value: cleanValue,
@@ -1216,6 +1300,94 @@ export class AuthService {
return response.payload || {};
}
async addBlockConnection({ login, toLogin, subType, storagePwd }) {
const cleanLogin = (login || '').trim();
const cleanToLogin = (toLogin || '').trim();
const cleanSubType = Number(subType);
if (!cleanLogin || !cleanToLogin) throw new Error('Не переданы login/toLogin для CONNECTION.');
if (!Number.isFinite(cleanSubType)) throw new Error('Не передан subType для CONNECTION.');
if (!storagePwd) throw new Error('Не передан storagePwd для подписи AddBlock.');
if (cleanLogin.toLowerCase() === cleanToLogin.toLowerCase()) {
throw new Error('Нельзя создать связь на самого себя.');
}
const user = await this.getUser(cleanLogin);
if (user?.exists === false) throw new Error('Текущий пользователь не найден.');
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
const freshNum = Number(user?.serverLastGlobalNumber);
const freshHash = String(user?.serverLastGlobalHash || '').trim().toLowerCase();
const freshCursor = {
serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1,
serverLastGlobalHash: freshHash.length === 64 ? freshHash : ZERO_HASH_HEX,
};
const targetUser = await this.getUser(cleanToLogin);
if (!targetUser?.exists) throw new Error('Пользователь цели не найден.');
const toBlockchainName = String(targetUser?.blockchainName || `${cleanToLogin}-${BCH_SUFFIX}`).trim();
const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
const blockchainPrivatePkcs8 = savedKeys?.blockchainKey;
if (!blockchainPrivatePkcs8) {
throw new Error('На устройстве нет сохраненного приватного blockchainKey');
}
const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8);
const tryAdd = async (cursor) => {
const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1;
const prevBlockHash = String(cursor?.serverLastGlobalHash || ZERO_HASH_HEX);
// Для CONNECTION в UI-MVP всегда стартуем новую line-цепочку:
// prevLineNumber=-1, prevLineHash=0x00..00, thisLineNumber=-1.
// target для user-связей указывает на HEADER пользователя (blockNumber=0).
const bodyBytes = makeConnectionBodyBytes({
lineCode: 0,
prevLineNumber: -1,
prevLineHashHex: ZERO_HASH_HEX,
thisLineNumber: -1,
toBlockchainName,
toBlockNumber: 0,
toBlockHashHex: ZERO_HASH_HEX,
});
const preimage = concatBytes(
int16Bytes(0),
hexToBytes(prevBlockHash),
int32Bytes(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.length),
int32Bytes(blockNumber),
int64Bytes(Math.floor(Date.now() / 1000)),
int16Bytes(3),
int16Bytes(cleanSubType),
int16Bytes(1),
bodyBytes,
);
const hash32 = await sha256Bytes(preimage);
const signatureBytes = await signBytes(privateKey, hash32);
const fullBlock = concatBytes(preimage, int16Bytes(0x0100), signatureBytes);
return this.ws.request('AddBlock', {
blockchainName,
blockNumber,
prevBlockHash,
blockBytesB64: bytesToBase64(fullBlock),
});
};
let cursor = freshCursor;
let response = await tryAdd(cursor);
if (response.status !== 200) {
const knownNum = Number(response?.payload?.serverLastGlobalNumber);
const knownHash = String(response?.payload?.serverLastGlobalHash || '').trim().toLowerCase();
if (Number.isFinite(knownNum) && knownHash.length === 64) {
cursor = { serverLastGlobalNumber: knownNum, serverLastGlobalHash: knownHash };
response = await tryAdd(cursor);
}
}
if (response.status !== 200) throw opError('AddBlock', response);
return response.payload || {};
}
async reportClientError(details) {
try {
const response = await this.ws.request('ClientErrorLog', details || {}, 3000);
+111 -33
View File
@@ -1,51 +1,129 @@
const LS_KEY = 'shine-ui-fcm-token-v1';
const LS_KEY = 'shine-ui-webpush-subscription-v1';
export async function initPwaPush({ authService }) {
if (!('serviceWorker' in navigator)) return;
try {
await navigator.serviceWorker.register('./firebase-messaging-sw.js');
} catch {
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
export async function initPwaPush({ authService, onLog = null }) {
const log = (entry) => {
if (typeof onLog === 'function') onLog(entry);
};
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
log({
level: 'warn',
source: 'web-push',
message: 'Web Push недоступен: нет serviceWorker или PushManager',
});
return;
}
if (!window.firebase || !window.firebase.messaging) return;
const vapidPublicKey = window.__SHINE_WEBPUSH_VAPID_PUBLIC_KEY__ || '';
if (!vapidPublicKey) {
log({
level: 'warn',
source: 'web-push',
message: 'Web Push отключен: не задан публичный VAPID ключ',
});
return;
}
try {
const config = window.__SHINE_FIREBASE_CONFIG__ || null;
if (!config) return;
if (!window.firebase.apps.length) {
window.firebase.initializeApp(config);
}
const messaging = window.firebase.messaging();
const registration = await navigator.serviceWorker.register('./firebase-messaging-sw.js');
log({
level: 'info',
source: 'web-push',
message: 'Service Worker зарегистрирован',
details: { scope: registration.scope },
});
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
if (permission !== 'granted') {
log({
level: 'warn',
source: 'web-push',
message: `Разрешение на уведомления: ${permission}`,
});
return;
}
log({
level: 'info',
source: 'web-push',
message: 'Разрешение на уведомления получено',
});
const vapidKey = window.__SHINE_FIREBASE_VAPID_KEY__ || '';
const token = await messaging.getToken({ vapidKey });
if (!token) return;
let sub = await registration.pushManager.getSubscription();
let isNewSubscription = false;
if (!sub) {
sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
isNewSubscription = true;
}
log({
level: 'info',
source: 'web-push',
message: isNewSubscription ? 'Создана новая push-подписка' : 'Найдена существующая push-подписка',
});
const prev = localStorage.getItem(LS_KEY);
if (prev === token) return;
const serialized = JSON.stringify(sub);
const prevSerialized = localStorage.getItem(LS_KEY);
if (prevSerialized === serialized) {
log({
level: 'info',
source: 'web-push',
message: 'Push-подписка не изменилась, отправка на сервер не требуется',
});
return;
}
localStorage.setItem(LS_KEY, serialized);
const json = sub.toJSON();
const endpoint = json.endpoint || '';
const p256dhKey = json.keys?.p256dh || '';
const authKey = json.keys?.auth || '';
if (!endpoint || !p256dhKey || !authKey) {
log({
level: 'warn',
source: 'web-push',
message: 'Подписка неполная: endpoint/p256dh/auth отсутствуют',
});
return;
}
log({
level: 'info',
source: 'web-push',
message: 'Push-токен получен, отправка на сервер',
details: { endpoint },
});
localStorage.setItem(LS_KEY, token);
const tokenId = `tok-${new Date().toISOString().replace(/[-:.TZ]/g, '')}-${Math.random().toString(36).slice(2, 12)}`;
await authService.upsertPushToken({
tokenId,
token,
provider: 'fcm',
endpoint,
p256dhKey,
authKey,
platform: 'web',
userAgent: navigator.userAgent || '',
});
messaging.onMessage((payload) => {
const title = payload?.notification?.title || 'Новое сообщение';
const body = payload?.notification?.body || '';
try {
new Notification(title, { body });
} catch {}
log({
level: 'info',
source: 'web-push',
message: 'Push-подписка успешно отправлена на сервер',
});
} catch (error) {
log({
level: 'error',
source: 'web-push',
message: 'Ошибка инициализации Web Push',
details: error?.message || 'unknown',
});
} catch {
// silent for MVP
}
}
+215
View File
@@ -0,0 +1,215 @@
import { authService, state } from '../state.js';
import { loadProfileSnapshot } from './user-profile-params.js';
function normalizeLogin(value) {
return String(value || '').trim();
}
function normKey(value) {
return normalizeLogin(value).toLowerCase();
}
function uniqueLogins(list) {
const out = [];
const seen = new Set();
(Array.isArray(list) ? list : []).forEach((item) => {
const login = normalizeLogin(item);
if (!login) return;
const key = normKey(login);
if (seen.has(key)) return;
seen.add(key);
out.push(login);
});
return out;
}
function listContainsLogin(list, login) {
const targetKey = normKey(login);
if (!targetKey) return false;
return uniqueLogins(list).some((value) => normKey(value) === targetKey);
}
function toFieldMap(snapshot) {
const map = {};
(snapshot?.fields || []).forEach((field) => {
map[field.key] = String(field.value || '').trim();
});
return map;
}
function toToggleMap(snapshot) {
const map = {};
(snapshot?.toggles || []).forEach((toggle) => {
map[toggle.key] = Boolean(toggle.enabled);
});
return map;
}
function readArray(payload, key) {
const value = payload?.[key];
return Array.isArray(value) ? uniqueLogins(value) : null;
}
function feedOwnerLogins(feedPayload) {
const rows = Array.isArray(feedPayload?.followedUsersChannels) ? feedPayload.followedUsersChannels : [];
const owners = rows
.map((row) => normalizeLogin(row?.channel?.ownerLogin))
.filter(Boolean);
return uniqueLogins(owners);
}
async function buildRelationsModel(login) {
const cleanLogin = normalizeLogin(login);
if (!cleanLogin) {
return {
outFriends: [],
inFriends: [],
outContacts: [],
inContacts: [],
outFollows: [],
inFollows: [],
};
}
const graph = await authService.getUserConnectionsGraph(cleanLogin);
let outContacts = readArray(graph, 'outContacts');
let outFollows = readArray(graph, 'outFollows');
const isCurrentSessionLogin = normKey(cleanLogin) === normKey(state.session.login);
if (outContacts === null && isCurrentSessionLogin) {
try {
const contacts = await authService.listContacts();
outContacts = uniqueLogins(contacts?.contacts || []);
} catch {
outContacts = [];
}
}
if (outContacts === null) outContacts = [];
if (outFollows === null) {
try {
const feed = await authService.listSubscriptionsFeed(cleanLogin, 200);
outFollows = feedOwnerLogins(feed);
} catch {
outFollows = [];
}
}
return {
outFriends: readArray(graph, 'outFriends') || [],
inFriends: readArray(graph, 'inFriends') || [],
outContacts,
inContacts: readArray(graph, 'inContacts') || [],
outFollows,
inFollows: readArray(graph, 'inFollows') || [],
};
}
export function buildIdentityLines({ login, firstName, lastName }) {
const lines = [];
const first = String(firstName || '').trim();
const last = String(lastName || '').trim();
const cleanLogin = normalizeLogin(login);
if (first) lines.push(first);
if (last) lines.push(last);
lines.push(cleanLogin || 'unknown');
return lines;
}
export function buildAvatarInitials({ login, firstName, lastName }) {
const first = String(firstName || '').trim();
const last = String(lastName || '').trim();
if (first || last) {
const a = (first[0] || '').toUpperCase();
const b = (last[0] || '').toUpperCase();
const initials = `${a}${b}`.trim();
if (initials) return initials;
}
const cleanLogin = normalizeLogin(login);
return (cleanLogin[0] || '?').toUpperCase();
}
export async function loadCurrentRelations() {
const login = normalizeLogin(state.session.login);
if (!login) {
return {
outFriends: [],
inFriends: [],
outContacts: [],
inContacts: [],
outFollows: [],
inFollows: [],
};
}
return buildRelationsModel(login);
}
export function relationFlagsForTarget(relations, targetLogin) {
return {
outFriend: listContainsLogin(relations?.outFriends, targetLogin),
inFriend: listContainsLogin(relations?.inFriends, targetLogin),
outContact: listContainsLogin(relations?.outContacts, targetLogin),
inContact: listContainsLogin(relations?.inContacts, targetLogin),
outFollow: listContainsLogin(relations?.outFollows, targetLogin),
inFollow: listContainsLogin(relations?.inFollows, targetLogin),
};
}
export async function loadUserProfileCard(login) {
const cleanLogin = normalizeLogin(login);
if (!cleanLogin) throw new Error('Пустой login');
const [user, snapshot] = await Promise.all([
authService.getUser(cleanLogin),
loadProfileSnapshot(cleanLogin),
]);
if (!user?.exists) throw new Error('Пользователь не найден');
const canonicalLogin = normalizeLogin(user.login || cleanLogin);
const fields = toFieldMap(snapshot);
const toggles = toToggleMap(snapshot);
return {
login: canonicalLogin,
blockchainName: normalizeLogin(user.blockchainName),
firstName: fields.first_name || '',
lastName: fields.last_name || '',
address: fields.address || '',
web: fields.web || '',
phone: fields.phone || '',
official: Boolean(toggles.official),
shine: Boolean(toggles.shine),
};
}
export async function loadRelationsForPair({ currentLogin, targetLogin }) {
const cleanCurrent = normalizeLogin(currentLogin);
const cleanTarget = normalizeLogin(targetLogin);
const currentRelations = await buildRelationsModel(cleanCurrent);
let flags = relationFlagsForTarget(currentRelations, cleanTarget);
if (!flags.inContact || !flags.inFollow) {
try {
const targetRelations = await buildRelationsModel(cleanTarget);
const backFlags = relationFlagsForTarget(targetRelations, cleanCurrent);
flags = {
...flags,
inContact: flags.inContact || backFlags.outContact,
inFollow: flags.inFollow || backFlags.outFollow,
};
} catch {
// ignore fallback failures for incoming direction
}
}
return {
...flags,
source: currentRelations,
};
}