ciphermesh 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +251 -0
- package/README.pt-BR.md +253 -0
- package/bin/ciphermesh.js +34 -0
- package/docs/ARCHITECTURE.md +1188 -0
- package/docs/SETUP.md +305 -0
- package/docs/demo.svg +46 -0
- package/package.json +87 -0
- package/src/client/ChatController.js +2476 -0
- package/src/client/Connection.js +129 -0
- package/src/client/FileTransfer.js +488 -0
- package/src/client/ImagePreview.js +88 -0
- package/src/client/UI.js +1830 -0
- package/src/client/index.js +231 -0
- package/src/crypto/CertPinStore.js +79 -0
- package/src/crypto/DeniableEncrypt.js +53 -0
- package/src/crypto/DoubleRatchet.js +574 -0
- package/src/crypto/Handshake.js +219 -0
- package/src/crypto/HistoryStore.js +241 -0
- package/src/crypto/IdentityBackup.js +70 -0
- package/src/crypto/KeyManager.js +134 -0
- package/src/crypto/MessageCrypto.js +181 -0
- package/src/crypto/NonceManager.js +72 -0
- package/src/crypto/SealedSender.js +58 -0
- package/src/crypto/SenderKey.js +204 -0
- package/src/crypto/StateManager.js +138 -0
- package/src/crypto/TrustStore.js +216 -0
- package/src/p2p/Discovery.js +80 -0
- package/src/p2p/P2PChatController.js +1856 -0
- package/src/p2p/PeerConnectionManager.js +252 -0
- package/src/p2p/PeerServer.js +68 -0
- package/src/p2p/index.js +219 -0
- package/src/protocol/messages.js +138 -0
- package/src/protocol/validators.js +175 -0
- package/src/server/CertManager.js +173 -0
- package/src/server/MessageRouter.js +80 -0
- package/src/server/OfflineQueue.js +124 -0
- package/src/server/SessionManager.js +296 -0
- package/src/server/WebSocketServer.js +632 -0
- package/src/server/index.js +89 -0
- package/src/shared/AuditLog.js +91 -0
- package/src/shared/PluginManager.js +83 -0
- package/src/shared/banner.js +271 -0
- package/src/shared/commandSuggest.js +59 -0
- package/src/shared/config.js +90 -0
- package/src/shared/constants.js +126 -0
- package/src/shared/coverTraffic.js +34 -0
- package/src/shared/dnd.js +60 -0
- package/src/shared/emoji.js +17 -0
- package/src/shared/fuzzy.js +40 -0
- package/src/shared/invite.js +61 -0
- package/src/shared/keyArt.js +66 -0
- package/src/shared/logger.js +38 -0
- package/src/shared/panic.js +38 -0
- package/src/shared/prompt.js +31 -0
- package/src/shared/terminalGraphics.js +72 -0
- package/src/shared/themes.js +36 -0
- package/src/shared/voiceNote.js +128 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { RECONNECT_BASE_MS, RECONNECT_MAX_MS } from '../shared/constants.js';
|
|
4
|
+
import { CertPinStore, PinResult } from '../crypto/CertPinStore.js';
|
|
5
|
+
|
|
6
|
+
export class Connection extends EventEmitter {
|
|
7
|
+
#url;
|
|
8
|
+
#ws;
|
|
9
|
+
#reconnectDelay;
|
|
10
|
+
#shouldReconnect;
|
|
11
|
+
#connected;
|
|
12
|
+
#pinStore;
|
|
13
|
+
#host;
|
|
14
|
+
|
|
15
|
+
constructor(url) {
|
|
16
|
+
super();
|
|
17
|
+
this.#url = url;
|
|
18
|
+
this.#reconnectDelay = RECONNECT_BASE_MS;
|
|
19
|
+
this.#shouldReconnect = true;
|
|
20
|
+
this.#connected = false;
|
|
21
|
+
this.#pinStore = new CertPinStore();
|
|
22
|
+
try {
|
|
23
|
+
this.#host = new URL(url).host;
|
|
24
|
+
} catch {
|
|
25
|
+
this.#host = url;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Trust-on-first-use pin of the server TLS certificate. Emits 'cert-pinned'
|
|
30
|
+
// on first sight and 'cert-mismatch' if it later changes (possible MITM).
|
|
31
|
+
#checkCertPin() {
|
|
32
|
+
if (!this.#url.startsWith('wss://')) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const socket = this.#ws?._socket;
|
|
36
|
+
const cert = socket?.getPeerCertificate?.();
|
|
37
|
+
const fingerprint = cert?.fingerprint256 || null;
|
|
38
|
+
const result = this.#pinStore.check(this.#host, fingerprint);
|
|
39
|
+
if (result === PinResult.PINNED) {
|
|
40
|
+
this.emit('cert-pinned', { host: this.#host, fingerprint });
|
|
41
|
+
} else if (result === PinResult.MISMATCH) {
|
|
42
|
+
this.emit('cert-mismatch', {
|
|
43
|
+
host: this.#host,
|
|
44
|
+
expected: this.#pinStore.getPinned(this.#host),
|
|
45
|
+
got: fingerprint,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
connect() {
|
|
51
|
+
this.#shouldReconnect = true;
|
|
52
|
+
this.#createSocket();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#createSocket() {
|
|
56
|
+
const opts = this.#url.startsWith('wss://') ? { rejectUnauthorized: false } : {};
|
|
57
|
+
this.#ws = new WebSocket(this.#url, opts);
|
|
58
|
+
|
|
59
|
+
this.#ws.on('open', () => {
|
|
60
|
+
this.#connected = true;
|
|
61
|
+
this.#reconnectDelay = RECONNECT_BASE_MS;
|
|
62
|
+
this.#checkCertPin();
|
|
63
|
+
this.emit('connected');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
this.#ws.on('message', (data) => {
|
|
67
|
+
try {
|
|
68
|
+
const msg = JSON.parse(data.toString('utf-8'));
|
|
69
|
+
this.emit('message', msg);
|
|
70
|
+
} catch {
|
|
71
|
+
// Ignore malformed messages
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
this.#ws.on('close', () => {
|
|
76
|
+
const wasConnected = this.#connected;
|
|
77
|
+
this.#connected = false;
|
|
78
|
+
|
|
79
|
+
if (wasConnected) {
|
|
80
|
+
this.emit('disconnected');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (this.#shouldReconnect) {
|
|
84
|
+
this.#scheduleReconnect();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
this.#ws.on('error', () => {
|
|
89
|
+
// Error is followed by 'close', reconnect handled there
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
this.#ws.on('ping', () => {
|
|
93
|
+
this.#ws.pong();
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#scheduleReconnect() {
|
|
98
|
+
setTimeout(() => {
|
|
99
|
+
if (this.#shouldReconnect) {
|
|
100
|
+
this.emit('reconnecting', this.#reconnectDelay);
|
|
101
|
+
this.#createSocket();
|
|
102
|
+
this.#reconnectDelay = Math.min(this.#reconnectDelay * 2, RECONNECT_MAX_MS);
|
|
103
|
+
}
|
|
104
|
+
}, this.#reconnectDelay);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
send(msg) {
|
|
108
|
+
if (this.#connected && this.#ws.readyState === WebSocket.OPEN) {
|
|
109
|
+
this.#ws.send(JSON.stringify(msg));
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
get connected() {
|
|
116
|
+
return this.#connected;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
get url() {
|
|
120
|
+
return this.#url;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
close() {
|
|
124
|
+
this.#shouldReconnect = false;
|
|
125
|
+
if (this.#ws) {
|
|
126
|
+
this.#ws.close();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import { createReadStream, existsSync, mkdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, resolve, join } from 'node:path';
|
|
4
|
+
import { createHash, randomFillSync } from 'node:crypto';
|
|
5
|
+
import { MAX_FILE_SIZE, FILE_CHUNK_SIZE } from '../shared/constants.js';
|
|
6
|
+
|
|
7
|
+
const TRANSFER_TIMEOUT_MS = 30_000;
|
|
8
|
+
const SEND_INTERVAL_MS = 40; // ~25 chunks/sec
|
|
9
|
+
const RESUME_KEEP_MS = 5 * 60_000; // keep partials and resend cache for 5 min
|
|
10
|
+
const MAX_RESUME_ATTEMPTS = 3;
|
|
11
|
+
const MAX_RESEND_BATCH = 300;
|
|
12
|
+
|
|
13
|
+
export class FileTransfer {
|
|
14
|
+
#outgoing; // Map<transferId, { interval, resolve, chunks, skip }>
|
|
15
|
+
#sentCache; // Map<transferId, { chunks, timer }> — to resend lost chunks
|
|
16
|
+
#incoming; // Map<transferId, { fileName, fileSize, totalChunks, chunks, sha256, from, timer, attempts }>
|
|
17
|
+
#partials; // Map<sha256, { chunks, received, fileSize, totalChunks, timer }>
|
|
18
|
+
#downloadDir;
|
|
19
|
+
#transferTimeoutMs;
|
|
20
|
+
#resumeKeepMs;
|
|
21
|
+
#acceptTimeoutMs;
|
|
22
|
+
|
|
23
|
+
constructor(options = {}) {
|
|
24
|
+
this.#outgoing = new Map();
|
|
25
|
+
this.#sentCache = new Map();
|
|
26
|
+
this.#incoming = new Map();
|
|
27
|
+
this.#partials = new Map();
|
|
28
|
+
this.#downloadDir = resolve(options.downloadDir || './downloads');
|
|
29
|
+
this.#transferTimeoutMs = options.transferTimeoutMs || TRANSFER_TIMEOUT_MS;
|
|
30
|
+
this.#resumeKeepMs = options.resumeKeepMs || RESUME_KEEP_MS;
|
|
31
|
+
this.#acceptTimeoutMs = options.acceptTimeoutMs || 60_000;
|
|
32
|
+
if (!existsSync(this.#downloadDir)) {
|
|
33
|
+
mkdirSync(this.#downloadDir, { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Send a file to all peers.
|
|
39
|
+
* @param {string} filePath - Absolute or relative path to file
|
|
40
|
+
* @param {Function} broadcastFn - (payloadObj) => void, broadcasts encrypted payload
|
|
41
|
+
* @param {object} callbacks - { onProgress(percent, text), onError(text), onComplete(text) }
|
|
42
|
+
*/
|
|
43
|
+
async initSend(filePath, broadcastFn, callbacks) {
|
|
44
|
+
const absPath = resolve(filePath);
|
|
45
|
+
|
|
46
|
+
if (!existsSync(absPath)) {
|
|
47
|
+
callbacks.onError(`File not found: ${filePath}`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const stat = statSync(absPath);
|
|
52
|
+
if (!stat.isFile()) {
|
|
53
|
+
callbacks.onError('Path is not a file');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (stat.size > MAX_FILE_SIZE) {
|
|
58
|
+
callbacks.onError(`File too large (${(stat.size / 1024 / 1024).toFixed(1)}MB). Max: 50MB`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (stat.size === 0) {
|
|
63
|
+
callbacks.onError('Empty file');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const fileName = basename(absPath);
|
|
68
|
+
const totalChunks = Math.ceil(stat.size / FILE_CHUNK_SIZE);
|
|
69
|
+
const transferId = Math.random().toString(36).slice(2, 10);
|
|
70
|
+
|
|
71
|
+
// Compute SHA-256
|
|
72
|
+
const sha256 = await this.#computeSHA256(absPath);
|
|
73
|
+
|
|
74
|
+
// Send file_offer
|
|
75
|
+
broadcastFn({
|
|
76
|
+
action: 'file_offer',
|
|
77
|
+
transferId,
|
|
78
|
+
fileName,
|
|
79
|
+
fileSize: stat.size,
|
|
80
|
+
totalChunks,
|
|
81
|
+
sha256,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
callbacks.onProgress(0, `Waiting for the recipient to accept ${fileName}...`);
|
|
85
|
+
|
|
86
|
+
// Read the chunks now, but WAIT for the receiver to accept before streaming
|
|
87
|
+
// (so files are never pushed without consent).
|
|
88
|
+
const chunks = await this.#readChunks(absPath);
|
|
89
|
+
|
|
90
|
+
return new Promise((resolveP) => {
|
|
91
|
+
const acceptTimer = setTimeout(() => {
|
|
92
|
+
this.#outgoing.delete(transferId);
|
|
93
|
+
callbacks.onError(`${fileName}: offer was not accepted in time`);
|
|
94
|
+
resolveP();
|
|
95
|
+
}, this.#acceptTimeoutMs);
|
|
96
|
+
if (acceptTimer.unref) {
|
|
97
|
+
acceptTimer.unref();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
this.#outgoing.set(transferId, {
|
|
101
|
+
interval: null,
|
|
102
|
+
resolve: resolveP,
|
|
103
|
+
chunks,
|
|
104
|
+
skip: new Set(),
|
|
105
|
+
pending: true,
|
|
106
|
+
acceptTimer,
|
|
107
|
+
broadcastFn,
|
|
108
|
+
callbacks,
|
|
109
|
+
fileName,
|
|
110
|
+
totalChunks,
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Receiver accepted the offer — start streaming (honouring resume `have`). */
|
|
116
|
+
handleFileAccept(fromSessionId, data) {
|
|
117
|
+
const transfer = this.#outgoing.get(data.transferId);
|
|
118
|
+
if (!transfer || !transfer.pending) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
transfer.pending = false;
|
|
122
|
+
clearTimeout(transfer.acceptTimer);
|
|
123
|
+
if (Array.isArray(data.have)) {
|
|
124
|
+
for (const i of data.have) {
|
|
125
|
+
if (Number.isInteger(i) && i >= 0) {
|
|
126
|
+
transfer.skip.add(i);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
this.#beginStreaming(data.transferId);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Receiver rejected the offer — abort the pending transfer. */
|
|
134
|
+
handleFileReject(fromSessionId, data) {
|
|
135
|
+
const transfer = this.#outgoing.get(data.transferId);
|
|
136
|
+
if (!transfer) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
clearTimeout(transfer.acceptTimer);
|
|
140
|
+
if (transfer.interval) {
|
|
141
|
+
clearInterval(transfer.interval);
|
|
142
|
+
}
|
|
143
|
+
this.#outgoing.delete(data.transferId);
|
|
144
|
+
transfer.callbacks.onError(`${transfer.fileName}: rejected by the recipient`);
|
|
145
|
+
transfer.resolve();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#beginStreaming(transferId) {
|
|
149
|
+
const transfer = this.#outgoing.get(transferId);
|
|
150
|
+
if (!transfer) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const { chunks, skip, broadcastFn, callbacks, fileName, totalChunks, resolve } = transfer;
|
|
154
|
+
let chunkIndex = 0;
|
|
155
|
+
|
|
156
|
+
const interval = setInterval(() => {
|
|
157
|
+
try {
|
|
158
|
+
while (chunkIndex < chunks.length && skip.has(chunkIndex)) {
|
|
159
|
+
chunkIndex++;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (chunkIndex >= chunks.length) {
|
|
163
|
+
clearInterval(interval);
|
|
164
|
+
this.#outgoing.delete(transferId);
|
|
165
|
+
this.#cacheSent(transferId, chunks);
|
|
166
|
+
|
|
167
|
+
broadcastFn({ action: 'file_complete', transferId });
|
|
168
|
+
callbacks.onComplete(`${fileName} sent successfully`);
|
|
169
|
+
resolve();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
broadcastFn({
|
|
174
|
+
action: 'file_chunk',
|
|
175
|
+
transferId,
|
|
176
|
+
chunkIndex,
|
|
177
|
+
data: chunks[chunkIndex].toString('base64'),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
chunkIndex++;
|
|
181
|
+
const percent = Math.round((chunkIndex / totalChunks) * 100);
|
|
182
|
+
callbacks.onProgress(percent, `Sending ${fileName}`);
|
|
183
|
+
} catch (e) {
|
|
184
|
+
// A send/crypto failure must not crash the whole client — abort this
|
|
185
|
+
// transfer and report it.
|
|
186
|
+
clearInterval(interval);
|
|
187
|
+
this.#outgoing.delete(transferId);
|
|
188
|
+
callbacks.onError(`Failed to send ${fileName}: ${e.message}`);
|
|
189
|
+
resolve();
|
|
190
|
+
}
|
|
191
|
+
}, SEND_INTERVAL_MS);
|
|
192
|
+
|
|
193
|
+
transfer.interval = interval;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Keep chunks after sending so we can answer file_resume_request
|
|
197
|
+
#cacheSent(transferId, chunks) {
|
|
198
|
+
const old = this.#sentCache.get(transferId);
|
|
199
|
+
if (old) {
|
|
200
|
+
clearTimeout(old.timer);
|
|
201
|
+
}
|
|
202
|
+
this.#sentCache.set(transferId, {
|
|
203
|
+
chunks,
|
|
204
|
+
timer: setTimeout(() => this.#sentCache.delete(transferId), this.#resumeKeepMs),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Receiver reported which chunks it already has (resume after re-offer) — skip them when sending.
|
|
210
|
+
*/
|
|
211
|
+
handleFileHave(fromSessionId, data) {
|
|
212
|
+
const transfer = this.#outgoing.get(data.transferId);
|
|
213
|
+
if (!transfer || !Array.isArray(data.have)) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
for (const i of data.have) {
|
|
217
|
+
if (Number.isInteger(i) && i >= 0) {
|
|
218
|
+
transfer.skip.add(i);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Chunks requested in a file_resume_request, from the active send or the cache.
|
|
225
|
+
* @returns {Array<{index: number, data: string}>|null}
|
|
226
|
+
*/
|
|
227
|
+
getChunksForResend(transferId, missing) {
|
|
228
|
+
const source = this.#sentCache.get(transferId) || this.#outgoing.get(transferId);
|
|
229
|
+
if (!source || !Array.isArray(missing)) {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
return missing
|
|
233
|
+
.filter((i) => Number.isInteger(i) && i >= 0 && i < source.chunks.length)
|
|
234
|
+
.slice(0, MAX_RESEND_BATCH)
|
|
235
|
+
.map((i) => ({ index: i, data: source.chunks[i].toString('base64') }));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Handle incoming file_offer action.
|
|
240
|
+
* @returns {{ message: string, have: number[] }}
|
|
241
|
+
*/
|
|
242
|
+
handleFileOffer(fromSessionId, data, peerNickname) {
|
|
243
|
+
const { transferId, fileName, fileSize, totalChunks, sha256 } = data;
|
|
244
|
+
|
|
245
|
+
// Clear any existing transfer with same id
|
|
246
|
+
this.#clearIncoming(transferId);
|
|
247
|
+
|
|
248
|
+
// Resume: is there a stored partial of the same file (same SHA-256)?
|
|
249
|
+
let chunks = new Array(totalChunks).fill(null);
|
|
250
|
+
let received = 0;
|
|
251
|
+
let have = [];
|
|
252
|
+
const partial = this.#partials.get(sha256);
|
|
253
|
+
if (partial && partial.fileSize === fileSize && partial.totalChunks === totalChunks) {
|
|
254
|
+
clearTimeout(partial.timer);
|
|
255
|
+
this.#partials.delete(sha256);
|
|
256
|
+
chunks = partial.chunks;
|
|
257
|
+
received = partial.received;
|
|
258
|
+
have = [];
|
|
259
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
260
|
+
if (chunks[i]) {
|
|
261
|
+
have.push(i);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const timer = setTimeout(() => {
|
|
267
|
+
this.#stashPartial(transferId);
|
|
268
|
+
}, this.#transferTimeoutMs);
|
|
269
|
+
|
|
270
|
+
this.#incoming.set(transferId, {
|
|
271
|
+
fileName,
|
|
272
|
+
fileSize,
|
|
273
|
+
totalChunks,
|
|
274
|
+
sha256,
|
|
275
|
+
from: fromSessionId,
|
|
276
|
+
peerNickname,
|
|
277
|
+
chunks,
|
|
278
|
+
received,
|
|
279
|
+
attempts: 0,
|
|
280
|
+
timer,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const resumeNote = have.length ? ` — resuming (${have.length}/${totalChunks} chunks)` : '';
|
|
284
|
+
return {
|
|
285
|
+
message: `${peerNickname} sending ${fileName} (${(fileSize / 1024).toFixed(0)}KB)${resumeNote}`,
|
|
286
|
+
have,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Handle incoming file_chunk action.
|
|
292
|
+
* @returns {{ percent: number, text: string } | null}
|
|
293
|
+
*/
|
|
294
|
+
handleFileChunk(fromSessionId, data) {
|
|
295
|
+
const transfer = this.#incoming.get(data.transferId);
|
|
296
|
+
if (!transfer || transfer.from !== fromSessionId) {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const { chunkIndex } = data;
|
|
301
|
+
if (chunkIndex < 0 || chunkIndex >= transfer.totalChunks) {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (!transfer.chunks[chunkIndex]) {
|
|
306
|
+
transfer.chunks[chunkIndex] = Buffer.from(data.data, 'base64');
|
|
307
|
+
transfer.received++;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Reset timeout
|
|
311
|
+
clearTimeout(transfer.timer);
|
|
312
|
+
transfer.timer = setTimeout(() => {
|
|
313
|
+
this.#stashPartial(data.transferId);
|
|
314
|
+
}, this.#transferTimeoutMs);
|
|
315
|
+
|
|
316
|
+
const percent = Math.round((transfer.received / transfer.totalChunks) * 100);
|
|
317
|
+
return { percent, text: `Receiving ${transfer.fileName}` };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Handle incoming file_complete action.
|
|
322
|
+
* @returns {{ success: boolean, message: string, savePath?: string, resume?: boolean, missing?: number[] }}
|
|
323
|
+
*/
|
|
324
|
+
async handleFileComplete(fromSessionId, data) {
|
|
325
|
+
const transfer = this.#incoming.get(data.transferId);
|
|
326
|
+
if (!transfer || transfer.from !== fromSessionId) {
|
|
327
|
+
return { success: false, message: 'Unknown transfer' };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
clearTimeout(transfer.timer);
|
|
331
|
+
|
|
332
|
+
// Missing chunks — request a resend instead of discarding everything
|
|
333
|
+
const missing = [];
|
|
334
|
+
for (let i = 0; i < transfer.totalChunks; i++) {
|
|
335
|
+
if (!transfer.chunks[i]) {
|
|
336
|
+
missing.push(i);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (missing.length > 0) {
|
|
341
|
+
transfer.attempts++;
|
|
342
|
+
if (transfer.attempts > MAX_RESUME_ATTEMPTS) {
|
|
343
|
+
this.#incoming.delete(data.transferId);
|
|
344
|
+
return {
|
|
345
|
+
success: false,
|
|
346
|
+
message: `${transfer.fileName}: chunks missing after ${MAX_RESUME_ATTEMPTS} attempts (${transfer.received}/${transfer.totalChunks})`,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
transfer.timer = setTimeout(() => {
|
|
350
|
+
this.#stashPartial(data.transferId);
|
|
351
|
+
}, this.#transferTimeoutMs);
|
|
352
|
+
return {
|
|
353
|
+
success: false,
|
|
354
|
+
resume: true,
|
|
355
|
+
missing: missing.slice(0, MAX_RESEND_BATCH),
|
|
356
|
+
message: `${transfer.fileName}: ${missing.length} chunk(s) missing — requesting resend (attempt ${transfer.attempts}/${MAX_RESUME_ATTEMPTS})`,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Reassemble — trim any last-chunk padding back to the real file size
|
|
361
|
+
// (all chunks are padded to a uniform size on the wire to hide file size).
|
|
362
|
+
const reassembled = Buffer.concat(transfer.chunks);
|
|
363
|
+
const fullData = Number.isInteger(transfer.fileSize)
|
|
364
|
+
? reassembled.subarray(0, transfer.fileSize)
|
|
365
|
+
: reassembled;
|
|
366
|
+
|
|
367
|
+
// Verify SHA-256
|
|
368
|
+
const hash = createHash('sha256').update(fullData).digest('hex');
|
|
369
|
+
if (hash !== transfer.sha256) {
|
|
370
|
+
this.#incoming.delete(data.transferId);
|
|
371
|
+
return { success: false, message: `SHA-256 mismatch for ${transfer.fileName}` };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Save to downloads
|
|
375
|
+
const savePath = this.#getSafePath(transfer.fileName);
|
|
376
|
+
await writeFile(savePath, fullData);
|
|
377
|
+
|
|
378
|
+
this.#incoming.delete(data.transferId);
|
|
379
|
+
return { success: true, message: `${transfer.fileName} saved to ${savePath}`, savePath };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Transfer died mid-way — stash the partial indexed by SHA-256
|
|
383
|
+
// so it can resume if the same file is offered again
|
|
384
|
+
#stashPartial(transferId) {
|
|
385
|
+
const transfer = this.#incoming.get(transferId);
|
|
386
|
+
if (!transfer) {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
clearTimeout(transfer.timer);
|
|
390
|
+
this.#incoming.delete(transferId);
|
|
391
|
+
|
|
392
|
+
if (transfer.received === 0 || !transfer.sha256) {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const old = this.#partials.get(transfer.sha256);
|
|
397
|
+
if (old) {
|
|
398
|
+
clearTimeout(old.timer);
|
|
399
|
+
}
|
|
400
|
+
this.#partials.set(transfer.sha256, {
|
|
401
|
+
chunks: transfer.chunks,
|
|
402
|
+
received: transfer.received,
|
|
403
|
+
fileSize: transfer.fileSize,
|
|
404
|
+
totalChunks: transfer.totalChunks,
|
|
405
|
+
timer: setTimeout(() => this.#partials.delete(transfer.sha256), this.#resumeKeepMs),
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
#clearIncoming(transferId) {
|
|
410
|
+
const transfer = this.#incoming.get(transferId);
|
|
411
|
+
if (transfer) {
|
|
412
|
+
clearTimeout(transfer.timer);
|
|
413
|
+
this.#incoming.delete(transferId);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
#getSafePath(fileName) {
|
|
418
|
+
// Sanitize filename
|
|
419
|
+
const safe = fileName.replace(/[<>:"/\\|?*]/g, '_');
|
|
420
|
+
let savePath = join(this.#downloadDir, safe);
|
|
421
|
+
|
|
422
|
+
// Avoid overwrite — append (1), (2), etc.
|
|
423
|
+
if (existsSync(savePath)) {
|
|
424
|
+
const dot = safe.lastIndexOf('.');
|
|
425
|
+
const name = dot > 0 ? safe.slice(0, dot) : safe;
|
|
426
|
+
const ext = dot > 0 ? safe.slice(dot) : '';
|
|
427
|
+
let i = 1;
|
|
428
|
+
do {
|
|
429
|
+
savePath = join(this.#downloadDir, `${name} (${i})${ext}`);
|
|
430
|
+
i++;
|
|
431
|
+
} while (existsSync(savePath));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return savePath;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async #readChunks(filePath) {
|
|
438
|
+
return new Promise((resolve, reject) => {
|
|
439
|
+
const chunks = [];
|
|
440
|
+
const stream = createReadStream(filePath, { highWaterMark: FILE_CHUNK_SIZE });
|
|
441
|
+
stream.on('data', (chunk) => chunks.push(chunk));
|
|
442
|
+
stream.on('end', () => {
|
|
443
|
+
// Pad the final (partial) chunk up to a full chunk so every chunk is the
|
|
444
|
+
// same size on the wire — the relay then can't read the exact file size,
|
|
445
|
+
// only its size rounded up to the chunk. The receiver truncates back to
|
|
446
|
+
// fileSize on reassembly.
|
|
447
|
+
const last = chunks[chunks.length - 1];
|
|
448
|
+
if (last && last.length < FILE_CHUNK_SIZE) {
|
|
449
|
+
const padded = Buffer.alloc(FILE_CHUNK_SIZE);
|
|
450
|
+
last.copy(padded);
|
|
451
|
+
randomFillSync(padded, last.length); // random tail, not compressible zeros
|
|
452
|
+
chunks[chunks.length - 1] = padded;
|
|
453
|
+
}
|
|
454
|
+
resolve(chunks);
|
|
455
|
+
});
|
|
456
|
+
stream.on('error', reject);
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async #computeSHA256(filePath) {
|
|
461
|
+
return new Promise((resolve, reject) => {
|
|
462
|
+
const hash = createHash('sha256');
|
|
463
|
+
const stream = createReadStream(filePath);
|
|
464
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
465
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
466
|
+
stream.on('error', reject);
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
destroy() {
|
|
471
|
+
for (const [, entry] of this.#outgoing) {
|
|
472
|
+
clearInterval(entry.interval);
|
|
473
|
+
}
|
|
474
|
+
for (const [, entry] of this.#incoming) {
|
|
475
|
+
clearTimeout(entry.timer);
|
|
476
|
+
}
|
|
477
|
+
for (const [, entry] of this.#sentCache) {
|
|
478
|
+
clearTimeout(entry.timer);
|
|
479
|
+
}
|
|
480
|
+
for (const [, entry] of this.#partials) {
|
|
481
|
+
clearTimeout(entry.timer);
|
|
482
|
+
}
|
|
483
|
+
this.#outgoing.clear();
|
|
484
|
+
this.#incoming.clear();
|
|
485
|
+
this.#sentCache.clear();
|
|
486
|
+
this.#partials.clear();
|
|
487
|
+
}
|
|
488
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { extname } from 'node:path';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { Jimp } from 'jimp';
|
|
4
|
+
|
|
5
|
+
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.bmp']);
|
|
6
|
+
const MAX_PREVIEW_HEIGHT = 96; // pixels (2 per terminal row)
|
|
7
|
+
const INLINE_MAX_WIDTH = 1000; // pixels — cap for full-resolution inline render
|
|
8
|
+
|
|
9
|
+
// Fit the half-block preview to the terminal (leaving room for the border),
|
|
10
|
+
// with a sane cap.
|
|
11
|
+
function previewWidth() {
|
|
12
|
+
const cols = process.stdout.columns || 80;
|
|
13
|
+
return Math.max(24, Math.min(64, cols - 6));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isImageFile(filePath) {
|
|
17
|
+
return IMAGE_EXTENSIONS.has(extname(filePath).toLowerCase());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Load an image as { raw, png } buffers for inline (kitty/iTerm) rendering.
|
|
22
|
+
* Large images are downscaled so the escape stays reasonable.
|
|
23
|
+
* @param {string} filePath
|
|
24
|
+
* @returns {Promise<{ raw: Buffer, png: Buffer }>}
|
|
25
|
+
*/
|
|
26
|
+
export async function loadImageBuffers(filePath) {
|
|
27
|
+
const raw = readFileSync(filePath);
|
|
28
|
+
const image = await Jimp.read(filePath);
|
|
29
|
+
if (image.width > INLINE_MAX_WIDTH) {
|
|
30
|
+
image.resize({ w: INLINE_MAX_WIDTH });
|
|
31
|
+
}
|
|
32
|
+
const png = await image.getBuffer('image/png');
|
|
33
|
+
return { raw, png };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Render an image as terminal half-blocks with blessed color tags.
|
|
38
|
+
* Each cell packs two vertical pixels: '▀' with fg = top pixel and
|
|
39
|
+
* bg = bottom pixel. Returns one string per terminal row.
|
|
40
|
+
* @param {string} filePath
|
|
41
|
+
* @param {number} [maxWidth] - max columns
|
|
42
|
+
* @returns {Promise<string[]>}
|
|
43
|
+
*/
|
|
44
|
+
export async function renderImagePreview(filePath, maxWidth = previewWidth()) {
|
|
45
|
+
const image = await Jimp.read(filePath);
|
|
46
|
+
|
|
47
|
+
if (image.width > maxWidth) {
|
|
48
|
+
image.resize({ w: maxWidth });
|
|
49
|
+
}
|
|
50
|
+
if (image.height > MAX_PREVIEW_HEIGHT) {
|
|
51
|
+
image.resize({ h: MAX_PREVIEW_HEIGHT });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const { width, height } = image;
|
|
55
|
+
const { data } = image.bitmap;
|
|
56
|
+
|
|
57
|
+
const px = (x, y) => {
|
|
58
|
+
const i = (y * width + x) * 4;
|
|
59
|
+
if (data[i + 3] < 128) {
|
|
60
|
+
return null; // transparent
|
|
61
|
+
}
|
|
62
|
+
return `#${data[i].toString(16).padStart(2, '0')}${data[i + 1]
|
|
63
|
+
.toString(16)
|
|
64
|
+
.padStart(2, '0')}${data[i + 2].toString(16).padStart(2, '0')}`;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const lines = [];
|
|
68
|
+
for (let y = 0; y < height; y += 2) {
|
|
69
|
+
let line = '';
|
|
70
|
+
for (let x = 0; x < width; x++) {
|
|
71
|
+
const top = px(x, y);
|
|
72
|
+
const bottom = y + 1 < height ? px(x, y + 1) : null;
|
|
73
|
+
|
|
74
|
+
if (!top && !bottom) {
|
|
75
|
+
line += ' ';
|
|
76
|
+
} else if (top && bottom) {
|
|
77
|
+
line += `{${top}-fg}{${bottom}-bg}▀{/${bottom}-bg}{/${top}-fg}`;
|
|
78
|
+
} else if (top) {
|
|
79
|
+
line += `{${top}-fg}▀{/${top}-fg}`;
|
|
80
|
+
} else {
|
|
81
|
+
line += `{${bottom}-fg}▄{/${bottom}-fg}`;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
lines.push(line);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return lines;
|
|
88
|
+
}
|