peer-term 1.2.14 → 1.3.1
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/package.json +1 -1
- package/src/index.js +213 -2
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -99,6 +99,10 @@ const RELAY_URLS = argv.relay
|
|
|
99
99
|
const HEARTBEAT_INTERVAL_MS = 5000;
|
|
100
100
|
const MAX_MISSED_PINGS = 2;
|
|
101
101
|
|
|
102
|
+
const UPLOAD_MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB max
|
|
103
|
+
const UPLOAD_MAX_CHUNKS_PER_FILE = Math.ceil(UPLOAD_MAX_FILE_SIZE / 16384);
|
|
104
|
+
const UPLOAD_MAX_CONCURRENT = 3;
|
|
105
|
+
|
|
102
106
|
// ─── Path Resolution ─────────────────────────────────────────────────────────
|
|
103
107
|
|
|
104
108
|
function expandTilde(inputPath) {
|
|
@@ -336,6 +340,10 @@ class Session {
|
|
|
336
340
|
this.webrtc = null;
|
|
337
341
|
this.useDataChannel = false;
|
|
338
342
|
|
|
343
|
+
// File upload: in-progress file transfers from client
|
|
344
|
+
// Shape: { [id]: { name, size, totalChunks, chunks: Map<index, Buffer> } }
|
|
345
|
+
this._incomingFiles = {};
|
|
346
|
+
|
|
339
347
|
// Local TCP viewer (new terminal window)
|
|
340
348
|
this.viewerServer = null;
|
|
341
349
|
this.viewerSocket = null;
|
|
@@ -601,6 +609,17 @@ class Session {
|
|
|
601
609
|
return;
|
|
602
610
|
}
|
|
603
611
|
|
|
612
|
+
// Check if decrypted message is a file transfer message (file-start, file-chunk, file-end)
|
|
613
|
+
try {
|
|
614
|
+
const parsedMsg = JSON.parse(plaintext);
|
|
615
|
+
if (parsedMsg && typeof parsedMsg.type === 'string' && parsedMsg.type.startsWith('file-')) {
|
|
616
|
+
this._handleFileMessage(parsedMsg);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
} catch {
|
|
620
|
+
// Not JSON — normal keystroke data, fall through
|
|
621
|
+
}
|
|
622
|
+
|
|
604
623
|
// Normal keystroke — drop if read-only
|
|
605
624
|
if (this.readOnly) return;
|
|
606
625
|
this.ptyProcess.write(plaintext);
|
|
@@ -820,6 +839,171 @@ class Session {
|
|
|
820
839
|
}
|
|
821
840
|
}
|
|
822
841
|
|
|
842
|
+
// ─── File Upload Handling ─────────────────────────────────────────────
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Route file transfer messages (file-start, file-chunk, file-end).
|
|
846
|
+
* All file messages arrive over the DataChannel only.
|
|
847
|
+
*/
|
|
848
|
+
_handleFileMessage(msg) {
|
|
849
|
+
switch (msg.type) {
|
|
850
|
+
case 'file-start': {
|
|
851
|
+
// Validate required fields
|
|
852
|
+
if (!msg.id || !msg.name || msg.size === undefined || msg.totalChunks === undefined) {
|
|
853
|
+
this._sendFileError(msg.id, 'Invalid file-start message');
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
if (msg.size > UPLOAD_MAX_FILE_SIZE) {
|
|
858
|
+
this._sendFileError(msg.id, `File exceeds maximum size of ${UPLOAD_MAX_FILE_SIZE / 1024 / 1024}MB`);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (msg.totalChunks > UPLOAD_MAX_CHUNKS_PER_FILE) {
|
|
863
|
+
this._sendFileError(msg.id, 'File requires too many chunks');
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
if (Object.keys(this._incomingFiles).length >= UPLOAD_MAX_CONCURRENT) {
|
|
868
|
+
this._sendFileError(msg.id, 'Too many concurrent uploads');
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
this.log(`File upload started: "${msg.name}" (${msg.size} bytes, ${msg.totalChunks} chunks)`);
|
|
873
|
+
this._incomingFiles[msg.id] = {
|
|
874
|
+
name: msg.name,
|
|
875
|
+
size: msg.size,
|
|
876
|
+
totalChunks: msg.totalChunks,
|
|
877
|
+
receivedSize: 0,
|
|
878
|
+
chunks: new Map(),
|
|
879
|
+
};
|
|
880
|
+
break;
|
|
881
|
+
}
|
|
882
|
+
case 'file-chunk': {
|
|
883
|
+
const transfer = this._incomingFiles[msg.id];
|
|
884
|
+
if (!transfer) {
|
|
885
|
+
this._sendFileError(msg.id, 'Unknown transfer ID');
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const chunkData = Buffer.from(msg.data, 'base64');
|
|
890
|
+
|
|
891
|
+
if (transfer.chunks.size >= transfer.totalChunks) {
|
|
892
|
+
this._sendFileError(msg.id, 'Too many chunks received');
|
|
893
|
+
delete this._incomingFiles[msg.id];
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
if (transfer.receivedSize + chunkData.length > transfer.size) {
|
|
898
|
+
this._sendFileError(msg.id, 'Received size exceeds declared size');
|
|
899
|
+
delete this._incomingFiles[msg.id];
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
transfer.chunks.set(msg.index, chunkData);
|
|
904
|
+
transfer.receivedSize += chunkData.length;
|
|
905
|
+
this.logDebug(`File chunk ${msg.index + 1}/${transfer.totalChunks} received for "${transfer.name}"`);
|
|
906
|
+
break;
|
|
907
|
+
}
|
|
908
|
+
case 'file-end': {
|
|
909
|
+
this._finalizeFile(msg.id);
|
|
910
|
+
break;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Reassemble all chunks in order and write the completed file to disk.
|
|
917
|
+
* Saves to ~/peerterm-uploads/<filename>.
|
|
918
|
+
*/
|
|
919
|
+
async _finalizeFile(id) {
|
|
920
|
+
const transfer = this._incomingFiles[id];
|
|
921
|
+
if (!transfer) {
|
|
922
|
+
this._sendFileError(id, 'Unknown transfer ID');
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// Check all chunks are present
|
|
927
|
+
for (let i = 0; i < transfer.totalChunks; i++) {
|
|
928
|
+
if (!transfer.chunks.has(i)) {
|
|
929
|
+
this.log(`File "${transfer.name}" missing chunk ${i}`);
|
|
930
|
+
this._sendFileError(id, `Missing chunk ${i}`);
|
|
931
|
+
delete this._incomingFiles[id];
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Reassemble in order using Buffer.concat
|
|
937
|
+
const ordered = [];
|
|
938
|
+
for (let i = 0; i < transfer.totalChunks; i++) {
|
|
939
|
+
ordered.push(transfer.chunks.get(i));
|
|
940
|
+
}
|
|
941
|
+
const fileData = Buffer.concat(ordered);
|
|
942
|
+
|
|
943
|
+
// Determine save path: ~/peerterm-uploads/<filename>
|
|
944
|
+
const homeDir = os.homedir();
|
|
945
|
+
const uploadDir = path.join(homeDir, 'peerterm-uploads');
|
|
946
|
+
// Sanitize filename to prevent path traversal (e.g., "../../../etc/passwd")
|
|
947
|
+
const baseName = path.basename(transfer.name);
|
|
948
|
+
if (!baseName || baseName === '.' || baseName === '..') {
|
|
949
|
+
this._sendFileError(id, 'Invalid filename');
|
|
950
|
+
delete this._incomingFiles[id];
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
const savePath = path.join(uploadDir, baseName);
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
try {
|
|
959
|
+
await fs.promises.mkdir(uploadDir, { recursive: true });
|
|
960
|
+
await fs.promises.writeFile(savePath, fileData);
|
|
961
|
+
|
|
962
|
+
this.log(`File saved: ${savePath}`);
|
|
963
|
+
|
|
964
|
+
// Write green ANSI message into the PTY to show the save path
|
|
965
|
+
if (this.ptyProcess) {
|
|
966
|
+
this.ptyProcess.write(`\r\n\x1b[32m[PeerTerm] File saved: ${savePath}\x1b[0m\r\n`);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Send ack to client
|
|
970
|
+
this._sendFileAck(id, transfer.name);
|
|
971
|
+
} catch (err) {
|
|
972
|
+
this.log(`Failed to save file "${transfer.name}": ${err.message}`);
|
|
973
|
+
this._sendFileError(id, `Save failed: ${err.message}`);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
delete this._incomingFiles[id];
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Send file-ack to the client confirming successful file save.
|
|
981
|
+
*/
|
|
982
|
+
async _sendFileAck(id, name) {
|
|
983
|
+
if (!this.sharedKey) return;
|
|
984
|
+
try {
|
|
985
|
+
const ackJson = JSON.stringify({ type: 'file-ack', id, name });
|
|
986
|
+
const payload = await encrypt(this.sharedKey, ackJson);
|
|
987
|
+
this._sendEncryptedToClient(payload);
|
|
988
|
+
} catch (err) {
|
|
989
|
+
this.log(`Failed to send file-ack: ${err.message}`);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* Send file-error to the client with an error message.
|
|
995
|
+
*/
|
|
996
|
+
async _sendFileError(id, msg) {
|
|
997
|
+
if (!this.sharedKey) return;
|
|
998
|
+
try {
|
|
999
|
+
const errJson = JSON.stringify({ type: 'file-error', id, msg });
|
|
1000
|
+
const payload = await encrypt(this.sharedKey, errJson);
|
|
1001
|
+
this._sendEncryptedToClient(payload);
|
|
1002
|
+
} catch (err) {
|
|
1003
|
+
this.log(`Failed to send file-error: ${err.message}`);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
823
1007
|
_startViewerServer() {
|
|
824
1008
|
// Generate a random nonce for viewer authentication
|
|
825
1009
|
this.viewerToken = crypto.randomBytes(16).toString('hex');
|
|
@@ -1145,6 +1329,17 @@ class Session {
|
|
|
1145
1329
|
return;
|
|
1146
1330
|
}
|
|
1147
1331
|
|
|
1332
|
+
// Check if decrypted message is a file transfer message
|
|
1333
|
+
try {
|
|
1334
|
+
const parsedMsg = JSON.parse(plaintext);
|
|
1335
|
+
if (parsedMsg && typeof parsedMsg.type === 'string' && parsedMsg.type.startsWith('file-')) {
|
|
1336
|
+
this._handleFileMessage(parsedMsg);
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
} catch {
|
|
1340
|
+
// Not JSON — normal keystroke data, fall through
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1148
1343
|
if (this.readOnly) return;
|
|
1149
1344
|
this.ptyProcess.write(plaintext);
|
|
1150
1345
|
} catch (err) {
|
|
@@ -1221,6 +1416,8 @@ class Session {
|
|
|
1221
1416
|
this.intentionalClose = true;
|
|
1222
1417
|
this.stopHeartbeat();
|
|
1223
1418
|
this._stopReconnecting();
|
|
1419
|
+
// Clean up in-progress file uploads
|
|
1420
|
+
this._incomingFiles = {};
|
|
1224
1421
|
// Clean up viewer
|
|
1225
1422
|
if (this.viewerSocket) {
|
|
1226
1423
|
try { this.viewerSocket.destroy(); } catch {}
|
|
@@ -1267,7 +1464,7 @@ class Session {
|
|
|
1267
1464
|
});
|
|
1268
1465
|
|
|
1269
1466
|
this.webrtc.onMessage(async (data) => {
|
|
1270
|
-
if (!this.sharedKey
|
|
1467
|
+
if (!this.sharedKey) return;
|
|
1271
1468
|
try {
|
|
1272
1469
|
// Check if this is a tagged message (resize)
|
|
1273
1470
|
let parsed;
|
|
@@ -1286,8 +1483,22 @@ class Session {
|
|
|
1286
1483
|
return;
|
|
1287
1484
|
}
|
|
1288
1485
|
|
|
1289
|
-
// Normal encrypted
|
|
1486
|
+
// Normal encrypted data — decrypt first
|
|
1290
1487
|
const plaintext = await decrypt(this.sharedKey, data);
|
|
1488
|
+
|
|
1489
|
+
// Check if decrypted message is a file transfer message (file-start, file-chunk, file-end)
|
|
1490
|
+
try {
|
|
1491
|
+
const msg = JSON.parse(plaintext);
|
|
1492
|
+
if (msg && typeof msg.type === 'string' && msg.type.startsWith('file-')) {
|
|
1493
|
+
this._handleFileMessage(msg);
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
} catch {
|
|
1497
|
+
// Not JSON — normal keystroke data, fall through
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// Normal keystroke — drop if read-only or no PTY
|
|
1501
|
+
if (!this.ptyProcess) return;
|
|
1291
1502
|
if (this.readOnly) return;
|
|
1292
1503
|
this.ptyProcess.write(plaintext);
|
|
1293
1504
|
} catch (err) {
|