Files
SHiNE-server/shine-UI/js/services/dm-file-service.js
T

205 lines
7.4 KiB
JavaScript

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);
}