689 lines
21 KiB
JavaScript
689 lines
21 KiB
JavaScript
const process = require('process')
|
|
const crypto = require('crypto')
|
|
const {spawn} = require('child_process')
|
|
const WebSocket = require('ws');
|
|
const fs = require('fs');
|
|
const parser = require('ua-parser-js');
|
|
const { uniqueNamesGenerator, animals, colors } = require('unique-names-generator');
|
|
const express = require('express');
|
|
const RateLimit = require('express-rate-limit');
|
|
const http = require('http');
|
|
|
|
// Handle SIGINT
|
|
process.on('SIGINT', () => {
|
|
console.info("SIGINT Received, exiting...")
|
|
process.exit(0)
|
|
})
|
|
|
|
// Handle SIGTERM
|
|
process.on('SIGTERM', () => {
|
|
console.info("SIGTERM Received, exiting...")
|
|
process.exit(0)
|
|
})
|
|
|
|
// Handle APP ERRORS
|
|
process.on('uncaughtException', (error, origin) => {
|
|
console.log('----- Uncaught exception -----')
|
|
console.log(error)
|
|
console.log('----- Exception origin -----')
|
|
console.log(origin)
|
|
})
|
|
process.on('unhandledRejection', (reason, promise) => {
|
|
console.log('----- Unhandled Rejection at -----')
|
|
console.log(promise)
|
|
console.log('----- Reason -----')
|
|
console.log(reason)
|
|
})
|
|
|
|
if (process.argv.includes('--auto-restart')) {
|
|
process.on(
|
|
'uncaughtException',
|
|
() => {
|
|
process.once(
|
|
'exit',
|
|
() => spawn(
|
|
process.argv.shift(),
|
|
process.argv,
|
|
{
|
|
cwd: process.cwd(),
|
|
detached: true,
|
|
stdio: 'inherit'
|
|
}
|
|
)
|
|
);
|
|
process.exit();
|
|
}
|
|
);
|
|
}
|
|
|
|
const rtcConfig = process.env.RTC_CONFIG
|
|
? JSON.parse(fs.readFileSync(process.env.RTC_CONFIG, 'utf8'))
|
|
: {
|
|
"sdpSemantics": "unified-plan",
|
|
"iceServers": [
|
|
{
|
|
"urls": "stun:stun.l.google.com:19302"
|
|
},
|
|
{
|
|
"urls": "stun:openrelay.metered.ca:80"
|
|
},
|
|
{
|
|
"urls": "turn:openrelay.metered.ca:443",
|
|
"username": "openrelayproject",
|
|
"credential": "openrelayproject"
|
|
}
|
|
]
|
|
};
|
|
|
|
const app = express();
|
|
|
|
if (process.argv.includes('--rate-limit')) {
|
|
const limiter = RateLimit({
|
|
windowMs: 5 * 60 * 1000, // 5 minutes
|
|
max: 1000, // Limit each IP to 1000 requests per `window` (here, per 5 minutes)
|
|
message: 'Too many requests from this IP Address, please try again after 5 minutes.',
|
|
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
|
|
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
|
|
})
|
|
|
|
app.use(limiter);
|
|
// ensure correct client ip and not the ip of the reverse proxy is used for rate limiting on render.com
|
|
// see https://github.com/express-rate-limit/express-rate-limit#troubleshooting-proxy-issues
|
|
app.set('trust proxy', 5);
|
|
}
|
|
|
|
if (process.argv.includes('--include-ws-fallback')) {
|
|
app.use(express.static('public_included_ws_fallback'));
|
|
} else {
|
|
app.use(express.static('public'));
|
|
}
|
|
|
|
app.use(function(req, res) {
|
|
res.redirect('/');
|
|
});
|
|
|
|
app.get('/', (req, res) => {
|
|
res.sendFile('index.html');
|
|
});
|
|
|
|
const server = http.createServer(app);
|
|
const port = process.env.PORT || 3000;
|
|
|
|
if (process.argv.includes('--localhost-only')) {
|
|
server.listen(port, '127.0.0.1');
|
|
} else {
|
|
server.listen(port);
|
|
}
|
|
|
|
class PairDropServer {
|
|
|
|
constructor() {
|
|
this._wss = new WebSocket.Server({ server });
|
|
this._wss.on('connection', (socket, request) => this._onConnection(new Peer(socket, request)));
|
|
|
|
this._rooms = {};
|
|
this._roomSecrets = {};
|
|
|
|
console.log('PairDrop is running on port', port);
|
|
}
|
|
|
|
_onConnection(peer) {
|
|
peer.socket.on('message', message => this._onMessage(peer, message));
|
|
peer.socket.onerror = e => console.error(e);
|
|
this._keepAlive(peer);
|
|
this._send(peer, {
|
|
type: 'rtc-config',
|
|
config: rtcConfig
|
|
});
|
|
this._joinRoom(peer);
|
|
|
|
// send displayName
|
|
this._send(peer, {
|
|
type: 'display-name',
|
|
message: {
|
|
displayName: peer.name.displayName,
|
|
deviceName: peer.name.deviceName,
|
|
peerId: peer.id,
|
|
peerIdHash: peer.id.hashCode128BitSalted()
|
|
}
|
|
});
|
|
}
|
|
|
|
_onMessage(sender, message) {
|
|
// Try to parse message
|
|
try {
|
|
message = JSON.parse(message);
|
|
} catch (e) {
|
|
return; // TODO: handle malformed JSON
|
|
}
|
|
|
|
switch (message.type) {
|
|
case 'disconnect':
|
|
this._onDisconnect(sender);
|
|
break;
|
|
case 'pong':
|
|
sender.lastBeat = Date.now();
|
|
break;
|
|
case 'room-secrets':
|
|
this._onRoomSecrets(sender, message);
|
|
break;
|
|
case 'room-secret-deleted':
|
|
this._onRoomSecretDeleted(sender, message);
|
|
break;
|
|
case 'room-secrets-cleared':
|
|
this._onRoomSecretsCleared(sender, message);
|
|
break;
|
|
case 'pair-device-initiate':
|
|
this._onPairDeviceInitiate(sender);
|
|
break;
|
|
case 'pair-device-join':
|
|
this._onPairDeviceJoin(sender, message);
|
|
break;
|
|
case 'pair-device-cancel':
|
|
this._onPairDeviceCancel(sender);
|
|
break;
|
|
case 'resend-peers':
|
|
this._notifyPeers(sender);
|
|
break;
|
|
case 'signal':
|
|
default:
|
|
this._signalAndRelay(sender, message);
|
|
}
|
|
}
|
|
|
|
_signalAndRelay(sender, message) {
|
|
const room = message.roomType === 'ip' ? sender.ip : message.roomSecret;
|
|
|
|
// relay message to recipient
|
|
if (message.to && Peer.isValidUuid(message.to) && this._rooms[room]) {
|
|
const recipient = this._rooms[room][message.to];
|
|
delete message.to;
|
|
// add sender
|
|
message.sender = {
|
|
id: sender.id,
|
|
rtcSupported: sender.rtcSupported
|
|
};
|
|
this._send(recipient, message);
|
|
}
|
|
}
|
|
|
|
_onDisconnect(sender) {
|
|
this._leaveRoom(sender, 'ip', '', true);
|
|
this._leaveAllSecretRooms(sender, true);
|
|
this._removeRoomKey(sender.roomKey);
|
|
sender.roomKey = null;
|
|
}
|
|
|
|
_onRoomSecrets(sender, message) {
|
|
const roomSecrets = message.roomSecrets.filter(roomSecret => {
|
|
return /^[\x00-\x7F]{64}$/.test(roomSecret);
|
|
})
|
|
this._joinSecretRooms(sender, roomSecrets);
|
|
}
|
|
|
|
_onRoomSecretDeleted(sender, message) {
|
|
this._deleteSecretRoom(sender, message.roomSecret)
|
|
}
|
|
|
|
_onRoomSecretsCleared(sender, message) {
|
|
for (let i = 0; i<message.roomSecrets.length; i++) {
|
|
this._deleteSecretRoom(sender, message.roomSecrets[i]);
|
|
}
|
|
}
|
|
|
|
_deleteSecretRoom(sender, roomSecret) {
|
|
const room = this._rooms[roomSecret];
|
|
if (room) {
|
|
for (const peerId in room) {
|
|
const peer = room[peerId];
|
|
this._leaveRoom(peer, 'secret', roomSecret);
|
|
this._send(peer, {
|
|
type: 'secret-room-deleted',
|
|
roomSecret: roomSecret,
|
|
});
|
|
}
|
|
}
|
|
this._notifyPeers(sender);
|
|
}
|
|
|
|
getRandomString(length) {
|
|
let string = "";
|
|
while (string.length < length) {
|
|
let arr = new Uint16Array(length);
|
|
crypto.webcrypto.getRandomValues(arr);
|
|
arr = Array.apply([], arr); /* turn into non-typed array */
|
|
arr = arr.map(function (r) {
|
|
return r % 128
|
|
})
|
|
arr = arr.filter(function (r) {
|
|
/* strip non-printables: if we transform into desirable range we have a propability bias, so I suppose we better skip this character */
|
|
return r === 45 || r >= 47 && r <= 57 || r >= 64 && r <= 90 || r >= 97 && r <= 122;
|
|
});
|
|
string += String.fromCharCode.apply(String, arr);
|
|
}
|
|
return string.substring(0, length)
|
|
}
|
|
|
|
_onPairDeviceInitiate(sender) {
|
|
let roomSecret = this.getRandomString(64);
|
|
let roomKey = this._createRoomKey(sender, roomSecret);
|
|
if (sender.roomKey) this._removeRoomKey(sender.roomKey);
|
|
sender.roomKey = roomKey;
|
|
this._send(sender, {
|
|
type: 'pair-device-initiated',
|
|
roomSecret: roomSecret,
|
|
roomKey: roomKey
|
|
});
|
|
this._joinRoom(sender, 'secret', roomSecret);
|
|
}
|
|
|
|
_onPairDeviceJoin(sender, message) {
|
|
if (sender.roomKeyRate >= 10) {
|
|
this._send(sender, { type: 'pair-device-join-key-rate-limit' });
|
|
return;
|
|
}
|
|
sender.roomKeyRate += 1;
|
|
setTimeout(_ => sender.roomKeyRate -= 1, 10000);
|
|
if (!this._roomSecrets[message.roomKey] || sender.id === this._roomSecrets[message.roomKey].creator.id) {
|
|
this._send(sender, { type: 'pair-device-join-key-invalid' });
|
|
return;
|
|
}
|
|
const roomSecret = this._roomSecrets[message.roomKey].roomSecret;
|
|
const creator = this._roomSecrets[message.roomKey].creator;
|
|
this._removeRoomKey(message.roomKey);
|
|
this._send(sender, {
|
|
type: 'pair-device-joined',
|
|
roomSecret: roomSecret,
|
|
peerId: creator.id
|
|
});
|
|
this._send(creator, {
|
|
type: 'pair-device-joined',
|
|
roomSecret: roomSecret,
|
|
peerId: sender.id
|
|
});
|
|
this._joinRoom(sender, 'secret', roomSecret);
|
|
this._removeRoomKey(sender.roomKey);
|
|
}
|
|
|
|
_onPairDeviceCancel(sender) {
|
|
if (sender.roomKey) {
|
|
this._send(sender, {
|
|
type: 'pair-device-canceled',
|
|
roomKey: sender.roomKey,
|
|
});
|
|
this._removeRoomKey(sender.roomKey);
|
|
}
|
|
}
|
|
|
|
_createRoomKey(creator, roomSecret) {
|
|
let roomKey;
|
|
do {
|
|
// get randomInt until keyRoom not occupied
|
|
roomKey = crypto.randomInt(1000000, 1999999).toString().substring(1); // include numbers with leading 0s
|
|
} while (roomKey in this._roomSecrets)
|
|
|
|
this._roomSecrets[roomKey] = {
|
|
roomSecret: roomSecret,
|
|
creator: creator
|
|
}
|
|
|
|
return roomKey;
|
|
}
|
|
|
|
_removeRoomKey(roomKey) {
|
|
if (roomKey in this._roomSecrets) {
|
|
this._roomSecrets[roomKey].creator.roomKey = null
|
|
delete this._roomSecrets[roomKey];
|
|
}
|
|
}
|
|
|
|
_joinRoom(peer, roomType = 'ip', roomSecret = '') {
|
|
const room = roomType === 'ip' ? peer.ip : roomSecret;
|
|
|
|
if (this._rooms[room] && this._rooms[room][peer.id]) {
|
|
this._leaveRoom(peer, roomType, roomSecret);
|
|
}
|
|
|
|
// if room doesn't exist, create it
|
|
if (!this._rooms[room]) {
|
|
this._rooms[room] = {};
|
|
}
|
|
|
|
this._notifyPeers(peer, roomType, roomSecret);
|
|
|
|
// add peer to room
|
|
this._rooms[room][peer.id] = peer;
|
|
// add secret to peer
|
|
if (roomType === 'secret') {
|
|
peer.addRoomSecret(roomSecret);
|
|
}
|
|
}
|
|
|
|
_leaveRoom(peer, roomType = 'ip', roomSecret = '', disconnect = false) {
|
|
const room = roomType === 'ip' ? peer.ip : roomSecret;
|
|
|
|
if (!this._rooms[room] || !this._rooms[room][peer.id]) return;
|
|
this._cancelKeepAlive(this._rooms[room][peer.id]);
|
|
|
|
// delete the peer
|
|
delete this._rooms[room][peer.id];
|
|
|
|
if (roomType === 'ip') {
|
|
peer.socket.terminate();
|
|
}
|
|
|
|
//if room is empty, delete the room
|
|
if (!Object.keys(this._rooms[room]).length) {
|
|
delete this._rooms[room];
|
|
} else {
|
|
// notify all other peers
|
|
for (const otherPeerId in this._rooms[room]) {
|
|
const otherPeer = this._rooms[room][otherPeerId];
|
|
this._send(otherPeer, {
|
|
type: 'peer-left',
|
|
peerId: peer.id,
|
|
roomType: roomType,
|
|
roomSecret: roomSecret,
|
|
disconnect: disconnect
|
|
});
|
|
}
|
|
}
|
|
//remove secret from peer
|
|
if (roomType === 'secret') {
|
|
peer.removeRoomSecret(roomSecret);
|
|
}
|
|
}
|
|
|
|
_notifyPeers(peer, roomType = 'ip', roomSecret = '') {
|
|
const room = roomType === 'ip' ? peer.ip : roomSecret;
|
|
if (!this._rooms[room]) return;
|
|
|
|
// notify all other peers
|
|
for (const otherPeerId in this._rooms[room]) {
|
|
if (otherPeerId === peer.id) continue;
|
|
const otherPeer = this._rooms[room][otherPeerId];
|
|
this._send(otherPeer, {
|
|
type: 'peer-joined',
|
|
peer: peer.getInfo(),
|
|
roomType: roomType,
|
|
roomSecret: roomSecret
|
|
});
|
|
}
|
|
|
|
// notify peer about the other peers
|
|
const otherPeers = [];
|
|
for (const otherPeerId in this._rooms[room]) {
|
|
if (otherPeerId === peer.id) continue;
|
|
otherPeers.push(this._rooms[room][otherPeerId].getInfo());
|
|
}
|
|
|
|
this._send(peer, {
|
|
type: 'peers',
|
|
peers: otherPeers,
|
|
roomType: roomType,
|
|
roomSecret: roomSecret
|
|
});
|
|
}
|
|
|
|
_joinSecretRooms(peer, roomSecrets) {
|
|
for (let i=0; i<roomSecrets.length; i++) {
|
|
this._joinRoom(peer, 'secret', roomSecrets[i])
|
|
}
|
|
}
|
|
|
|
_leaveAllSecretRooms(peer, disconnect = false) {
|
|
for (let i=0; i<peer.roomSecrets.length; i++) {
|
|
this._leaveRoom(peer, 'secret', peer.roomSecrets[i], disconnect);
|
|
}
|
|
}
|
|
|
|
_send(peer, message) {
|
|
if (!peer) return;
|
|
if (this._wss.readyState !== this._wss.OPEN) return;
|
|
message = JSON.stringify(message);
|
|
peer.socket.send(message);
|
|
}
|
|
|
|
_keepAlive(peer) {
|
|
this._cancelKeepAlive(peer);
|
|
let timeout = 500;
|
|
if (!peer.lastBeat) {
|
|
peer.lastBeat = Date.now();
|
|
}
|
|
if (Date.now() - peer.lastBeat > 2 * timeout) {
|
|
this._leaveRoom(peer);
|
|
this._leaveAllSecretRooms(peer);
|
|
return;
|
|
}
|
|
|
|
this._send(peer, { type: 'ping' });
|
|
|
|
peer.timerId = setTimeout(() => this._keepAlive(peer), timeout);
|
|
}
|
|
|
|
_cancelKeepAlive(peer) {
|
|
if (peer && peer.timerId) {
|
|
clearTimeout(peer.timerId);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
class Peer {
|
|
|
|
constructor(socket, request) {
|
|
// set socket
|
|
this.socket = socket;
|
|
|
|
// set remote ip
|
|
this._setIP(request);
|
|
|
|
// set peer id
|
|
this._setPeerId(request)
|
|
|
|
// is WebRTC supported ?
|
|
this.rtcSupported = request.url.indexOf('webrtc') > -1;
|
|
|
|
// set name
|
|
this._setName(request);
|
|
|
|
// for keepalive
|
|
this.timerId = 0;
|
|
this.lastBeat = Date.now();
|
|
|
|
this.roomSecrets = [];
|
|
this.roomKey = null;
|
|
this.roomKeyRate = 0;
|
|
}
|
|
|
|
_setIP(request) {
|
|
if (request.headers['cf-connecting-ip']) {
|
|
this.ip = request.headers['cf-connecting-ip'].split(/\s*,\s*/)[0];
|
|
} else if (request.headers['x-forwarded-for']) {
|
|
this.ip = request.headers['x-forwarded-for'].split(/\s*,\s*/)[0];
|
|
} else {
|
|
this.ip = request.connection.remoteAddress;
|
|
}
|
|
|
|
// remove the prefix used for IPv4-translated addresses
|
|
if (this.ip.substring(0,7) === "::ffff:")
|
|
this.ip = this.ip.substring(7);
|
|
|
|
// IPv4 and IPv6 use different values to refer to localhost
|
|
// put all peers on the same network as the server into the same room as well
|
|
if (this.ip === '::1' || this.ipIsPrivate(this.ip)) {
|
|
this.ip = '127.0.0.1';
|
|
}
|
|
}
|
|
|
|
ipIsPrivate(ip) {
|
|
// if ip is IPv4
|
|
if (!ip.includes(":")) {
|
|
// 10.0.0.0 - 10.255.255.255 || 172.16.0.0 - 172.31.255.255 || 192.168.0.0 - 192.168.255.255
|
|
return /^(10)\.(.*)\.(.*)\.(.*)$/.test(ip) || /^(172)\.(1[6-9]|2[0-9]|3[0-1])\.(.*)\.(.*)$/.test(ip) || /^(192)\.(168)\.(.*)\.(.*)$/.test(ip)
|
|
}
|
|
|
|
// else: ip is IPv6
|
|
const firstWord = ip.split(":").find(el => !!el); //get first not empty word
|
|
|
|
// The original IPv6 Site Local addresses (fec0::/10) are deprecated. Range: fec0 - feff
|
|
if (/^fe[c-f][0-f]$/.test(firstWord))
|
|
return true;
|
|
|
|
// These days Unique Local Addresses (ULA) are used in place of Site Local.
|
|
// Range: fc00 - fcff
|
|
else if (/^fc[0-f]{2}$/.test(firstWord))
|
|
return true;
|
|
|
|
// Range: fd00 - fcff
|
|
else if (/^fd[0-f]{2}$/.test(firstWord))
|
|
return true;
|
|
|
|
// Link local addresses (prefixed with fe80) are not routable
|
|
else if (firstWord === "fe80")
|
|
return true;
|
|
|
|
// Discard Prefix
|
|
else if (firstWord === "100")
|
|
return true;
|
|
|
|
// Any other IP address is not Unique Local Address (ULA)
|
|
return false;
|
|
}
|
|
|
|
_setPeerId(request) {
|
|
const searchParams = new URL(request.url, "http://server").searchParams;
|
|
let peerId = searchParams.get("peer_id");
|
|
let peerIdHash = searchParams.get("peer_id_hash");
|
|
if (peerId && Peer.isValidUuid(peerId) && this.isPeerIdHashValid(peerId, peerIdHash)) {
|
|
this.id = peerId;
|
|
} else {
|
|
this.id = crypto.randomUUID();
|
|
}
|
|
}
|
|
|
|
toString() {
|
|
return `<Peer id=${this.id} ip=${this.ip} rtcSupported=${this.rtcSupported}>`
|
|
}
|
|
|
|
_setName(req) {
|
|
let ua = parser(req.headers['user-agent']);
|
|
|
|
|
|
let deviceName = '';
|
|
|
|
if (ua.os && ua.os.name) {
|
|
deviceName = ua.os.name.replace('Mac OS', 'Mac') + ' ';
|
|
}
|
|
|
|
if (ua.device.model) {
|
|
deviceName += ua.device.model;
|
|
} else {
|
|
deviceName += ua.browser.name;
|
|
}
|
|
|
|
if(!deviceName)
|
|
deviceName = 'Unknown Device';
|
|
|
|
const displayName = uniqueNamesGenerator({
|
|
length: 2,
|
|
separator: ' ',
|
|
dictionaries: [colors, animals],
|
|
style: 'capital',
|
|
seed: this.id.hashCode()
|
|
})
|
|
|
|
this.name = {
|
|
model: ua.device.model,
|
|
os: ua.os.name,
|
|
browser: ua.browser.name,
|
|
type: ua.device.type,
|
|
deviceName,
|
|
displayName
|
|
};
|
|
}
|
|
|
|
getInfo() {
|
|
return {
|
|
id: this.id,
|
|
name: this.name,
|
|
rtcSupported: this.rtcSupported
|
|
}
|
|
}
|
|
|
|
static isValidUuid(uuid) {
|
|
return /^([0-9]|[a-f]){8}-(([0-9]|[a-f]){4}-){3}([0-9]|[a-f]){12}$/.test(uuid);
|
|
}
|
|
|
|
isPeerIdHashValid(peerId, peerIdHash) {
|
|
return peerIdHash === peerId.hashCode128BitSalted();
|
|
}
|
|
|
|
addRoomSecret(roomSecret) {
|
|
if (!(roomSecret in this.roomSecrets)) {
|
|
this.roomSecrets.push(roomSecret);
|
|
}
|
|
}
|
|
|
|
removeRoomSecret(roomSecret) {
|
|
if (roomSecret in this.roomSecrets) {
|
|
delete this.roomSecrets[roomSecret];
|
|
}
|
|
}
|
|
}
|
|
|
|
Object.defineProperty(String.prototype, 'hashCode', {
|
|
value: function() {
|
|
return cyrb53(this);
|
|
}
|
|
});
|
|
|
|
Object.defineProperty(String.prototype, 'hashCode128BitSalted', {
|
|
value: function() {
|
|
return hasher.hashCode128BitSalted(this);
|
|
}
|
|
});
|
|
|
|
const hasher = (() => {
|
|
let seeds;
|
|
return {
|
|
hashCode128BitSalted(str) {
|
|
if (!seeds) {
|
|
// seeds are created on first call to salt hash.
|
|
seeds = [4];
|
|
for (let i=0; i<4; i++) {
|
|
const randomBuffer = new Uint32Array(1);
|
|
crypto.webcrypto.getRandomValues(randomBuffer);
|
|
seeds[i] = randomBuffer[0];
|
|
}
|
|
}
|
|
let hashCode = "";
|
|
for (let i=0; i<4; i++) {
|
|
hashCode += cyrb53(str, seeds[i]);
|
|
}
|
|
return hashCode;
|
|
}
|
|
}
|
|
|
|
})()
|
|
|
|
/*
|
|
cyrb53 (c) 2018 bryc (github.com/bryc)
|
|
A fast and simple hash function with decent collision resistance.
|
|
Largely inspired by MurmurHash2/3, but with a focus on speed/simplicity.
|
|
Public domain. Attribution appreciated.
|
|
*/
|
|
const cyrb53 = function(str, seed = 0) {
|
|
let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed;
|
|
for (let i = 0, ch; i < str.length; i++) {
|
|
ch = str.charCodeAt(i);
|
|
h1 = Math.imul(h1 ^ ch, 2654435761);
|
|
h2 = Math.imul(h2 ^ ch, 1597334677);
|
|
}
|
|
h1 = Math.imul(h1 ^ (h1>>>16), 2246822507) ^ Math.imul(h2 ^ (h2>>>13), 3266489909);
|
|
h2 = Math.imul(h2 ^ (h2>>>16), 2246822507) ^ Math.imul(h1 ^ (h1>>>13), 3266489909);
|
|
return 4294967296 * (2097151 & h2) + (h1>>>0);
|
|
};
|
|
|
|
new PairDropServer();
|