зеркало из https://github.com/mozilla/galaxy-api.git
WIP client services
This commit is contained in:
Родитель
6e8677e464
Коммит
cab3057812
|
@ -17,3 +17,4 @@ node_modules
|
|||
|
||||
settings_local.js
|
||||
data/
|
||||
dump.rdb
|
||||
|
|
27
app.js
27
app.js
|
@ -37,7 +37,7 @@ wss.on('connection', function(ws) {
|
|||
var authenticated = false;
|
||||
var subscribed = false;
|
||||
|
||||
var user = new user.User(clientData);
|
||||
var user_ = new user.User(clientData);
|
||||
|
||||
function send(data) {
|
||||
return ws.send(JSON.stringify(data));
|
||||
|
@ -53,8 +53,8 @@ wss.on('connection', function(ws) {
|
|||
return;
|
||||
}
|
||||
|
||||
console.log('auth', user.authenticated);
|
||||
if (!user.authenticated) {
|
||||
console.log('auth', user_.authenticated);
|
||||
if (!user_.authenticated) {
|
||||
if (message.type !== 'auth') {
|
||||
// Ignore non-auth requests from the client until
|
||||
// authentication has taken place.
|
||||
|
@ -66,12 +66,12 @@ wss.on('connection', function(ws) {
|
|||
send({type: 'error', error: 'bad_token'});
|
||||
return;
|
||||
}
|
||||
user.authenticate(result, function(err) {
|
||||
user_.authenticate(result, function(err) {
|
||||
if (err) {
|
||||
send({type: 'error', error: err});
|
||||
} else {
|
||||
clientPub.subscribe('user:' + user.get('id'));
|
||||
send({type: 'authenticated', id: user.get('id')});
|
||||
clientPub.subscribe('user:' + user_.get('id'));
|
||||
send({type: 'authenticated', id: user_.get('id')});
|
||||
// TODO: broadcast to friends that user is online.
|
||||
}
|
||||
});
|
||||
|
@ -80,17 +80,17 @@ wss.on('connection', function(ws) {
|
|||
console.log('message', message)
|
||||
switch (message.type) {
|
||||
case 'playing':
|
||||
user.startPlaying(message.game, function(err) {
|
||||
user_.startPlaying(message.game, function(err) {
|
||||
if (!err) return;
|
||||
send({type: 'error', error: err});
|
||||
});
|
||||
// TODO: broadcast this to friends.
|
||||
break;
|
||||
case 'notPlaying':
|
||||
user.donePlaying();
|
||||
user_.donePlaying();
|
||||
break;
|
||||
case 'score':
|
||||
user.updateLeaderboard(message.board, message.value, function(err) {
|
||||
user_.updateLeaderboard(message.board, message.value, function(err) {
|
||||
if (!err) return;
|
||||
send({type: 'error', error: err});
|
||||
});
|
||||
|
@ -100,19 +100,16 @@ wss.on('connection', function(ws) {
|
|||
|
||||
ws.on('close', function() {
|
||||
console.log('close');
|
||||
intervals.forEach(function(v) {
|
||||
clearInterval(v);
|
||||
});
|
||||
if (user.authenticated && subscribed) {
|
||||
if (user_.authenticated && subscribed) {
|
||||
clientPub.unsubscribe();
|
||||
}
|
||||
user.finish();
|
||||
user_.finish();
|
||||
clientPub.end();
|
||||
clientData.end();
|
||||
});
|
||||
|
||||
clientPub.on('message', function(channel, message) {
|
||||
if (!user.authenticated) {
|
||||
if (!user_.authenticated) {
|
||||
return;
|
||||
}
|
||||
ws.send(message);
|
||||
|
|
90
game.html
90
game.html
|
@ -1,90 +1,2 @@
|
|||
<ul id="pings"></ul>
|
||||
<script src="//cdnjs.cloudflare.com/ajax/libs/lodash.js/2.4.1/lodash.min.js"></script>
|
||||
<script>
|
||||
|
||||
function getVars(qs, excl_undefined) {
|
||||
if (!qs) qs = location.search;
|
||||
if (!qs || qs === '?') return {};
|
||||
if (qs[0] == '?') {
|
||||
qs = qs.substr(1); // Filter off the leading ? if it's there.
|
||||
}
|
||||
|
||||
return _.chain(qs.split('&')) // ['a=b', 'c=d']
|
||||
.map(function(c) {return c.split('=').map(decodeURIComponent);}) // [['a', 'b'], ['c', 'd']]
|
||||
.filter(function(p) { // [['a', 'b'], ['c', undefined]] -> [['a', 'b']]
|
||||
return !!p[0] && (!excl_undefined || !_.isUndefined(p[1]));
|
||||
}).object() // {'a': 'b', 'c': 'd'}
|
||||
.value();
|
||||
}
|
||||
|
||||
var host = 'ws://' + window.location.host;
|
||||
host = 'ws://localhost:5000';
|
||||
|
||||
var GET = getVars();
|
||||
var game = GET.game;
|
||||
var user = GET.user;
|
||||
console.log(game, user);
|
||||
|
||||
|
||||
var timerID;
|
||||
var intervals = [];
|
||||
|
||||
function WS() {
|
||||
|
||||
var ws = new WebSocket(host);
|
||||
ws.onmessage = function(event) {
|
||||
var li = document.createElement('li');
|
||||
li.innerHTML = JSON.parse(event.data);
|
||||
document.querySelector('#pings').appendChild(li);
|
||||
};
|
||||
|
||||
ws.onerror = function(error) {
|
||||
console.error('[websocket] Error:', error.toString());
|
||||
};
|
||||
|
||||
ws.onclose = function() {
|
||||
console.log('[websocket] open');
|
||||
|
||||
intervals.forEach(function(v) {
|
||||
clearInterval(v);
|
||||
});
|
||||
|
||||
// Fire only one setTimeout at a time.
|
||||
if (!timerID) {
|
||||
timerID = setTimeout(function() {
|
||||
WS();
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
ws.sendd = function(msg) {
|
||||
console.log(msg);
|
||||
return ws.send(msg);
|
||||
};
|
||||
|
||||
ws.onopen = function() {
|
||||
if (timerID) { // A setInterval has been fired
|
||||
clearInterval(timerID);
|
||||
timerID = 0;
|
||||
}
|
||||
|
||||
console.log('[websocket] open');
|
||||
|
||||
// TODO: Authenticate.
|
||||
ws.sendd(JSON.stringify({
|
||||
type: 'auth',
|
||||
user: user
|
||||
}));
|
||||
|
||||
ws.sendd(JSON.stringify({
|
||||
type: 'playing',
|
||||
user: user,
|
||||
game: game
|
||||
}));
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
WS();
|
||||
|
||||
</script>
|
||||
<html><body><script src="http://localhost:5000/include.js"></script></body></html>
|
||||
|
|
|
@ -56,7 +56,12 @@ function createSSA(email) {
|
|||
exports.createSSA = createSSA;
|
||||
|
||||
function verifySSA(token) {
|
||||
var tokenBits = token.split(',', 3);
|
||||
var tokenBits;
|
||||
try {
|
||||
tokenBits = token.split(',', 3);
|
||||
} catch(e) {
|
||||
return false;
|
||||
}
|
||||
if (tokenBits.length !== 3) return false;
|
||||
|
||||
var guid = tokenBits[1];
|
||||
|
|
16
lib/user.js
16
lib/user.js
|
@ -50,10 +50,13 @@ user.prototype.authenticate = function(email, callback) {
|
|||
return;
|
||||
}
|
||||
if (!userData) {
|
||||
finishAuth(newUser());
|
||||
finishAuth(newUser(self.dataChannel, email));
|
||||
} else {
|
||||
userData = JSON.parse(userData);
|
||||
self.dataChannel.hget('authenticated', userData.id, function(err, resp) {
|
||||
if (userData.id === self.get('id')) {
|
||||
callback(null);
|
||||
return;
|
||||
}
|
||||
self.dataChannel.sismember('authenticated', userData.id, function(err, resp) {
|
||||
if (resp) {
|
||||
callback('already_authenticated');
|
||||
return;
|
||||
|
@ -64,9 +67,9 @@ user.prototype.authenticate = function(email, callback) {
|
|||
});
|
||||
|
||||
function finishAuth(userData) {
|
||||
self.dataChannel.hset('authenticated', userData.id, 'y');
|
||||
self.dataChannel.sadd('authenticated', userData.id);
|
||||
self.set('username', userData.username);
|
||||
self.set('uid', userData.id);
|
||||
self.set('id', userData.id);
|
||||
self.set('email', userData.email);
|
||||
self.authenticated = true;
|
||||
callback(null);
|
||||
|
@ -182,7 +185,7 @@ user.prototype.updateLeaderboard = function(board, value, callback) {
|
|||
|
||||
user.prototype.finish = function() {
|
||||
if (this.authenticated) {
|
||||
this.dataChannel.hdel('authenticated', this.get('id'));
|
||||
this.dataChannel.srem('authenticated', this.get('id'));
|
||||
this.authenticated = false;
|
||||
}
|
||||
this.donePlaying();
|
||||
|
@ -251,6 +254,7 @@ function publicUserObj(full) {
|
|||
id: full.id
|
||||
};
|
||||
}
|
||||
exports.publicUserObj = publicUserObj;
|
||||
|
||||
function getPublicUserObj(client, id, callback) {
|
||||
// `callback` is called with a single parameter, which is either
|
||||
|
|
180
static/host.html
180
static/host.html
|
@ -0,0 +1,180 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>Galaxy</title>
|
||||
</head>
|
||||
<body>
|
||||
<script src="https://login.persona.org/include.js"></script>
|
||||
<script type="text/javascript">
|
||||
<!--
|
||||
var host = 'ws://' + window.location.host;
|
||||
var ws;
|
||||
|
||||
var origin = location.hash.substr(1);
|
||||
|
||||
var errors = 0;
|
||||
|
||||
function connect() {
|
||||
ws = new WebSocket(host);
|
||||
console.log('Starting socket');
|
||||
ws.onmessage = function(event) {
|
||||
window.top.postMessage(JSON.parse(event.data), origin);
|
||||
};
|
||||
|
||||
ws.onerror = function(error) {
|
||||
console.error('[websocket] Error:', error.toString());
|
||||
};
|
||||
|
||||
ws.onclose = function() {
|
||||
console.log('[websocket] close');
|
||||
errors++;
|
||||
if (errors > 10) return;
|
||||
connect();
|
||||
};
|
||||
|
||||
ws.sendd = function(msg) {
|
||||
console.log(msg);
|
||||
return this.send(msg);
|
||||
};
|
||||
|
||||
ws.onopen = function() {
|
||||
console.log('[websocket] open');
|
||||
if (ssa) {
|
||||
ws.send(JSON.stringify({type: 'auth', token: ssa}));
|
||||
}
|
||||
ready();
|
||||
};
|
||||
}
|
||||
|
||||
function xhr(method, url, data, callback) {
|
||||
// TODO: Make this hardier.
|
||||
var req = new XMLHttpRequest();
|
||||
req.onload = function() {
|
||||
if (Math.floor(req.status / 100) !== 2) {
|
||||
callback(null);
|
||||
return;
|
||||
}
|
||||
callback(req.responseText);
|
||||
};
|
||||
req.onerror = function() {
|
||||
callback(null);
|
||||
};
|
||||
req.open(method, url, true);
|
||||
if (data) {
|
||||
req.setRequestHeader('content-type', 'application/x-www-form-urlencoded');
|
||||
}
|
||||
req.send(data);
|
||||
}
|
||||
|
||||
var SSATOKEN = '0::user';
|
||||
var ssa = window.localStorage.getItem(SSATOKEN);
|
||||
function authenticate(callback, callback_cancel) {
|
||||
// TODO: Port login.js to work here instead.
|
||||
if (ssa) return callback(ssa), null;
|
||||
navigator.id.watch({
|
||||
onlogin: function(assertion) {
|
||||
xhr(
|
||||
'POST',
|
||||
'/user/login',
|
||||
'assertion=' + encodeURIComponent(assertion) +
|
||||
'&audience=' + encodeURIComponent(
|
||||
window.location.protocol + '//' + window.location.host),
|
||||
function(resp) {
|
||||
var data;
|
||||
try {
|
||||
data = JSON.parse(resp);
|
||||
} catch (e) {
|
||||
callback_cancel();
|
||||
return;
|
||||
}
|
||||
ssa = data.token;
|
||||
localStorage.setItem(SSATOKEN, data.token);
|
||||
if (wasReady) {
|
||||
ws.send(JSON.stringify({type: 'auth', token: data.token}));
|
||||
}
|
||||
callback(data);
|
||||
}
|
||||
);
|
||||
},
|
||||
onlogout: function() {}
|
||||
});
|
||||
navigator.id.request({
|
||||
siteName: 'Mozilla Galaxy',
|
||||
oncancel: callback_cancel
|
||||
});
|
||||
}
|
||||
|
||||
function notify(title, body, icon, callback) {
|
||||
if (window.Notification) {
|
||||
function donotify() {
|
||||
var notification = new Notification(title, {
|
||||
body: body,
|
||||
icon: icon
|
||||
});
|
||||
notification.onclick = callback;
|
||||
}
|
||||
if (Notification.permission === 'granted') {
|
||||
donotify();
|
||||
} else if (Notification.permission !== 'denied') {
|
||||
Notification.requestPermission(function(permission) {
|
||||
if (permission !== 'granted') return;
|
||||
donotify();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
var havePermission = window.webkitNotifications.checkPermission();
|
||||
if (havePermission === 0) {
|
||||
var notification = window.webkitNotifications.createNotification(
|
||||
icon || null,
|
||||
title,
|
||||
body
|
||||
);
|
||||
notification.onclick = callback;
|
||||
notification.show();
|
||||
} else {
|
||||
window.webkitNotifications.requestPermission();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', function(e) {
|
||||
if (e.origin !== origin) {
|
||||
return;
|
||||
}
|
||||
console.log('host', e);
|
||||
var data = e.data;
|
||||
if (data.request) {
|
||||
ws.send(JSON.stringify(data.request));
|
||||
return;
|
||||
} else if (data.require) {
|
||||
switch(data.require) {
|
||||
case 'auth':
|
||||
authenticate(function() {
|
||||
// Noop. The server sends the official authenticated message.
|
||||
}, function() {
|
||||
window.top.postMessage({type: 'error', error: 'could_not_auth'}, origin);
|
||||
});
|
||||
}
|
||||
} else if (data.dispatch) {
|
||||
var url = data.url;
|
||||
if (data.signed && authenticated) {
|
||||
url += (url.indexOf('?') === -1) ? '?' : '&';
|
||||
url += '_user=' + encodeURIComponent(ssa);
|
||||
}
|
||||
xhr(data.method, url, data.data, function(resp) {
|
||||
window.top.postMessage({type: 'response', response: data.dispatch, data: resp}, origin);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
var wasReady = false;
|
||||
function ready() {
|
||||
if (wasReady) return;
|
||||
wasReady = true;
|
||||
console.log('Galaxy Ready!');
|
||||
window.top.postMessage('galaxy ready', origin);
|
||||
}
|
||||
connect();
|
||||
-->
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,253 @@
|
|||
;(function(exports) {
|
||||
|
||||
var origin = window.location.protocol + '//' + window.location.host;
|
||||
var slice = function(arr) {return Array.prototype.slice.call(arr, 0)};
|
||||
|
||||
var ifr = document.createElement('iframe');
|
||||
ifr.src = 'http://localhost:5000/host.html#' + origin;
|
||||
ifr.style.display = 'none';
|
||||
document.body.appendChild(ifr);
|
||||
|
||||
var Deferred = (function() {
|
||||
|
||||
var PENDING = 'pending';
|
||||
var RESOLVED = 'resolved';
|
||||
var REJECTED = 'rejected';
|
||||
|
||||
function defer(beforeStart) {
|
||||
var _this = this;
|
||||
var state = PENDING;
|
||||
|
||||
var doneCBs = [];
|
||||
var failCBs = [];
|
||||
|
||||
var closedArgs = [];
|
||||
|
||||
function execute(funcs, args, ctx) {
|
||||
for (var i = 0, e; e = funcs[i++];) {
|
||||
if (Array.isArray(e)) {
|
||||
execute(e, args, ctx);
|
||||
} else {
|
||||
e.apply(ctx || _this, args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closer(list, new_state, ctx) {
|
||||
return function() {
|
||||
if (state !== PENDING) {
|
||||
return;
|
||||
}
|
||||
state = new_state;
|
||||
var args = slice(arguments);
|
||||
execute(list, closedArgs = ctx ? args.slice(1) : args, ctx ? args[0] : _this);
|
||||
return _this;
|
||||
};
|
||||
}
|
||||
|
||||
this.resolve = closer(doneCBs, RESOLVED);
|
||||
this.resolveWith = closer(doneCBs, RESOLVED, true);
|
||||
this.reject = closer(failCBs, REJECTED);
|
||||
this.rejectWith = closer(failCBs, REJECTED, true);
|
||||
|
||||
this.promise = function(obj) {
|
||||
obj = obj || {};
|
||||
function wrap(instant, cblist) {
|
||||
return function(arglist) {
|
||||
var args = slice(arguments);
|
||||
if (state === instant) {
|
||||
execute(args, closedArgs);
|
||||
} else if (state === PENDING) {
|
||||
for (var i = 0, e; e = args[i++];) {
|
||||
cblist.push(e);
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
}
|
||||
obj.state = function() {return state;};
|
||||
obj.done = wrap(RESOLVED, doneCBs);
|
||||
obj.fail = wrap(REJECTED, failCBs);
|
||||
obj.then = function(doneFilter, failFilter) {
|
||||
var def = new defer();
|
||||
obj.done(function() {
|
||||
var args = slice(arguments);
|
||||
def.resolveWith.apply(this, [this].concat(doneFilter ? doneFilter.apply(this, args) : args));
|
||||
});
|
||||
obj.fail(function() {
|
||||
var args = slice(arguments);
|
||||
def.rejectWith.apply(this, [this].concat(failFilter ? failFilter.apply(this, args) : args));
|
||||
});
|
||||
return def.promise();
|
||||
};
|
||||
obj.always = function() {
|
||||
_this.done.apply(_this, arguments).fail.apply(_this, arguments);
|
||||
return obj;
|
||||
};
|
||||
return obj;
|
||||
};
|
||||
|
||||
this.promise(this);
|
||||
|
||||
if (beforeStart) {
|
||||
beforeStart.call(this, this);
|
||||
}
|
||||
}
|
||||
|
||||
return function(func) {return new defer(func)};
|
||||
})();
|
||||
|
||||
Deferred.when = this.when = function() {
|
||||
var args = slice(arguments);
|
||||
if (args.length === 1 && args[0].promise) {
|
||||
return args[0].promise();
|
||||
}
|
||||
var out = [];
|
||||
var def = this.Deferred();
|
||||
var count = 0;
|
||||
for (var i = 0, e; e = args[i];) {
|
||||
if (!e.promise) {
|
||||
out[i++] = e;
|
||||
continue;
|
||||
}
|
||||
count++;
|
||||
(function(i) {
|
||||
e.fail(def.reject).done(function() {
|
||||
count--;
|
||||
out[i] = slice(arguments);
|
||||
if (!count) {
|
||||
def.resolve.apply(def, out);
|
||||
}
|
||||
});
|
||||
})(i++);
|
||||
}
|
||||
if (!count) {def.resolve.apply(def, out);}
|
||||
return def.promise();
|
||||
};
|
||||
|
||||
var initializer = Deferred();
|
||||
var initialized = false;
|
||||
|
||||
var waiting = {};
|
||||
window.addEventListener('message', function(e) {
|
||||
if (e.origin === origin) return;
|
||||
console.log('include', e);
|
||||
// TODO: Put in the real Galaxy API origin.
|
||||
if (false && e.origin !== 'https://api.galaxy.mozilla.org') {
|
||||
return;
|
||||
}
|
||||
// Setup code.
|
||||
if (!initialized) {
|
||||
if (e.data === 'galaxy ready') {
|
||||
initialized = true;
|
||||
initializer.resolve();
|
||||
}
|
||||
return;
|
||||
}
|
||||
var data;
|
||||
try {
|
||||
data = JSON.parse(e.data);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
// XHR message bus.
|
||||
if (data.type === 'authenticated') {
|
||||
authenticated = true;
|
||||
auth_def.resolve();
|
||||
} else if (data.type === 'response') {
|
||||
if (!(data.response in waiting)) return;
|
||||
waiting[data.response].forEach(function(cb) {
|
||||
cb(data.data);
|
||||
});
|
||||
delete waiting[data.response];
|
||||
}
|
||||
|
||||
});
|
||||
function send(data) {
|
||||
// TODO: Make this point at the Galaxy API origin.
|
||||
initializer.done(function() {
|
||||
ifr.contentWindow.postMessage(data, '*');
|
||||
});
|
||||
}
|
||||
|
||||
function request(opts, callback) {
|
||||
(waiting[opts.dispatch] = waiting[opts.dispatch] || []).push(callback);
|
||||
send(opts);
|
||||
}
|
||||
|
||||
var game;
|
||||
exports.configure = function(this_game) {
|
||||
if (game) return;
|
||||
game = this_game;
|
||||
};
|
||||
|
||||
var authenticated = false;
|
||||
var auth_def = Deferred();
|
||||
function requireAuth(func) {
|
||||
return function() {
|
||||
if (!authenticated) {
|
||||
send({require: 'auth'});
|
||||
var self = this;
|
||||
var args = arguments;
|
||||
auth_def.done(function() {
|
||||
func.apply(self, args);
|
||||
});
|
||||
return;
|
||||
}
|
||||
return func.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
var playing = false;
|
||||
exports.playing = requireAuth(function() {
|
||||
if (playing) return;
|
||||
playing = true;
|
||||
send({type: 'playing', game: game});
|
||||
});
|
||||
exports.donePlaying = requireAuth(function() {
|
||||
if (!playing) return;
|
||||
playing = false;
|
||||
send({type: 'notPlaying'});
|
||||
});
|
||||
|
||||
// TODO: Throttle this method the same as on the server.
|
||||
exports.updateScore = requireAuth(function(board, increment) {
|
||||
if (!playing) return;
|
||||
playing = false;
|
||||
// Do basic validation that increment is within the right range.
|
||||
send({type: 'score', game: game, value: increment | 0 || 0}); // NaN trap.
|
||||
});
|
||||
|
||||
exports.authenticate = function() {
|
||||
if (authenticated) {
|
||||
return Deferred().resolve().promise();
|
||||
}
|
||||
send({require: 'auth'});
|
||||
return auth_def.promise();
|
||||
};
|
||||
|
||||
exports.getFriends = function() {
|
||||
if (!authenticated) return [];
|
||||
|
||||
var resp = Deferred();
|
||||
request({
|
||||
dispatch: 'friends',
|
||||
url: '/user/friends',
|
||||
signed: true,
|
||||
method: 'GET'
|
||||
}, function(data) {
|
||||
if (data)
|
||||
resp.resolve(JSON.parse(data));
|
||||
else
|
||||
resp.reject();
|
||||
});
|
||||
return resp.promise();
|
||||
};
|
||||
|
||||
function requestPause() {
|
||||
var ev = document.createEvent('Event');
|
||||
e.initEvent('requestPause', true, false);
|
||||
window.dispatchEvent(e);
|
||||
}
|
||||
|
||||
})(navigator.game || (navigator.game = {}));
|
|
@ -20,7 +20,7 @@ module.exports = function(server) {
|
|||
var audience = POST.audience || '';
|
||||
|
||||
console.log('Attempting verification:', audience);
|
||||
|
||||
|
||||
auth.verifyPersonaAssertion(
|
||||
assertion,
|
||||
audience,
|
||||
|
@ -46,11 +46,8 @@ module.exports = function(server) {
|
|||
res.json({
|
||||
error: null,
|
||||
token: auth.createSSA(email),
|
||||
settings: {
|
||||
username: resp.username,
|
||||
email: email,
|
||||
id: resp.id
|
||||
},
|
||||
settings: resp,
|
||||
public: user.publicUserObj(resp),
|
||||
permissions: {}
|
||||
});
|
||||
client.end();
|
||||
|
|
Загрузка…
Ссылка в новой задаче