Первая версия вложенных файлов работает

This commit is contained in:
AidarKC
2026-09-11 02:38:02 +03:00
parent ea0098d705
commit f9ae811702
29 changed files with 1463 additions and 57 deletions
+3
View File
@@ -75,6 +75,7 @@ import * as walletView from './pages/wallet-view.js?v=202606281930';
import * as settingsView from './pages/settings-view.js';
import * as accessServersView from './pages/access-servers-view.js';
import * as developerSettingsView from './pages/developer-settings-view.js';
import * as advancedSettingsView from './pages/advanced-settings-view.js';
import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240';
import * as arweaveUploadsView from './pages/arweave-uploads-view.js';
import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300';
@@ -141,6 +142,7 @@ const routes = {
'settings-view': settingsView,
'access-servers-view': accessServersView,
'developer-settings-view': developerSettingsView,
'advanced-settings-view': advancedSettingsView,
'server-settings-view': serverSettingsView,
'arweave-uploads-view': arweaveUploadsView,
'remote-addblock-session-view': remoteAddBlockSessionView,
@@ -226,6 +228,7 @@ const SETTINGS_BORDERED_ACTION_PAGE_IDS = new Set([
'server-settings-view',
'arweave-uploads-view',
'developer-settings-view',
'advanced-settings-view',
'trusted-device-login-settings-view',
'device-view',
'device-session-view',
@@ -0,0 +1,62 @@
import { createTopBar } from '../components/topbar.js';
import {
isDeveloperToolsEnabled,
isDmFileTransferEnabled,
setDeveloperToolsEnabled,
setDmFileTransferEnabled,
} from '../services/feature-settings.js';
export const pageMeta = { id: 'advanced-settings-view', title: 'Дополнительные настройки' };
function createToggleRow({ id, title, hint, checked }) {
const label = document.createElement('label');
label.className = 'card settings-feature-toggle-row';
label.htmlFor = id;
label.innerHTML = `
<span class="settings-feature-toggle-copy">
<strong class="settings-feature-toggle-title">${title}</strong>
<span class="settings-feature-toggle-hint">${hint}</span>
</span>
<input class="settings-feature-switch" id="${id}" type="checkbox" ${checked ? 'checked' : ''} />
`;
return label;
}
export function render({ navigate, chrome }) {
const screen = document.createElement('section');
screen.className = 'stack advanced-settings-screen';
chrome?.setTopbar(createTopBar({
title: 'Дополнительные настройки',
back: { label: '←', onClick: () => navigate('settings-view') },
}));
const intro = document.createElement('div');
intro.className = 'card stack advanced-settings-intro';
intro.innerHTML = `
<p class="field-label">Скрытые возможности SHiNE</p>
<p class="meta-muted">Этот экран открывается пятью быстрыми нажатиями по логотипу в обычных настройках и имеет постоянный адрес <code>/settings/advanced</code>.</p>
`;
const developerRow = createToggleRow({
id: 'advanced-developer-tools',
title: 'Настройки разработчика',
hint: 'Показывать блок «Версии» и кнопку настроек разработчика в обычных настройках.',
checked: isDeveloperToolsEnabled(),
});
const filesRow = createToggleRow({
id: 'advanced-dm-files',
title: 'Передача файлов в личных сообщениях',
hint: 'Разрешить отправку новых зашифрованных файлов через удержание кнопки эмодзи. Полученные ранее файлы останутся доступными.',
checked: isDmFileTransferEnabled(),
});
const developerInput = developerRow.querySelector('#advanced-developer-tools');
const filesInput = filesRow.querySelector('#advanced-dm-files');
developerInput?.addEventListener('change', () => setDeveloperToolsEnabled(developerInput.checked));
filesInput?.addEventListener('change', () => setDmFileTransferEnabled(filesInput.checked));
screen.append(intro, developerRow, filesRow);
return screen;
}
+229 -8
View File
@@ -29,7 +29,13 @@ import {
} from '../components/emoji-picker.js?v=202607152130';
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
import { showToast } from '../services/channels-ux.js';
import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
import { buildDmFileTechBlock, buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
import { isDmFileTransferEnabled } from '../services/feature-settings.js';
import {
downloadAndDecryptDmFile,
encryptAndUploadDmFile,
formatDmFileSize,
} from '../services/dm-file-service.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-routes.js';
@@ -686,10 +692,10 @@ function renderLog(
const bubble = document.createElement('div');
const parsedText = parseDmTechBlocks(String(msg?.text || ''));
const autoKind = parsedText.callSummary ? 'call-tech' : '';
const autoKind = parsedText.callSummary ? 'call-tech' : (parsedText.fileAttachment ? 'file-tech' : '');
const bubbleKind = String(msg?.kind || autoKind || '').trim();
bubble.className = `bubble ${msg.from}${bubbleKind ? ` ${bubbleKind}` : ''}`;
const emojiOnly = !parsedText.callSummary && !parsedText.replyRef && isTwemojiOnlyMessage(parsedText.displayText || '');
const emojiOnly = !parsedText.callSummary && !parsedText.fileAttachment && !parsedText.replyRef && isTwemojiOnlyMessage(parsedText.displayText || '');
if (emojiOnly) {
bubble.classList.add('bubble--emoji-only');
}
@@ -734,6 +740,50 @@ function renderLog(
bubble.append(callCard);
}
if (parsedText.fileAttachment) {
const attachment = parsedText.fileAttachment;
const fileCard = document.createElement('div');
fileCard.className = 'dm-file-card';
const fileIcon = document.createElement('span');
fileIcon.className = 'dm-file-card__icon';
fileIcon.textContent = '📎';
const fileCopy = document.createElement('span');
fileCopy.className = 'dm-file-card__copy';
const fileName = document.createElement('strong');
fileName.className = 'dm-file-card__name';
fileName.textContent = attachment.name || 'file';
const fileMeta = document.createElement('span');
fileMeta.className = 'dm-file-card__meta';
fileMeta.textContent = formatDmFileSize(attachment.size);
fileCopy.append(fileName, fileMeta);
const downloadBtn = document.createElement('button');
downloadBtn.type = 'button';
downloadBtn.className = 'ui-button dm-file-card__download';
downloadBtn.textContent = 'Скачать';
downloadBtn.addEventListener('click', async (event) => {
event.stopPropagation();
if (downloadBtn.disabled) return;
downloadBtn.disabled = true;
const previousLabel = downloadBtn.textContent;
downloadBtn.textContent = 'Скачивание…';
try {
await downloadAndDecryptDmFile(attachment);
showToast('Файл расшифрован и передан браузеру для сохранения', { timeoutMs: 1600 });
} catch (error) {
showToast(error?.message || 'Не удалось скачать файл', { kind: 'error', timeoutMs: 2400 });
} finally {
downloadBtn.disabled = false;
downloadBtn.textContent = previousLabel;
}
});
fileCard.append(fileIcon, fileCopy, downloadBtn);
bubble.append(fileCard);
}
const textNode = document.createElement('div');
textNode.className = 'bubble-text';
renderMessageText(textNode, parsedText.displayText || '');
@@ -764,7 +814,7 @@ function renderLog(
bubble.append(replyBox);
}
}
if (!parsedText.callSummary) {
if (!parsedText.callSummary && !parsedText.fileAttachment) {
bubble.append(textNode);
}
@@ -1118,7 +1168,9 @@ export function render({ navigate, route, chrome }) {
</div>
<div class="emoji-picker-slot" id="chat-emoji-picker-slot" hidden></div>
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="12000" enterkeyhint="enter"></textarea>
<input id="chat-file-input" type="file" hidden />
<div class="dm-actions-col">
<button class="ghost-btn dm-file-btn" type="button" id="chat-file-toggle" aria-label="Прикрепить файл" title="Прикрепить файл" hidden>📎</button>
<button class="ghost-btn dm-emoji-btn" type="button" id="chat-emoji-toggle" aria-label="Эмодзи" title="Эмодзи"><span class="dm-emoji-btn__glyph">☺</span></button>
<button class="primary-btn dm-send-btn dm-send-icon-btn" type="submit" title="Отправить">➤</button>
</div>
@@ -1127,6 +1179,9 @@ export function render({ navigate, route, chrome }) {
const input = form.elements.message;
const emojiSlot = form.querySelector('#chat-emoji-picker-slot');
const emojiToggle = form.querySelector('#chat-emoji-toggle');
const fileToggle = form.querySelector('#chat-file-toggle');
const fileInput = form.querySelector('#chat-file-input');
const fileTransferEnabled = isDmFileTransferEnabled();
const editBanner = form.querySelector('#chat-edit-banner');
const editBannerText = form.querySelector('#chat-edit-banner-text');
const editCancelBtn = form.querySelector('#chat-edit-cancel');
@@ -1135,6 +1190,9 @@ export function render({ navigate, route, chrome }) {
let inputFocused = false;
let emojiPickerOpen = false;
let emojiSelection = null;
let emojiLongPressTimer = null;
let emojiLongPressTriggered = false;
let fileActionVisible = false;
const setHistoryLoadingState = (isLoading) => {
historyLoader.hidden = !isLoading;
@@ -1162,6 +1220,19 @@ export function render({ navigate, route, chrome }) {
emojiToggle?.setAttribute('aria-expanded', 'false');
};
const clearEmojiLongPress = () => {
if (emojiLongPressTimer != null) {
window.clearTimeout(emojiLongPressTimer);
emojiLongPressTimer = null;
}
};
const setFileActionVisible = (visible) => {
fileActionVisible = Boolean(visible && fileTransferEnabled);
if (fileToggle) fileToggle.hidden = !fileActionVisible;
syncComposerLayout();
};
// На сенсорных устройствах фокус в textarea поднимает клавиатуру поверх пикера —
// поэтому при вставке эмодзи фокусируем поле только там, где есть «точный» указатель.
const isFinePointer = window.matchMedia?.('(hover: hover) and (pointer: fine)')?.matches ?? true;
@@ -1453,6 +1524,117 @@ export function render({ navigate, route, chrome }) {
}
};
const sendFileAttachmentMessage = async (attachment) => {
const replying = activeEdit ? null : activeReply;
const fileBlock = buildDmFileTechBlock(attachment);
const replyBlock = replying ? buildDmReplyTechBlock({ baseKey: replying.baseKey }) : '';
const fallbackText = `📎 ${String(attachment?.name || 'file')}`;
const finalText = `${replyBlock}${fileBlock}${fallbackText}`;
// Как и обычное исходящее сообщение, отправка файла сразу закрывает текущий
// визуальный раздел «Новые сообщения», не дожидаясь ответа сервера.
hideUnreadSeparator({ rerender: false });
const tempId = addOutgoingPendingMessage(chatId, finalText);
renderChatLog({ scrollMode: 'latest', markAsRead: false });
scrollToLatestMessageSmart(log, { smoothIfNearBottom: true });
try {
const result = await authService.sendDirectMessage({
login: state.session.login,
toLogin: chatId,
text: finalText,
storagePwd: state.session.storagePwdInMemory,
});
markOutgoingSent(tempId, {
messageKey: result?.outgoingKey || '',
baseKey: result?.baseKey || result?.localBaseKey || '',
deliveryState: result?.deliveryState || 'accepted',
});
const localRevisionApplied = await applyLocalRevision({
localOutgoingBlobB64: result?.localOutgoingBlobB64 || '',
fallbackMessageKey: result?.outgoingKey || '',
fallbackBaseKey: result?.baseKey || result?.localBaseKey || '',
deliveryState: result?.deliveryState || 'accepted',
});
if (replying) cancelReplyMode({ restoreDraft: false });
renderChatLog({ scrollMode: 'latest', markAsRead: false });
if (localRevisionApplied) notifyUnreadStateUpdated();
scrollToLatestMessageSmart(log, { smoothIfNearBottom: true });
window.requestAnimationFrame(() => scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }));
addAppLogEntry({
level: 'info',
source: 'outgoing-dm-file',
message: `Файл отправлен для ${chatId}`,
details: {
toLogin: chatId,
fileId: attachment?.id || '',
fileSize: Number(attachment?.size || 0),
messageId: result?.outgoingKey || '',
replyToBaseKey: replying?.baseKey || '',
},
});
} catch (error) {
addChatMessage(chatId, `Ошибка отправки файла: ${error?.message || 'unknown'}`);
addAppLogEntry({
level: 'warn',
source: 'outgoing-dm-file',
message: 'Файл загружен, но DM с ключом не отправлен',
details: {
toLogin: chatId,
fileId: attachment?.id || '',
error: error?.message || 'unknown',
},
});
renderChatLog({ scrollMode: 'latest', markAsRead: false });
throw error;
}
};
const handleSelectedFile = async (file) => {
if (!fileTransferEnabled) {
showToast('Передача файлов отключена в дополнительных настройках.', { kind: 'error', timeoutMs: 1800 });
return;
}
if (activeEdit) {
showToast('Сначала завершите редактирование сообщения.', { kind: 'error', timeoutMs: 1600 });
return;
}
if (!file) return;
const originalLabel = fileToggle?.textContent || '📎';
if (fileToggle) {
fileToggle.disabled = true;
fileToggle.textContent = '…';
fileToggle.setAttribute('aria-label', 'Шифрование и загрузка файла');
}
showToast(`Шифрую и загружаю «${String(file.name || 'file')}»…`, { timeoutMs: 1600 });
try {
const attachment = await encryptAndUploadDmFile({
file,
login: state.session.login,
sessionId: state.session.sessionId,
wsUrl: authService.serverUrl,
});
await sendFileAttachmentMessage(attachment);
setFileActionVisible(false);
showToast('Файл отправлен.', { timeoutMs: 1200 });
} catch (error) {
showToast(error?.message || 'Не удалось отправить файл', { kind: 'error', timeoutMs: 2600 });
} finally {
if (fileToggle) {
fileToggle.disabled = false;
fileToggle.textContent = originalLabel;
fileToggle.setAttribute('aria-label', 'Прикрепить файл');
}
if (fileInput) fileInput.value = '';
}
};
const handleOpenActions = (msg, event) => {
const parsed = parseDmTechBlocks(String(msg?.text || ''));
const readState = resolveEffectiveReadState(getChatMessages(chatId), msg);
@@ -1465,7 +1647,7 @@ export function render({ navigate, route, chrome }) {
: '',
canReply: true,
showReadAloud: isTextToSpeechReady,
canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2 && !parsed?.callSummary,
canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2 && !parsed?.callSummary && !parsed?.fileAttachment,
canDelete: (
(msg?.from === 'out' && Number(msg?.messageType || 0) === 2)
|| (msg?.from === 'in' && Number(msg?.messageType || 0) === 1)
@@ -1577,9 +1759,38 @@ export function render({ navigate, route, chrome }) {
emojiToggle?.setAttribute('aria-expanded', 'false');
emojiToggle?.addEventListener('pointerdown', (event) => {
rememberEmojiSelection();
emojiLongPressTriggered = false;
clearEmojiLongPress();
if (fileTransferEnabled) {
emojiLongPressTimer = window.setTimeout(() => {
emojiLongPressTimer = null;
emojiLongPressTriggered = true;
closeEmojiPicker();
setFileActionVisible(true);
}, 520);
}
try {
emojiToggle.setPointerCapture?.(event.pointerId);
} catch {
// Pointer capture is optional; long press still works without it.
}
event.preventDefault();
});
emojiToggle?.addEventListener('click', () => {
emojiToggle?.addEventListener('pointerup', clearEmojiLongPress);
emojiToggle?.addEventListener('pointercancel', clearEmojiLongPress);
emojiToggle?.addEventListener('pointerleave', (event) => {
if (event.buttons) clearEmojiLongPress();
});
emojiToggle?.addEventListener('contextmenu', (event) => {
if (fileTransferEnabled) event.preventDefault();
});
emojiToggle?.addEventListener('click', (event) => {
if (emojiLongPressTriggered) {
emojiLongPressTriggered = false;
event.preventDefault();
return;
}
setFileActionVisible(false);
emojiPickerOpen = !emojiPickerOpen;
emojiSlot.hidden = !emojiPickerOpen;
emojiToggle.setAttribute('aria-expanded', String(emojiPickerOpen));
@@ -1587,9 +1798,18 @@ export function render({ navigate, route, chrome }) {
// открытии пикера прячем клавиатуру, чтобы поле ввода оставалось видно.
if (emojiPickerOpen && !isFinePointer) input?.blur();
});
fileToggle?.addEventListener('click', () => {
if (!fileTransferEnabled || fileToggle.disabled) return;
fileInput?.click();
});
fileInput?.addEventListener('change', () => {
const file = fileInput.files?.[0] || null;
if (file) void handleSelectedFile(file);
});
screen.addEventListener('pointerdown', (event) => {
if (!emojiPickerOpen || form.contains(event.target)) return;
closeEmojiPicker();
if (form.contains(event.target)) return;
if (emojiPickerOpen) closeEmojiPicker();
if (fileActionVisible) setFileActionVisible(false);
});
form.querySelector('.dm-send-btn')?.addEventListener('pointerdown', (event) => {
event.preventDefault();
@@ -1658,6 +1878,7 @@ export function render({ navigate, route, chrome }) {
screen.cleanup = () => {
hideUnreadSeparator({ rerender: false });
stopAllTwemojiAnimations();
clearEmojiLongPress();
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
window.removeEventListener('shine-dm-delivery-updated', handleDeliveryRefresh);
boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll);
+64 -42
View File
@@ -1,5 +1,6 @@
import { createTopBar } from '../components/topbar.js';
import { addAppLogEntry, authService, closeSavedProfile, state } from '../state.js';
import { isDeveloperToolsEnabled } from '../services/feature-settings.js';
export const pageMeta = { id: 'settings-view', title: 'Настройки' };
@@ -35,6 +36,24 @@ export function render({navigate, chrome}) {
back: { label: '←', onClick: () => navigate('profile-view') },
}));
const logoButton = document.createElement('button');
logoButton.type = 'button';
logoButton.className = 'settings-shine-logo-button';
logoButton.setAttribute('aria-label', 'Логотип SHiNE');
logoButton.title = 'SHiNE';
logoButton.innerHTML = '<img class="settings-shine-logo" src="/img/shine-logo-transparent-final_big.png" alt="SHiNE" />';
let logoTapCount = 0;
let lastLogoTapAt = 0;
logoButton.addEventListener('click', () => {
const now = Date.now();
logoTapCount = now - lastLogoTapAt <= 900 ? logoTapCount + 1 : 1;
lastLogoTapAt = now;
if (logoTapCount < 5) return;
logoTapCount = 0;
navigate('advanced-settings-view');
});
const card = document.createElement('div');
card.className = 'card stack';
card.innerHTML = `
@@ -93,61 +112,64 @@ export function render({navigate, chrome}) {
}
});
const versionCard = document.createElement('div');
versionCard.className = 'card stack';
screen.append(logoButton, card);
const title = document.createElement('p');
title.className = 'field-label';
title.textContent = 'Версии';
if (isDeveloperToolsEnabled()) {
const versionCard = document.createElement('div');
versionCard.className = 'card stack';
const clientVersion = document.createElement('p');
clientVersion.className = 'meta-muted';
clientVersion.textContent = `Клиент: ${formatVersionForUi(window.__SHINE_CLIENT_VERSION__)}`;
const title = document.createElement('p');
title.className = 'field-label';
title.textContent = 'Версии';
const uiBuild = document.createElement('p');
uiBuild.className = 'meta-muted';
uiBuild.textContent = `Сборка UI: ${formatVersionForUi(window.__SHINE_BUILD_HASH__)}`;
const clientVersion = document.createElement('p');
clientVersion.className = 'meta-muted';
clientVersion.textContent = `Клиент: ${formatVersionForUi(window.__SHINE_CLIENT_VERSION__)}`;
const serverVersion = document.createElement('p');
serverVersion.className = 'meta-muted';
serverVersion.textContent = 'Сервер: загружается...';
const uiBuild = document.createElement('p');
uiBuild.className = 'meta-muted';
uiBuild.textContent = `Сборка UI: ${formatVersionForUi(window.__SHINE_BUILD_HASH__)}`;
versionCard.append(title, clientVersion, uiBuild, serverVersion);
const serverVersion = document.createElement('p');
serverVersion.className = 'meta-muted';
serverVersion.textContent = 'Сервер: загружается...';
const developerCard = document.createElement('div');
developerCard.className = 'card stack';
developerCard.innerHTML = `
<button class="shine-btn shine-btn--settings" type="button" id="settings-developer">Настройки разработчика</button>
`;
developerCard.querySelector('#settings-developer').addEventListener('click', () => navigate('developer-settings-view'));
versionCard.append(title, clientVersion, uiBuild, serverVersion);
void (async () => {
try {
let value = '';
const developerCard = document.createElement('div');
developerCard.className = 'card stack';
developerCard.innerHTML = `
<button class="shine-btn shine-btn--settings" type="button" id="settings-developer">Настройки разработчика</button>
`;
developerCard.querySelector('#settings-developer').addEventListener('click', () => navigate('developer-settings-view'));
void (async () => {
try {
const pingResp = await authService.ws.request('Ping', { ts: Date.now() }, 7000);
value = String(pingResp?.payload?.serverVersion || pingResp?.serverVersion || '').trim();
} catch {
// fallback below
}
let value = '';
try {
const pingResp = await authService.ws.request('Ping', { ts: Date.now() }, 7000);
value = String(pingResp?.payload?.serverVersion || pingResp?.serverVersion || '').trim();
} catch {
// fallback below
}
if (!value) {
const infoResp = await authService.ws.request('GetServerInfo', {});
value = String(infoResp?.payload?.version || '').trim();
if (!value) {
const infoResp = await authService.ws.request('GetServerInfo', {});
value = String(infoResp?.payload?.version || '').trim();
}
if (!isDisposed) serverVersion.textContent = `Сервер: ${formatVersionForUi(value)}`;
} catch {
if (!isDisposed) {
serverVersion.textContent = 'Сервер: недоступно';
}
}
if (!isDisposed) serverVersion.textContent = `Сервер: ${formatVersionForUi(value)}`;
} catch {
if (!isDisposed) {
serverVersion.textContent = 'Сервер: недоступно';
}
}
})();
})();
screen.append(versionCard, developerCard);
}
screen.cleanup = () => {
isDisposed = true;
};
screen.append(card);
screen.append(versionCard);
screen.append(developerCard);
return screen;
}
+3
View File
@@ -36,6 +36,7 @@ const PRETTY_PATHS = new Map([
['server-settings-view', 'settings/servers'],
['arweave-uploads-view', 'settings/arweave-uploads'],
['developer-settings-view', 'settings/developer'],
['advanced-settings-view', 'settings/advanced'],
['trusted-device-login-settings-view', 'settings/device-login'],
['language-view', 'settings/language'],
['app-log-view', 'settings/app-log'],
@@ -322,6 +323,7 @@ export function parseRouteFromPath(pathname = '') {
if (sub === 'servers') return { pageId: 'server-settings-view', params: {} };
if (sub === 'arweave-uploads') return { pageId: 'arweave-uploads-view', params: {} };
if (sub === 'developer') return { pageId: 'developer-settings-view', params: {} };
if (sub === 'advanced') return { pageId: 'advanced-settings-view', params: {} };
if (sub === 'device-login') return { pageId: 'trusted-device-login-settings-view', params: {} };
if (sub === 'language') return { pageId: 'language-view', params: {} };
if (sub === 'app-log') return { pageId: 'app-log-view', params: {} };
@@ -447,6 +449,7 @@ export function resolveToolbarActive(pageId) {
pageId === 'settings-view' ||
pageId === 'access-servers-view' ||
pageId === 'developer-settings-view' ||
pageId === 'advanced-settings-view' ||
pageId === 'server-settings-view' ||
pageId === 'arweave-uploads-view' ||
pageId === 'remote-addblock-session-view' ||
+204
View File
@@ -0,0 +1,204 @@
import {
base64UrlToBytes,
bytesToBase58,
bytesToBase64Url,
decryptBytesAesGcm,
encryptBytesAesGcm,
importPkcs8Ed25519,
randomBytes,
sha256Bytes,
signBase64,
} from './crypto-utils.js';
import { loadSessionMaterial } from './key-vault.js';
export const DM_FILE_MAX_SOURCE_BYTES = 50 * 1024 * 1024;
function normalizeFileName(value = '') {
const cleaned = String(value || 'file')
.replace(/[\\/\u0000-\u001f\u007f]/g, '_')
.trim();
return (cleaned || 'file').slice(0, 180);
}
function wsUrlToHttpBase(wsUrl = '') {
const parsed = new URL(String(wsUrl || ''), window.location.href);
if (parsed.protocol === 'wss:') parsed.protocol = 'https:';
else if (parsed.protocol === 'ws:') parsed.protocol = 'http:';
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('Не удалось определить HTTP-адрес сервера SHiNE');
}
parsed.pathname = '/';
parsed.search = '';
parsed.hash = '';
return parsed.origin;
}
function uploadPreimage({ sessionId, fileId, encryptedSize, timeMs }) {
return `DM_FILE_UPLOAD_V1:${sessionId}:${fileId}:${Number(encryptedSize)}:${Number(timeMs)}`;
}
async function readErrorMessage(response) {
try {
const raw = String(await response.text()).trim();
if (!raw) return '';
try {
const payload = JSON.parse(raw);
return String(payload?.message || payload?.error || raw).trim();
} catch {
return raw;
}
} catch {
return '';
}
}
export function formatDmFileSize(bytes = 0) {
const value = Math.max(0, Number(bytes || 0));
if (value < 1024) return `${value} Б`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} КБ`;
return `${(value / (1024 * 1024)).toFixed(value >= 10 * 1024 * 1024 ? 0 : 1)} МБ`;
}
export async function encryptAndUploadDmFile({ file, login, sessionId, wsUrl } = {}) {
if (!file || typeof file.arrayBuffer !== 'function') throw new Error('Файл не выбран');
const cleanLogin = String(login || '').trim();
const cleanSessionId = String(sessionId || '').trim();
if (!cleanLogin || !cleanSessionId) throw new Error('Нет активной пользовательской сессии');
if (Number(file.size || 0) > DM_FILE_MAX_SOURCE_BYTES) {
throw new Error(`Файл слишком большой. Текущий лимит — ${formatDmFileSize(DM_FILE_MAX_SOURCE_BYTES)}.`);
}
const sessionMaterial = await loadSessionMaterial(cleanLogin);
if (!sessionMaterial?.sessionPrivPkcs8) {
throw new Error('На устройстве нет сохранённого session key для загрузки файла');
}
if (sessionMaterial.sessionId && String(sessionMaterial.sessionId) !== cleanSessionId) {
throw new Error('Сохранённый session key относится к другой сессии');
}
const sourceBytes = new Uint8Array(await file.arrayBuffer());
const fileKey = randomBytes(32);
const iv = randomBytes(12);
let encryptedBytes;
try {
encryptedBytes = await encryptBytesAesGcm(sourceBytes, fileKey, iv);
} finally {
sourceBytes.fill(0);
}
try {
const fileHash = await sha256Bytes(encryptedBytes);
const fileId = bytesToBase58(fileHash);
const serverBase = wsUrlToHttpBase(wsUrl);
const fileUrl = `${serverBase}/dm-files/${fileId}`;
const timeMs = Date.now();
const privateKey = await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8);
const signatureB64 = await signBase64(privateKey, uploadPreimage({
sessionId: cleanSessionId,
fileId,
encryptedSize: encryptedBytes.byteLength,
timeMs,
}));
let response;
try {
response = await fetch(fileUrl, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
'X-Shine-Session-Id': cleanSessionId,
'X-Shine-Time-Ms': String(timeMs),
'X-Shine-Content-Length': String(encryptedBytes.byteLength),
'X-Shine-Signature': signatureB64,
},
body: encryptedBytes,
cache: 'no-store',
});
} catch (error) {
throw new Error(`Не удалось загрузить файл на сервер отправителя: ${error?.message || 'network error'}`);
}
if (!response.ok) {
const detail = await readErrorMessage(response);
throw new Error(detail || `Сервер отклонил файл (HTTP ${response.status})`);
}
return {
version: 1,
id: fileId,
url: fileUrl,
keyB64Url: bytesToBase64Url(fileKey),
ivB64Url: bytesToBase64Url(iv),
name: normalizeFileName(file.name),
mime: String(file.type || 'application/octet-stream').trim().slice(0, 160) || 'application/octet-stream',
size: Number(file.size || 0),
encryptedSize: encryptedBytes.byteLength,
};
} finally {
fileKey.fill(0);
iv.fill(0);
encryptedBytes?.fill(0);
}
}
export async function downloadAndDecryptDmFile(attachment = {}) {
const fileId = String(attachment?.id || '').trim();
const fileUrl = String(attachment?.url || '').trim();
const keyB64Url = String(attachment?.keyB64Url || '').trim();
const ivB64Url = String(attachment?.ivB64Url || '').trim();
if (!fileId || !fileUrl || !keyB64Url || !ivB64Url) {
throw new Error('В сообщении не хватает данных для расшифровки файла');
}
let response;
try {
response = await fetch(fileUrl, { method: 'GET', cache: 'no-store' });
} catch (error) {
throw new Error(`Не удалось скачать зашифрованный файл: ${error?.message || 'network error'}`);
}
if (!response.ok) {
throw new Error(response.status === 404 ? 'Файл больше не найден на сервере отправителя' : `Ошибка скачивания файла (HTTP ${response.status})`);
}
const encryptedBytes = new Uint8Array(await response.arrayBuffer());
const actualId = bytesToBase58(await sha256Bytes(encryptedBytes));
if (actualId !== fileId) {
encryptedBytes.fill(0);
throw new Error('SHA-256 файла не совпал: зашифрованный файл повреждён или подменён');
}
const keyBytes = base64UrlToBytes(keyB64Url);
const ivBytes = base64UrlToBytes(ivB64Url);
let plainBytes;
try {
plainBytes = await decryptBytesAesGcm(encryptedBytes, keyBytes, ivBytes);
} catch {
throw new Error('Не удалось расшифровать файл: ключ или содержимое повреждены');
} finally {
encryptedBytes.fill(0);
keyBytes.fill(0);
ivBytes.fill(0);
}
const expectedSize = Number(attachment?.size || 0);
if (expectedSize >= 0 && plainBytes.byteLength !== expectedSize) {
plainBytes.fill(0);
throw new Error('Размер расшифрованного файла не совпал с сообщением');
}
const blob = new Blob([plainBytes], {
type: String(attachment?.mime || 'application/octet-stream') || 'application/octet-stream',
});
plainBytes.fill(0);
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = normalizeFileName(attachment?.name || 'file');
anchor.rel = 'noopener';
anchor.style.display = 'none';
document.body.append(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
}
+50
View File
@@ -8,6 +8,7 @@ function defaultParsed(rawText = '') {
blocks: [],
replyRef: null,
callSummary: null,
fileAttachment: null,
};
}
@@ -87,6 +88,51 @@ export function buildDmReplyTechBlock({ baseKey = '' } = {}) {
return `<S:reply;v=1;id=${cleanBaseKey}>`;
}
function decodeFileField(value = '') {
try {
return decodeURIComponent(String(value || ''));
} catch {
return String(value || '');
}
}
function normalizeFileAttachment(fields = {}) {
const id = String(fields.id || '').trim();
const url = decodeFileField(fields.url || '').trim();
const keyB64Url = String(fields.key || '').trim();
const ivB64Url = String(fields.iv || '').trim();
const name = decodeFileField(fields.name || '').trim() || 'file';
const mime = decodeFileField(fields.mime || '').trim() || 'application/octet-stream';
const size = Number(fields.size || 0);
const encryptedSize = Number(fields.encsize || 0);
if (!id || !url || !keyB64Url || !ivB64Url || !Number.isFinite(size) || size < 0) return null;
return {
version: Number(fields.v || 0),
id,
url,
keyB64Url,
ivB64Url,
name,
mime,
size,
encryptedSize: Number.isFinite(encryptedSize) && encryptedSize > 0 ? encryptedSize : 0,
};
}
export function buildDmFileTechBlock(attachment = {}) {
const id = String(attachment?.id || '').trim();
const url = String(attachment?.url || '').trim();
const keyB64Url = String(attachment?.keyB64Url || '').trim();
const ivB64Url = String(attachment?.ivB64Url || '').trim();
const size = Math.max(0, Math.floor(Number(attachment?.size || 0)));
const encryptedSize = Math.max(0, Math.floor(Number(attachment?.encryptedSize || 0)));
if (!id || !url || !keyB64Url || !ivB64Url) throw new Error('Не хватает данных для технического блока файла');
const name = encodeURIComponent(String(attachment?.name || 'file'));
const mime = encodeURIComponent(String(attachment?.mime || 'application/octet-stream'));
const encodedUrl = encodeURIComponent(url);
return `<S:file;v=1;id=${id};url=${encodedUrl};key=${keyB64Url};iv=${ivB64Url};name=${name};mime=${mime};size=${size};encsize=${encryptedSize}>`;
}
export function parseDmTechBlocks(rawText = '') {
const text = String(rawText || '');
if (!hasTechPrefix(text)) return defaultParsed(text);
@@ -95,6 +141,7 @@ export function parseDmTechBlocks(rawText = '') {
let cursor = 0;
let replyRef = null;
let callSummary = null;
let fileAttachment = null;
while (hasTechPrefix(text, cursor)) {
const end = text.indexOf('>', cursor);
@@ -142,6 +189,8 @@ export function parseDmTechBlocks(rawText = '') {
reason: String(fields.reason || '').trim().toLowerCase(),
};
}
} else if (kind === 'file' && !fileAttachment) {
fileAttachment = normalizeFileAttachment(fields);
}
cursor = end + 1;
@@ -157,5 +206,6 @@ export function parseDmTechBlocks(rawText = '') {
blocks,
replyRef,
callSummary,
fileAttachment,
};
}
+43
View File
@@ -0,0 +1,43 @@
const STORAGE_KEYS = Object.freeze({
developerTools: 'shine-feature-developer-tools-v1',
dmFileTransfer: 'shine-feature-dm-file-transfer-v1',
});
function readBoolean(key, defaultValue = true) {
try {
const raw = localStorage.getItem(key);
if (raw == null) return Boolean(defaultValue);
return raw !== '0' && raw !== 'false';
} catch {
return Boolean(defaultValue);
}
}
function writeBoolean(key, enabled) {
const value = Boolean(enabled);
try {
localStorage.setItem(key, value ? '1' : '0');
} catch {
// В приватном режиме/при запрете storage оставляем значение только на уровне default.
}
window.dispatchEvent(new CustomEvent('shine-feature-settings-updated', {
detail: { key, enabled: value },
}));
return value;
}
export function isDeveloperToolsEnabled() {
return readBoolean(STORAGE_KEYS.developerTools, true);
}
export function setDeveloperToolsEnabled(enabled) {
return writeBoolean(STORAGE_KEYS.developerTools, enabled);
}
export function isDmFileTransferEnabled() {
return readBoolean(STORAGE_KEYS.dmFileTransfer, true);
}
export function setDmFileTransferEnabled(enabled) {
return writeBoolean(STORAGE_KEYS.dmFileTransfer, enabled);
}