@hyxf/pairdrop 0.0.0-stage → 0.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 +674 -0
- package/README.md +104 -2
- package/bin/pairdrop.js +171 -0
- package/dist/assets/index-BFMfhZgb.js +9 -0
- package/dist/assets/index-Cy2qcAPB.css +2 -0
- package/dist/favicon.svg +14 -0
- package/dist/index.html +25 -0
- package/dist-server/config.js +96 -0
- package/dist-server/features/pairing/pairing-manager.js +346 -0
- package/dist-server/features/rooms/room-manager.js +113 -0
- package/dist-server/features/signaling/peer.js +202 -0
- package/dist-server/features/signaling/ws-server.js +210 -0
- package/dist-server/index.js +81 -0
- package/dist-server/server.js +75 -0
- package/dist-server/shared/helper.js +67 -0
- package/dist-server/types.js +1 -0
- package/package.json +66 -4
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** Owns room membership and the peer notifications caused by membership changes. */
|
|
2
|
+
export default class RoomManager {
|
|
3
|
+
send;
|
|
4
|
+
rooms = Object.create(null);
|
|
5
|
+
constructor(send) {
|
|
6
|
+
this.send = send;
|
|
7
|
+
}
|
|
8
|
+
hasRoom(roomId) {
|
|
9
|
+
return Boolean(this.rooms[roomId]);
|
|
10
|
+
}
|
|
11
|
+
getPeer(roomId, peerId) {
|
|
12
|
+
return this.rooms[roomId]?.[peerId];
|
|
13
|
+
}
|
|
14
|
+
getPeers(roomId) {
|
|
15
|
+
return Object.values(this.rooms[roomId] ?? {});
|
|
16
|
+
}
|
|
17
|
+
clearSecretRoom(roomSecret) {
|
|
18
|
+
for (const peer of this.getPeers(roomSecret))
|
|
19
|
+
peer.removeRoomSecret(roomSecret);
|
|
20
|
+
delete this.rooms[roomSecret];
|
|
21
|
+
}
|
|
22
|
+
joinIpRoom(peer) {
|
|
23
|
+
this.joinRoom(peer, 'ip', peer.ip);
|
|
24
|
+
}
|
|
25
|
+
joinSecretRoom(peer, roomSecret) {
|
|
26
|
+
this.joinRoom(peer, 'secret', roomSecret);
|
|
27
|
+
peer.addRoomSecret(roomSecret);
|
|
28
|
+
}
|
|
29
|
+
leaveIpRoom(peer, disconnect = false) {
|
|
30
|
+
this.leaveRoom(peer, 'ip', peer.ip, disconnect);
|
|
31
|
+
}
|
|
32
|
+
leaveSecretRoom(peer, roomSecret, disconnect = false) {
|
|
33
|
+
this.leaveRoom(peer, 'secret', roomSecret, disconnect);
|
|
34
|
+
peer.removeRoomSecret(roomSecret);
|
|
35
|
+
}
|
|
36
|
+
joinSecretRooms(peer, roomSecrets) {
|
|
37
|
+
for (const roomSecret of roomSecrets)
|
|
38
|
+
this.joinSecretRoom(peer, roomSecret);
|
|
39
|
+
}
|
|
40
|
+
leaveAllSecretRooms(peer, disconnect = false) {
|
|
41
|
+
for (const roomSecret of [...peer.roomSecrets]) {
|
|
42
|
+
this.leaveSecretRoom(peer, roomSecret, disconnect);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
notifyDisplayNameChanged(peer) {
|
|
46
|
+
const recipients = new Set();
|
|
47
|
+
for (const roomId of [peer.ip, ...peer.roomSecrets]) {
|
|
48
|
+
const room = this.rooms[roomId];
|
|
49
|
+
if (room?.[peer.id] !== peer)
|
|
50
|
+
continue;
|
|
51
|
+
for (const otherPeer of Object.values(room)) {
|
|
52
|
+
if (otherPeer !== peer)
|
|
53
|
+
recipients.add(otherPeer);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const recipient of recipients) {
|
|
57
|
+
this.send(recipient, {
|
|
58
|
+
type: 'display-name-changed',
|
|
59
|
+
sender: { id: peer.id },
|
|
60
|
+
displayName: peer.name.displayName,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
joinRoom(peer, roomType, roomId) {
|
|
65
|
+
const room = this.rooms[roomId];
|
|
66
|
+
if (room?.[peer.id]) {
|
|
67
|
+
// Avoid emitting peer-left after peer-joined during a reconnect.
|
|
68
|
+
this.leaveRoom(peer, roomType, roomId);
|
|
69
|
+
}
|
|
70
|
+
const targetRoom = (this.rooms[roomId] ??= Object.create(null));
|
|
71
|
+
targetRoom[peer.id] = peer;
|
|
72
|
+
this.notifyPeers(peer, roomType, roomId);
|
|
73
|
+
}
|
|
74
|
+
leaveRoom(peer, roomType, roomId, disconnect = false) {
|
|
75
|
+
const room = this.rooms[roomId];
|
|
76
|
+
if (!room?.[peer.id])
|
|
77
|
+
return;
|
|
78
|
+
delete room[peer.id];
|
|
79
|
+
if (Object.keys(room).length === 0) {
|
|
80
|
+
delete this.rooms[roomId];
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
for (const otherPeer of Object.values(room)) {
|
|
84
|
+
this.send(otherPeer, {
|
|
85
|
+
type: 'peer-left',
|
|
86
|
+
peerId: peer.id,
|
|
87
|
+
roomType,
|
|
88
|
+
roomId,
|
|
89
|
+
disconnect,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
notifyPeers(peer, roomType, roomId) {
|
|
94
|
+
const room = this.rooms[roomId];
|
|
95
|
+
if (!room)
|
|
96
|
+
return;
|
|
97
|
+
const otherPeers = Object.values(room).filter((otherPeer) => otherPeer.id !== peer.id);
|
|
98
|
+
for (const otherPeer of otherPeers) {
|
|
99
|
+
this.send(otherPeer, {
|
|
100
|
+
type: 'peer-joined',
|
|
101
|
+
peer: peer.getInfo(),
|
|
102
|
+
roomType,
|
|
103
|
+
roomId,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
this.send(peer, {
|
|
107
|
+
type: 'peers',
|
|
108
|
+
peers: otherPeers.map((otherPeer) => otherPeer.getInfo()),
|
|
109
|
+
roomType,
|
|
110
|
+
roomId,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
import parser from 'ua-parser-js';
|
|
4
|
+
import { animals, colors, uniqueNamesGenerator } from 'unique-names-generator';
|
|
5
|
+
import { cyrb53, hasher } from '../../shared/helper.js';
|
|
6
|
+
export default class Peer {
|
|
7
|
+
conf;
|
|
8
|
+
socket;
|
|
9
|
+
ip = '';
|
|
10
|
+
id = '';
|
|
11
|
+
rtcSupported = false;
|
|
12
|
+
directPairingSupported = false;
|
|
13
|
+
name;
|
|
14
|
+
requestRate = 0;
|
|
15
|
+
roomSecrets = [];
|
|
16
|
+
pairKey = null;
|
|
17
|
+
constructor(socket, request, conf) {
|
|
18
|
+
this.conf = conf;
|
|
19
|
+
// set socket
|
|
20
|
+
this.socket = socket;
|
|
21
|
+
// set remote ip
|
|
22
|
+
this._setIP(request);
|
|
23
|
+
// set peer id
|
|
24
|
+
this._setPeerId(request);
|
|
25
|
+
// is WebRTC supported
|
|
26
|
+
this._setRtcSupported(request);
|
|
27
|
+
this.directPairingSupported =
|
|
28
|
+
new URL(String(request.url), 'http://server').searchParams.get('direct_pairing_version') === '2';
|
|
29
|
+
// set name
|
|
30
|
+
this._setName(request);
|
|
31
|
+
this.requestRate = 0;
|
|
32
|
+
this.roomSecrets = [];
|
|
33
|
+
this.pairKey = null;
|
|
34
|
+
}
|
|
35
|
+
rateLimitReached() {
|
|
36
|
+
// rate limit implementation: max 10 attempts every 10s
|
|
37
|
+
if (this.requestRate >= 10) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
this.requestRate += 1;
|
|
41
|
+
setTimeout(() => (this.requestRate -= 1), 10000);
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
_setIP(request) {
|
|
45
|
+
const remoteAddress = request.socket.remoteAddress ?? '';
|
|
46
|
+
const normalizedRemote = remoteAddress.replace(/^::ffff:/, '');
|
|
47
|
+
this.ip = normalizedRemote;
|
|
48
|
+
if (this.conf.trustedProxyIps.includes(normalizedRemote)) {
|
|
49
|
+
const forwarded = request.headers['x-forwarded-for'];
|
|
50
|
+
if (typeof forwarded === 'string') {
|
|
51
|
+
const chain = forwarded
|
|
52
|
+
.split(',')
|
|
53
|
+
.map((address) => address.trim().replace(/^::ffff:/, ''));
|
|
54
|
+
if (chain.length && chain.every((address) => isIP(address))) {
|
|
55
|
+
this.ip =
|
|
56
|
+
[...chain]
|
|
57
|
+
.reverse()
|
|
58
|
+
.find((address) => !this.conf.trustedProxyIps.includes(address)) ?? chain[0];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
const cloudflareIp = request.headers['cf-connecting-ip'];
|
|
63
|
+
if (typeof cloudflareIp === 'string' && isIP(cloudflareIp))
|
|
64
|
+
this.ip = cloudflareIp;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// remove the prefix used for IPv4-translated addresses
|
|
68
|
+
if (this.ip.substring(0, 7) === '::ffff:') {
|
|
69
|
+
this.ip = this.ip.substring(7);
|
|
70
|
+
}
|
|
71
|
+
let ipv6_was_localized = false;
|
|
72
|
+
if (this.conf.ipv6Localize && this.ip.includes(':')) {
|
|
73
|
+
this.ip = this.ip.split(':', this.conf.ipv6Localize).join(':');
|
|
74
|
+
ipv6_was_localized = true;
|
|
75
|
+
}
|
|
76
|
+
if (this.conf.debugMode) {
|
|
77
|
+
console.debug('\n');
|
|
78
|
+
console.debug('----DEBUGGING-PEER-IP-START----');
|
|
79
|
+
console.debug('remoteAddress:', request.connection.remoteAddress);
|
|
80
|
+
console.debug('x-forwarded-for:', request.headers['x-forwarded-for']);
|
|
81
|
+
console.debug('cf-connecting-ip:', request.headers['cf-connecting-ip']);
|
|
82
|
+
if (ipv6_was_localized) {
|
|
83
|
+
console.debug('IPv6 client IP was localized to', this.conf.ipv6Localize, Number(this.conf.ipv6Localize) > 1 ? 'segments' : 'segment');
|
|
84
|
+
}
|
|
85
|
+
console.debug('PairDrop uses:', this.ip);
|
|
86
|
+
console.debug('IP is private:', this.ipIsPrivate(this.ip));
|
|
87
|
+
console.debug("if IP is private, '127.0.0.1' is used instead");
|
|
88
|
+
console.debug('----DEBUGGING-PEER-IP-END----');
|
|
89
|
+
}
|
|
90
|
+
// IPv4 and IPv6 use different values to refer to localhost
|
|
91
|
+
// put all peers on the same network as the server into the same room as well
|
|
92
|
+
if (this.ip === '::1' || this.ipIsPrivate(this.ip)) {
|
|
93
|
+
this.ip = '127.0.0.1';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
ipIsPrivate(ip) {
|
|
97
|
+
// if ip is IPv4
|
|
98
|
+
if (!ip.includes(':')) {
|
|
99
|
+
// 10.0.0.0 - 10.255.255.255 || 172.16.0.0 - 172.31.255.255 || 192.168.0.0 - 192.168.255.255
|
|
100
|
+
return (/^(10)\.(.*)\.(.*)\.(.*)$/.test(ip) ||
|
|
101
|
+
/^(172)\.(1[6-9]|2[0-9]|3[0-1])\.(.*)\.(.*)$/.test(ip) ||
|
|
102
|
+
/^(192)\.(168)\.(.*)\.(.*)$/.test(ip));
|
|
103
|
+
}
|
|
104
|
+
// else: ip is IPv6
|
|
105
|
+
const firstWord = ip.split(':').find((el) => !!el); //get first not empty word
|
|
106
|
+
if (/^fe[c-f][0-f]$/.test(String(firstWord))) {
|
|
107
|
+
// The original IPv6 Site Local addresses (fec0::/10) are deprecated. Range: fec0 - feff
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
// These days Unique Local Addresses (ULA) are used in place of Site Local.
|
|
111
|
+
// Range: fc00 - fcff
|
|
112
|
+
else if (/^fc[0-f]{2}$/.test(String(firstWord))) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
// Range: fd00 - fcff
|
|
116
|
+
else if (/^fd[0-f]{2}$/.test(String(firstWord))) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
// Link local addresses (prefixed with fe80) are not routable
|
|
120
|
+
else if (firstWord === 'fe80') {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
// Discard Prefix
|
|
124
|
+
else if (firstWord === '100') {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
// Any other IP address is not Unique Local Address (ULA)
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
_setPeerId(request) {
|
|
131
|
+
const searchParams = new URL(String(request.url), 'http://server')
|
|
132
|
+
.searchParams;
|
|
133
|
+
const peerId = searchParams.get('peer_id');
|
|
134
|
+
const peerIdHash = searchParams.get('peer_id_hash');
|
|
135
|
+
if (peerId &&
|
|
136
|
+
Peer.isValidUuid(peerId) &&
|
|
137
|
+
this.isPeerIdHashValid(peerId, peerIdHash)) {
|
|
138
|
+
this.id = peerId;
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
this.id = crypto.randomUUID();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
_setRtcSupported(request) {
|
|
145
|
+
const searchParams = new URL(String(request.url), 'http://server')
|
|
146
|
+
.searchParams;
|
|
147
|
+
this.rtcSupported = searchParams.get('webrtc_supported') === 'true';
|
|
148
|
+
}
|
|
149
|
+
_setName(req) {
|
|
150
|
+
const ua = parser(req.headers['user-agent']);
|
|
151
|
+
let deviceName = '';
|
|
152
|
+
if (ua.os && ua.os.name) {
|
|
153
|
+
deviceName = ua.os.name.replace('Mac OS', 'Mac') + ' ';
|
|
154
|
+
}
|
|
155
|
+
if (ua.device.model) {
|
|
156
|
+
deviceName += ua.device.model;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
deviceName += ua.browser.name;
|
|
160
|
+
}
|
|
161
|
+
if (!deviceName) {
|
|
162
|
+
deviceName = 'Unknown Device';
|
|
163
|
+
}
|
|
164
|
+
const displayName = uniqueNamesGenerator({
|
|
165
|
+
length: 2,
|
|
166
|
+
separator: ' ',
|
|
167
|
+
dictionaries: [colors, animals],
|
|
168
|
+
style: 'capital',
|
|
169
|
+
seed: cyrb53(this.id),
|
|
170
|
+
});
|
|
171
|
+
this.name = {
|
|
172
|
+
model: ua.device.model,
|
|
173
|
+
os: ua.os.name,
|
|
174
|
+
browser: ua.browser.name,
|
|
175
|
+
type: ua.device.type,
|
|
176
|
+
deviceName,
|
|
177
|
+
displayName,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
getInfo() {
|
|
181
|
+
return {
|
|
182
|
+
id: this.id,
|
|
183
|
+
name: this.name,
|
|
184
|
+
rtcSupported: this.rtcSupported,
|
|
185
|
+
directPairingSupported: this.directPairingSupported,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
static isValidUuid(uuid) {
|
|
189
|
+
return /^([0-9]|[a-f]){8}-(([0-9]|[a-f]){4}-){3}([0-9]|[a-f]){12}$/.test(uuid);
|
|
190
|
+
}
|
|
191
|
+
isPeerIdHashValid(peerId, peerIdHash) {
|
|
192
|
+
return peerIdHash === hasher.hashCodeSalted(peerId);
|
|
193
|
+
}
|
|
194
|
+
addRoomSecret(roomSecret) {
|
|
195
|
+
if (!this.roomSecrets.includes(roomSecret)) {
|
|
196
|
+
this.roomSecrets.push(roomSecret);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
removeRoomSecret(roomSecret) {
|
|
200
|
+
this.roomSecrets = this.roomSecrets.filter((secret) => secret !== roomSecret);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { WebSocket, WebSocketServer } from 'ws';
|
|
2
|
+
import Peer from './peer.js';
|
|
3
|
+
import { hasher } from '../../shared/helper.js';
|
|
4
|
+
import RoomManager, {} from '../rooms/room-manager.js';
|
|
5
|
+
import PairingManager from '../pairing/pairing-manager.js';
|
|
6
|
+
function isServerMessage(value) {
|
|
7
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
8
|
+
}
|
|
9
|
+
export default class PairDropWsServer {
|
|
10
|
+
_conf;
|
|
11
|
+
_roomManager;
|
|
12
|
+
_pairingManager;
|
|
13
|
+
_keepAliveTimers = {};
|
|
14
|
+
_wss;
|
|
15
|
+
constructor(server, conf) {
|
|
16
|
+
this._conf = conf;
|
|
17
|
+
this._roomManager = new RoomManager((peer, message) => this._send(peer, message));
|
|
18
|
+
this._pairingManager = new PairingManager(this._roomManager, (peer, message) => this._send(peer, message));
|
|
19
|
+
this._wss = new WebSocketServer({ server, maxPayload: 1024 * 1024 });
|
|
20
|
+
this._wss.on('connection', (socket, request) => {
|
|
21
|
+
const version = new URL(request.url ?? '/', 'http://localhost').searchParams.get('protocol_version');
|
|
22
|
+
if (version !== '2') {
|
|
23
|
+
socket.close(1002, 'Open the updated PairDrop page');
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
this._onConnection(new Peer(socket, request, conf));
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
_onConnection(peer) {
|
|
30
|
+
const previous = this._keepAliveTimers[peer.id]?.peer;
|
|
31
|
+
if (previous && previous !== peer)
|
|
32
|
+
this._disconnect(previous);
|
|
33
|
+
peer.socket.on('message', (message) => this._onMessage(peer, message));
|
|
34
|
+
peer.socket.on('close', () => this._disconnect(peer));
|
|
35
|
+
peer.socket.onerror = (error) => console.error(error);
|
|
36
|
+
this._keepAlive(peer);
|
|
37
|
+
this._send(peer, {
|
|
38
|
+
type: 'ws-config',
|
|
39
|
+
wsConfig: {
|
|
40
|
+
rtcConfig: this._conf.rtcConfig,
|
|
41
|
+
wsFallback: this._conf.wsFallback,
|
|
42
|
+
protocolVersion: 2,
|
|
43
|
+
directPairingVersion: 2,
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
// send displayName
|
|
47
|
+
this._send(peer, {
|
|
48
|
+
type: 'display-name',
|
|
49
|
+
displayName: peer.name.displayName,
|
|
50
|
+
deviceName: peer.name.deviceName,
|
|
51
|
+
peerId: peer.id,
|
|
52
|
+
peerIdHash: hasher.hashCodeSalted(peer.id),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
_onMessage(sender, raw) {
|
|
56
|
+
let message;
|
|
57
|
+
try {
|
|
58
|
+
const parsed = JSON.parse(raw.toString());
|
|
59
|
+
if (!isServerMessage(parsed))
|
|
60
|
+
return;
|
|
61
|
+
message = parsed;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
console.warn('WS: Received JSON is malformed');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
switch (message.type) {
|
|
68
|
+
case 'disconnect':
|
|
69
|
+
this._disconnect(sender);
|
|
70
|
+
break;
|
|
71
|
+
case 'pong':
|
|
72
|
+
this._setKeepAliveTimerToNow(sender);
|
|
73
|
+
break;
|
|
74
|
+
case 'join-ip-room':
|
|
75
|
+
this._roomManager.joinIpRoom(sender);
|
|
76
|
+
break;
|
|
77
|
+
case 'room-secrets':
|
|
78
|
+
this._pairingManager.joinSecretRooms(sender, message);
|
|
79
|
+
break;
|
|
80
|
+
case 'room-secrets-deleted':
|
|
81
|
+
this._pairingManager.deleteSecretRooms(sender, message);
|
|
82
|
+
break;
|
|
83
|
+
case 'pair-device-initiate':
|
|
84
|
+
this._pairingManager.initiate(sender);
|
|
85
|
+
break;
|
|
86
|
+
case 'pair-device-join':
|
|
87
|
+
this._pairingManager.join(sender, message);
|
|
88
|
+
break;
|
|
89
|
+
case 'pair-device-cancel':
|
|
90
|
+
this._pairingManager.cancel(sender);
|
|
91
|
+
break;
|
|
92
|
+
case 'pair-request':
|
|
93
|
+
this._pairingManager.request(sender, message);
|
|
94
|
+
break;
|
|
95
|
+
case 'pair-request-respond':
|
|
96
|
+
this._pairingManager.respond(sender, message);
|
|
97
|
+
break;
|
|
98
|
+
case 'pair-request-storage':
|
|
99
|
+
this._pairingManager.confirmStorage(sender, message);
|
|
100
|
+
break;
|
|
101
|
+
case 'pair-request-cancel':
|
|
102
|
+
this._pairingManager.cancelRequest(sender, message);
|
|
103
|
+
break;
|
|
104
|
+
case 'regenerate-room-secret':
|
|
105
|
+
this._pairingManager.regenerateSecret(sender, message);
|
|
106
|
+
break;
|
|
107
|
+
case 'signal':
|
|
108
|
+
this._signalAndRelay(sender, message);
|
|
109
|
+
break;
|
|
110
|
+
case 'display-name-changed': {
|
|
111
|
+
if (typeof message.displayName !== 'string')
|
|
112
|
+
break;
|
|
113
|
+
const displayName = message.displayName
|
|
114
|
+
.trim()
|
|
115
|
+
.replace(/[\r\n]+/g, ' ')
|
|
116
|
+
.slice(0, 32);
|
|
117
|
+
if (!displayName)
|
|
118
|
+
break;
|
|
119
|
+
sender.name.displayName = displayName;
|
|
120
|
+
this._roomManager.notifyDisplayNameChanged(sender);
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
case 'request':
|
|
124
|
+
case 'header':
|
|
125
|
+
case 'partition':
|
|
126
|
+
case 'partition-received':
|
|
127
|
+
case 'files-transfer-response':
|
|
128
|
+
case 'files-transfer-cancel':
|
|
129
|
+
case 'file-transfer-complete':
|
|
130
|
+
case 'file-checksum':
|
|
131
|
+
case 'message-transfer-complete':
|
|
132
|
+
case 'text':
|
|
133
|
+
case 'ws-chunk':
|
|
134
|
+
// relay ws-fallback
|
|
135
|
+
if (this._conf.wsFallback) {
|
|
136
|
+
this._signalAndRelay(sender, message);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
console.log('Websocket fallback is not activated on this instance.');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
_signalAndRelay(sender, message) {
|
|
144
|
+
if (message.roomType !== 'secret')
|
|
145
|
+
return;
|
|
146
|
+
const room = message.roomId;
|
|
147
|
+
if (typeof room !== 'string' || typeof message.to !== 'string')
|
|
148
|
+
return;
|
|
149
|
+
// relay message to recipient
|
|
150
|
+
if (Peer.isValidUuid(message.to) &&
|
|
151
|
+
this._roomManager.getPeer(room, sender.id) === sender) {
|
|
152
|
+
const recipient = this._roomManager.getPeer(room, message.to);
|
|
153
|
+
const payload = { ...message };
|
|
154
|
+
delete payload.to;
|
|
155
|
+
this._send(recipient, {
|
|
156
|
+
...payload,
|
|
157
|
+
sender: { id: sender.id, rtcSupported: sender.rtcSupported },
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
_disconnect(sender) {
|
|
162
|
+
if (this._keepAliveTimers[sender.id]?.peer !== sender)
|
|
163
|
+
return;
|
|
164
|
+
this._pairingManager.removePeer(sender);
|
|
165
|
+
this._cancelKeepAlive(sender);
|
|
166
|
+
delete this._keepAliveTimers[sender.id];
|
|
167
|
+
this._roomManager.leaveIpRoom(sender, true);
|
|
168
|
+
this._roomManager.leaveAllSecretRooms(sender, true);
|
|
169
|
+
sender.socket.terminate();
|
|
170
|
+
}
|
|
171
|
+
_send(peer, message) {
|
|
172
|
+
if (!peer)
|
|
173
|
+
return;
|
|
174
|
+
if (peer.socket.readyState !== WebSocket.OPEN)
|
|
175
|
+
return;
|
|
176
|
+
if (peer.socket.bufferedAmount > 4 * 1024 * 1024) {
|
|
177
|
+
console.warn('WS: Disconnecting a peer with a full send buffer');
|
|
178
|
+
peer.socket.terminate();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
peer.socket.send(JSON.stringify(message));
|
|
182
|
+
}
|
|
183
|
+
_keepAlive(peer) {
|
|
184
|
+
this._cancelKeepAlive(peer);
|
|
185
|
+
const timeout = 1000;
|
|
186
|
+
if (!this._keepAliveTimers[peer.id]) {
|
|
187
|
+
this._keepAliveTimers[peer.id] = {
|
|
188
|
+
lastBeat: Date.now(),
|
|
189
|
+
peer,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
if (Date.now() - this._keepAliveTimers[peer.id].lastBeat > 5 * timeout) {
|
|
193
|
+
// Disconnect peer if unresponsive for 10s
|
|
194
|
+
this._disconnect(peer);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
this._send(peer, { type: 'ping' });
|
|
198
|
+
this._keepAliveTimers[peer.id].timer = setTimeout(() => this._keepAlive(peer), timeout);
|
|
199
|
+
}
|
|
200
|
+
_cancelKeepAlive(peer) {
|
|
201
|
+
if (this._keepAliveTimers[peer.id]?.timer) {
|
|
202
|
+
clearTimeout(this._keepAliveTimers[peer.id].timer);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
_setKeepAliveTimerToNow(peer) {
|
|
206
|
+
if (this._keepAliveTimers[peer.id]?.lastBeat) {
|
|
207
|
+
this._keepAliveTimers[peer.id].lastBeat = Date.now();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { loadConfig } from './config.js';
|
|
3
|
+
import PairDropServer from './server.js';
|
|
4
|
+
import PairDropWsServer from './features/signaling/ws-server.js';
|
|
5
|
+
let autoRestartEnabled = false;
|
|
6
|
+
let fatalExitStarted = false;
|
|
7
|
+
function exitAfterFatal(error) {
|
|
8
|
+
if (fatalExitStarted)
|
|
9
|
+
return;
|
|
10
|
+
fatalExitStarted = true;
|
|
11
|
+
console.error(error);
|
|
12
|
+
if (autoRestartEnabled) {
|
|
13
|
+
const args = process.argv.slice(1);
|
|
14
|
+
process.once('exit', () => {
|
|
15
|
+
const child = spawn(process.execPath, args, {
|
|
16
|
+
cwd: process.cwd(),
|
|
17
|
+
detached: true,
|
|
18
|
+
stdio: 'inherit',
|
|
19
|
+
});
|
|
20
|
+
child.unref();
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
function registerProcessHandlers() {
|
|
26
|
+
process.on('SIGINT', () => {
|
|
27
|
+
console.info('SIGINT received, exiting...');
|
|
28
|
+
process.exit(0);
|
|
29
|
+
});
|
|
30
|
+
process.on('SIGTERM', () => {
|
|
31
|
+
console.info('SIGTERM received, exiting...');
|
|
32
|
+
process.exit(0);
|
|
33
|
+
});
|
|
34
|
+
process.on('uncaughtException', (error, origin) => {
|
|
35
|
+
console.error('Uncaught exception from', origin);
|
|
36
|
+
exitAfterFatal(error);
|
|
37
|
+
});
|
|
38
|
+
process.on('unhandledRejection', (reason) => {
|
|
39
|
+
console.error('Unhandled rejection');
|
|
40
|
+
exitAfterFatal(reason);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
async function start() {
|
|
44
|
+
let config;
|
|
45
|
+
try {
|
|
46
|
+
config = loadConfig();
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
console.error(error);
|
|
50
|
+
process.exitCode = 1;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
autoRestartEnabled = config.autoStart;
|
|
54
|
+
const httpServer = new PairDropServer(config);
|
|
55
|
+
if (!config.signalingServer) {
|
|
56
|
+
new PairDropWsServer(httpServer.server, config);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
console.log('This instance does not include a signaling server. Clients on this instance connect to:', config.signalingServer);
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
await httpServer.start();
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
console.error(error);
|
|
66
|
+
// TYPE-EXCEPTION: 保留原有异常属性读取 | 改为守卫会改变非对象异常的处理
|
|
67
|
+
if (error.code === 'EADDRINUSE') {
|
|
68
|
+
console.info('The port is already in use; exiting without restarting the process.');
|
|
69
|
+
}
|
|
70
|
+
process.exitCode = 1;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
console.log('\nPairDrop is running on port', config.port);
|
|
74
|
+
process.send?.({
|
|
75
|
+
type: 'ready',
|
|
76
|
+
port: config.port,
|
|
77
|
+
wsFallback: config.wsFallback,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
registerProcessHandlers();
|
|
81
|
+
void start();
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import fastify, {} from 'fastify';
|
|
2
|
+
import fastifyRateLimit from '@fastify/rate-limit';
|
|
3
|
+
import fastifyStatic from '@fastify/static';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { networkInterfaces } from 'node:os';
|
|
6
|
+
import path, { dirname } from 'node:path';
|
|
7
|
+
function getLanAddresses() {
|
|
8
|
+
const addresses = Object.values(networkInterfaces()).flatMap((interfaces) => (interfaces ?? [])
|
|
9
|
+
.filter(({ family, internal, address }) => {
|
|
10
|
+
if (internal || family !== 'IPv4')
|
|
11
|
+
return false;
|
|
12
|
+
const octets = address.split('.').map(Number);
|
|
13
|
+
return (octets.length === 4 &&
|
|
14
|
+
(octets[0] === 10 ||
|
|
15
|
+
(octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) ||
|
|
16
|
+
(octets[0] === 192 && octets[1] === 168) ||
|
|
17
|
+
(octets[0] === 100 && octets[1] >= 64 && octets[1] <= 127)));
|
|
18
|
+
})
|
|
19
|
+
.map(({ address }) => address));
|
|
20
|
+
return [...new Set(addresses)];
|
|
21
|
+
}
|
|
22
|
+
export default class PairDropServer {
|
|
23
|
+
app;
|
|
24
|
+
server;
|
|
25
|
+
config;
|
|
26
|
+
constructor(conf) {
|
|
27
|
+
this.config = conf;
|
|
28
|
+
this.app = fastify({
|
|
29
|
+
logger: {
|
|
30
|
+
level: process.env.LOG_LEVEL ||
|
|
31
|
+
(process.env.NODE_ENV === 'production' ? 'warn' : 'info'),
|
|
32
|
+
},
|
|
33
|
+
trustProxy: (address) => conf.trustedProxyIps.includes(address.replace(/^::ffff:/, '')),
|
|
34
|
+
});
|
|
35
|
+
this.server = this.app.server;
|
|
36
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
37
|
+
const __dirname = dirname(__filename);
|
|
38
|
+
const publicPathAbs = path.resolve(__dirname, '../dist');
|
|
39
|
+
if (conf.rateLimit) {
|
|
40
|
+
this.app.register(fastifyRateLimit, {
|
|
41
|
+
max: 1000,
|
|
42
|
+
timeWindow: 5 * 60 * 1000,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
this.app.register(fastifyStatic, { root: publicPathAbs });
|
|
46
|
+
if (conf.debugMode && conf.rateLimit) {
|
|
47
|
+
console.debug('\n');
|
|
48
|
+
console.debug('----DEBUG RATE_LIMIT----');
|
|
49
|
+
console.debug("To find out the correct value for '/ip', ensure the returned IP-address is the IP-address of your client.");
|
|
50
|
+
console.debug('See https://github.com/fastify/fastify-rate-limit#configuration for more info');
|
|
51
|
+
this.app.get('/ip', async (request) => request.ip);
|
|
52
|
+
}
|
|
53
|
+
this.app.get('/config', async () => ({
|
|
54
|
+
signalingServer: conf.signalingServer,
|
|
55
|
+
buttons: conf.buttons,
|
|
56
|
+
lanAddresses: conf.localhostOnly ? [] : getLanAddresses(),
|
|
57
|
+
}));
|
|
58
|
+
this.app.setErrorHandler((error, _request, reply) => {
|
|
59
|
+
this.app.log.error(error);
|
|
60
|
+
// TYPE-EXCEPTION: Fastify 错误在此具有 statusCode | 校验并替换异常会改变原有错误处理
|
|
61
|
+
const statusCode = error.statusCode ?? 500;
|
|
62
|
+
void reply.status(statusCode).send({ error: 'Internal Server Error' });
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
async start() {
|
|
66
|
+
await this.app.listen({
|
|
67
|
+
port: this.config.port,
|
|
68
|
+
...(this.config.localhostOnly
|
|
69
|
+
? { host: '127.0.0.1' }
|
|
70
|
+
: process.env.NODE_ENV === 'production'
|
|
71
|
+
? { host: '0.0.0.0' }
|
|
72
|
+
: {}),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|