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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +213 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peer-term",
3
- "version": "1.2.14",
3
+ "version": "1.3.1",
4
4
  "license": "MIT",
5
5
  "description": "Share your terminal instantly using a 6-digit code. Encrypted. No config.",
6
6
  "repository": {
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 || !this.ptyProcess) return;
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 terminal data
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) {