Define the models/relations for ACL
This commit is contained in:
parent
67b934357b
commit
48a0242711
|
@ -33,12 +33,15 @@
|
||||||
|
|
||||||
var loopback = require('../loopback');
|
var loopback = require('../loopback');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for Scope which represents the permissions that are granted to client applications by the resource owner
|
||||||
|
*/
|
||||||
var ScopeSchema = {
|
var ScopeSchema = {
|
||||||
name: {type: String, required: true},
|
name: {type: String, required: true},
|
||||||
description: String
|
description: String
|
||||||
};
|
};
|
||||||
|
|
||||||
var ScopeResourceAccessSchema = {
|
var ScopeACLSchema = {
|
||||||
model: String, // The name of the model
|
model: String, // The name of the model
|
||||||
property: String, // The name of the property, method, scope, or relation
|
property: String, // The name of the property, method, scope, or relation
|
||||||
|
|
||||||
|
@ -57,7 +60,7 @@ var ScopeResourceAccessSchema = {
|
||||||
scopeId: Number
|
scopeId: Number
|
||||||
};
|
};
|
||||||
|
|
||||||
var ScopeResourceAccess = loopback.createModel('ScopeResourceAccess', ScopeResourceAccessSchema, {
|
var ScopeACL = loopback.createModel('ScopeACL', ScopeACLSchema, {
|
||||||
relations: {
|
relations: {
|
||||||
scope: {
|
scope: {
|
||||||
type: 'belongsTo',
|
type: 'belongsTo',
|
||||||
|
@ -68,6 +71,10 @@ var ScopeResourceAccess = loopback.createModel('ScopeResourceAccess', ScopeResou
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Resource owner grants/delegates permissions to client applications
|
||||||
|
*
|
||||||
|
* For a protected resource, does the client application have the authorization from the resource owner (user or system)?
|
||||||
|
*
|
||||||
* Scope has many resource access entries
|
* Scope has many resource access entries
|
||||||
* @type {createModel|*}
|
* @type {createModel|*}
|
||||||
*/
|
*/
|
||||||
|
@ -75,12 +82,21 @@ var Scope = loopback.createModel('Scope', ScopeSchema, {
|
||||||
relations: {
|
relations: {
|
||||||
resources: {
|
resources: {
|
||||||
type: 'hasMany',
|
type: 'hasMany',
|
||||||
model: 'ScopeResourceAccess',
|
model: 'ScopeACL',
|
||||||
foreignKey: 'scopeId'
|
foreignKey: 'scopeId'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System grants permissions to principals (users/applications, can be grouped into roles).
|
||||||
|
*
|
||||||
|
* Protected resource: the model data and operations (model/property/method/relation/…)
|
||||||
|
*
|
||||||
|
* For a given principal, such as client application and/or user, is it allowed to access (read/write/execute)
|
||||||
|
* the protected resource?
|
||||||
|
*
|
||||||
|
*/
|
||||||
var ACLSchema = {
|
var ACLSchema = {
|
||||||
model: String, // The name of the model
|
model: String, // The name of the model
|
||||||
property: String, // The name of the property, method, scope, or relation
|
property: String, // The name of the property, method, scope, or relation
|
||||||
|
@ -112,32 +128,72 @@ var ACL = loopback.createModel('ACL', ACLSchema);
|
||||||
module.exports = {
|
module.exports = {
|
||||||
ACL: ACL,
|
ACL: ACL,
|
||||||
Scope: Scope,
|
Scope: Scope,
|
||||||
ScopeResourceAccess: ScopeResourceAccess
|
ScopeACL: ScopeACL
|
||||||
};
|
};
|
||||||
|
|
||||||
Scope.isAllowed = function (scope, model, property, accessType, callback) {
|
/**
|
||||||
|
* Check if the given principal is allowed to access the model/property
|
||||||
|
* @param principalType
|
||||||
|
* @param principalId
|
||||||
|
* @param model
|
||||||
|
* @param property
|
||||||
|
* @param accessType
|
||||||
|
* @param callback
|
||||||
|
*/
|
||||||
|
ACL.checkPermission = function (principalType, principalId, model, property, accessType, callback) {
|
||||||
|
ACL.find({where: {principalType: principalType, principalId: principalId,
|
||||||
|
model: model, property: {inq: [property, '*']}, accessType: {inq: [accessType, '*']}}},
|
||||||
|
function (err, acls) {
|
||||||
|
if (err) {
|
||||||
|
callback && callback(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var resolvedPermission = acls.reduce(function (previousValue, currentValue, index, array) {
|
||||||
|
// If the property is the same or the previous one is '*' (ALL)
|
||||||
|
if (previousValue.property === currentValue.property || (previousValue.property === '*' && currentValue.property)) {
|
||||||
|
previousValue.property = currentValue.property;
|
||||||
|
if (previousValue.accessType === currentValue.accessType || (previousValue.accessType === '*' && currentValue.accessType)) {
|
||||||
|
previousValue.accessType = currentValue.accessType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return previousValue;
|
||||||
|
}, {principalType: principalType, principalId: principalId, model: model, property: '*', accessType: '*', permission: 'Allow'});
|
||||||
|
callback && callback(resolvedPermission);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the given scope is allowed to access the model/property
|
||||||
|
* @param scope
|
||||||
|
* @param model
|
||||||
|
* @param property
|
||||||
|
* @param accessType
|
||||||
|
* @param callback
|
||||||
|
*/
|
||||||
|
Scope.checkPermission = function (scope, model, property, accessType, callback) {
|
||||||
Scope.findOne({where: {name: scope}}, function (err, scope) {
|
Scope.findOne({where: {name: scope}}, function (err, scope) {
|
||||||
if (err) {
|
if (err) {
|
||||||
callback && callback(err);
|
callback && callback(err);
|
||||||
} else {
|
} else {
|
||||||
scope.resources({where: {model: model, property: {inq: [property, '*']}, accessType: {inq: [accessType, '*']}}}, function (err, resources)
|
scope.resources({where: {model: model, property: {inq: [property, '*']}, accessType: {inq: [accessType, '*']}}}, function (err, resources) {
|
||||||
{
|
|
||||||
if (err) {
|
if (err) {
|
||||||
callback && callback(err);
|
callback && callback(err);
|
||||||
} else {
|
|
||||||
console.log('Resources: ', resources);
|
|
||||||
for (var r = 0; r < resources.length; r++) {
|
|
||||||
if (resources[r].permission === 'Allow') {
|
|
||||||
callback && callback(null, true);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Try to resolve the permission
|
||||||
|
var resolvedPermission = resources.reduce(function (previousValue, currentValue, index, array) {
|
||||||
|
// If the property is the same or the previous one is '*' (ALL)
|
||||||
|
if (previousValue.property === currentValue.property || (previousValue.property === '*' && currentValue.property)) {
|
||||||
|
previousValue.property = currentValue.property;
|
||||||
|
if (previousValue.accessType === currentValue.accessType || (previousValue.accessType === '*' && currentValue.accessType)) {
|
||||||
|
previousValue.accessType = currentValue.accessType;
|
||||||
}
|
}
|
||||||
callback && callback(null, false);
|
|
||||||
}
|
}
|
||||||
|
return previousValue;
|
||||||
|
}, {model: model, property: '*', accessType: '*', permission: 'Allow'});
|
||||||
|
callback && callback(resolvedPermission);
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
|
@ -5,36 +5,112 @@ var RoleSchema = {
|
||||||
id: {type: String, id: true}, // Id
|
id: {type: String, id: true}, // Id
|
||||||
name: {type: String, required: true}, // The name of a role
|
name: {type: String, required: true}, // The name of a role
|
||||||
description: String, // Description
|
description: String, // Description
|
||||||
// roles: [String], // A role can be an aggregate of other roles
|
|
||||||
// users: [String], // A role contains a list of user ids
|
|
||||||
|
|
||||||
parent: String,
|
|
||||||
// Timestamps
|
// Timestamps
|
||||||
created: {type: Date, default: Date},
|
created: {type: Date, default: Date},
|
||||||
modified: {type: Date, default: Date}
|
modified: {type: Date, default: Date}
|
||||||
};
|
};
|
||||||
|
|
||||||
var Role = loopback.createModel('Role', RoleSchema, {
|
/**
|
||||||
|
* Map principals to roles
|
||||||
|
*/
|
||||||
|
var RoleMappingSchema = {
|
||||||
|
id: {type: String, id: true}, // Id
|
||||||
|
roleId: String, // The role id
|
||||||
|
principalType: String, // The principal type, such as user, application, or role
|
||||||
|
principalId: String // The principal id
|
||||||
|
};
|
||||||
|
|
||||||
|
var RoleMapping = loopback.createModel('RoleMapping', RoleMappingSchema, {
|
||||||
relations: {
|
relations: {
|
||||||
roles: {
|
role: {
|
||||||
type: 'hasMany',
|
type: 'belongsTo',
|
||||||
model: 'Role',
|
model: 'Role',
|
||||||
foreignKey: 'parent'
|
foreignKey: 'roleId'
|
||||||
},
|
|
||||||
users: {
|
|
||||||
type: 'hasAndBelongsToMany',
|
|
||||||
model: 'user',
|
|
||||||
foreignKey: 'userId'
|
|
||||||
},
|
|
||||||
applications: {
|
|
||||||
type: 'hasAndBelongsToMany',
|
|
||||||
model: 'Application',
|
|
||||||
foreignKey: 'appId'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = Role;
|
RoleMapping.prototype.application = function(callback) {
|
||||||
|
if(this.principalType === 'application') {
|
||||||
|
loopback.Application.findById(this.principalId, callback);
|
||||||
|
} else {
|
||||||
|
process.nextTick(function() {
|
||||||
|
callback && callback(null, null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
RoleMapping.prototype.user = function(callback) {
|
||||||
|
if(this.principalType === 'user') {
|
||||||
|
loopback.User.findById(this.principalId, callback);
|
||||||
|
} else {
|
||||||
|
process.nextTick(function() {
|
||||||
|
callback && callback(null, null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
RoleMapping.prototype.childRole = function(callback) {
|
||||||
|
if(this.principalType === 'role') {
|
||||||
|
loopback.User.findById(this.principalId, callback);
|
||||||
|
} else {
|
||||||
|
process.nextTick(function() {
|
||||||
|
callback && callback(null, null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var Role = loopback.createModel('Role', RoleSchema, {
|
||||||
|
relations: {
|
||||||
|
principals: {
|
||||||
|
type: 'hasMany',
|
||||||
|
model: 'RoleMapping',
|
||||||
|
foreignKey: 'roleId'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
Role.once('dataSourceAttached', function() {
|
||||||
|
Role.prototype.users = function(callback) {
|
||||||
|
RoleMapping.find({where: {roleId: this.id, principalType: 'user'}}, function(err, mappings) {
|
||||||
|
if(err) {
|
||||||
|
callback && callback(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return mappings.map(function(m) {
|
||||||
|
return m.principalId;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Role.prototype.applications = function(callback) {
|
||||||
|
RoleMapping.find({where: {roleId: this.id, principalType: 'application'}}, function(err, mappings) {
|
||||||
|
if(err) {
|
||||||
|
callback && callback(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return mappings.map(function(m) {
|
||||||
|
return m.principalId;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Role.prototype.roles = function(callback) {
|
||||||
|
RoleMapping.find({where: {roleId: this.id, principalType: 'role'}}, function(err, mappings) {
|
||||||
|
if(err) {
|
||||||
|
callback && callback(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return mappings.map(function(m) {
|
||||||
|
return m.principalId;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// Special roles
|
// Special roles
|
||||||
Role.OWNER = '$owner'; // owner of the object
|
Role.OWNER = '$owner'; // owner of the object
|
||||||
|
@ -42,3 +118,47 @@ Role.RELATED = "$related"; // any User with a relationship to the object
|
||||||
Role.AUTHENTICATED = "$authenticated"; // authenticated user
|
Role.AUTHENTICATED = "$authenticated"; // authenticated user
|
||||||
Role.EVERYONE = "$everyone"; // everyone
|
Role.EVERYONE = "$everyone"; // everyone
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a given principal is in the role
|
||||||
|
*
|
||||||
|
* @param principalType
|
||||||
|
* @param principalId
|
||||||
|
* @param role
|
||||||
|
* @param callback
|
||||||
|
*/
|
||||||
|
Role.isInRole = function(principalType, principalId, role, callback) {
|
||||||
|
Role.findOne({where: {name: role}}, function(err, role) {
|
||||||
|
if(err) {
|
||||||
|
callback && callback(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
RoleMapping.exists({where: {roleId: role.id, principalType: principalType, principalId: principalId}}, callback);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List roles for a given principal
|
||||||
|
* @param principalType
|
||||||
|
* @param principalId
|
||||||
|
* @param role
|
||||||
|
* @param callback
|
||||||
|
*/
|
||||||
|
Role.getRoles = function(principalType, principalId, role, callback) {
|
||||||
|
RoleMapping.find({where: {principalType: principalType, principalId: principalId}},function(err, mappings) {
|
||||||
|
if(err) {
|
||||||
|
callback && callback(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var roles = [];
|
||||||
|
mappings.forEach(function(m) {
|
||||||
|
roles.push(m.roleId);
|
||||||
|
});
|
||||||
|
callback && callback(null, roles);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
Role: Role,
|
||||||
|
RoleMapping: RoleMapping
|
||||||
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,27 +1,69 @@
|
||||||
var assert = require('assert');
|
var assert = require('assert');
|
||||||
var loopback = require('../index');
|
var loopback = require('../index');
|
||||||
var acl = require('../lib/models/acl');
|
var acl = require('../lib/models/acl');
|
||||||
|
var Scope = acl.Scope;
|
||||||
|
var ACL = acl.ACL;
|
||||||
|
var ScopeACL = acl.ScopeACL;
|
||||||
var User = loopback.User;
|
var User = loopback.User;
|
||||||
|
|
||||||
describe('security scopes', function () {
|
describe('security scopes', function () {
|
||||||
|
|
||||||
it("should allow access to models", function () {
|
it("should allow access to models for the given scope by wildcard", function () {
|
||||||
var ds = loopback.createDataSource({connector: loopback.Memory});
|
var ds = loopback.createDataSource({connector: loopback.Memory});
|
||||||
acl.Scope.attachTo(ds);
|
Scope.attachTo(ds);
|
||||||
acl.ScopeResourceAccess.attachTo(ds);
|
ScopeACL.attachTo(ds);
|
||||||
|
|
||||||
// console.log(acl.Scope.relations);
|
// console.log(Scope.relations);
|
||||||
|
|
||||||
acl.Scope.create({name: 'user', description: 'access user information'}, function (err, scope) {
|
Scope.create({name: 'user', description: 'access user information'}, function (err, scope) {
|
||||||
console.log(scope);
|
// console.log(scope);
|
||||||
scope.resources.create({model: 'user', property: '*', accessType: '*', permission: 'Allow'}, function (err, resource) {
|
scope.resources.create({model: 'user', property: '*', accessType: '*', permission: 'Allow'}, function (err, resource) {
|
||||||
console.log(resource);
|
// console.log(resource);
|
||||||
acl.Scope.isAllowed('user', 'user', '*', '*', console.log);
|
Scope.checkPermission('user', 'user', '*', '*', console.log);
|
||||||
|
Scope.checkPermission('user', 'user', 'name', '*', console.log);
|
||||||
|
Scope.checkPermission('user', 'user', 'name', 'Read', console.log);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should allow access to models for the given scope", function () {
|
||||||
|
var ds = loopback.createDataSource({connector: loopback.Memory});
|
||||||
|
Scope.attachTo(ds);
|
||||||
|
ScopeACL.attachTo(ds);
|
||||||
|
|
||||||
|
// console.log(Scope.relations);
|
||||||
|
|
||||||
|
Scope.create({name: 'user', description: 'access user information'}, function (err, scope) {
|
||||||
|
// console.log(scope);
|
||||||
|
scope.resources.create({model: 'user', property: 'name', accessType: 'Read', permission: 'Allow'}, function (err, resource) {
|
||||||
|
// console.log(resource);
|
||||||
|
Scope.checkPermission('user', 'user', '*', '*', console.log);
|
||||||
|
Scope.checkPermission('user', 'user', 'name', '*', console.log);
|
||||||
|
Scope.checkPermission('user', 'user', 'name', 'Read', console.log);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('security ACLs', function () {
|
||||||
|
|
||||||
|
it("should allow access to models for the given principal by wildcard", function () {
|
||||||
|
var ds = loopback.createDataSource({connector: loopback.Memory});
|
||||||
|
ACL.attachTo(ds);
|
||||||
|
|
||||||
|
// console.log(Scope.relations);
|
||||||
|
|
||||||
|
ACL.create({principalType: 'user', principalId: 'u001', model: 'user', property: '*', accessType: '*', permission: 'Allow'}, function (err, acl) {
|
||||||
|
|
||||||
|
ACL.checkPermission('user', 'u001', 'user', 'u001', 'Read', console.log);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -512,6 +512,39 @@ describe('Model', function() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Model.extend() events', function() {
|
||||||
|
it('create isolated emitters for subclasses', function() {
|
||||||
|
var User1 = loopback.createModel('User1', {
|
||||||
|
'first': String,
|
||||||
|
'last': String
|
||||||
|
});
|
||||||
|
|
||||||
|
var User2 = loopback.createModel('User2', {
|
||||||
|
'name': String
|
||||||
|
});
|
||||||
|
|
||||||
|
var user1Triggered = false;
|
||||||
|
User1.once('x', function(event) {
|
||||||
|
user1Triggered = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
var user2Triggered = false;
|
||||||
|
User2.once('x', function(event) {
|
||||||
|
user2Triggered = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
assert(User1.once !== User2.once);
|
||||||
|
assert(User1.once !== loopback.Model.once);
|
||||||
|
|
||||||
|
User1.emit('x', User1);
|
||||||
|
|
||||||
|
assert(user1Triggered);
|
||||||
|
assert(!user2Triggered);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
// describe('Model.hasAndBelongsToMany()', function() {
|
// describe('Model.hasAndBelongsToMany()', function() {
|
||||||
// it("TODO: implement / document", function(done) {
|
// it("TODO: implement / document", function(done) {
|
||||||
// /* example -
|
// /* example -
|
||||||
|
|
|
@ -1,41 +1,60 @@
|
||||||
var assert = require('assert');
|
var assert = require('assert');
|
||||||
var loopback = require('../index');
|
var loopback = require('../index');
|
||||||
var Role = require('../lib/models/role');
|
var role = require('../lib/models/role');
|
||||||
|
var Role = role.Role;
|
||||||
|
var RoleMapping = role.RoleMapping;
|
||||||
var User = loopback.User;
|
var User = loopback.User;
|
||||||
|
|
||||||
describe('security models', function () {
|
describe('security models', function () {
|
||||||
|
|
||||||
describe('roles', function () {
|
describe('roles', function () {
|
||||||
|
|
||||||
it("Defines role/role relations", function () {
|
it("should define role/role relations", function () {
|
||||||
var ds = loopback.createDataSource({connector: loopback.Memory});
|
var ds = loopback.createDataSource({connector: loopback.Memory});
|
||||||
Role.attachTo(ds);
|
Role.attachTo(ds);
|
||||||
|
RoleMapping.attachTo(ds);
|
||||||
|
|
||||||
Role.create({name: 'user'}, function (err, role) {
|
Role.create({name: 'user'}, function (err, userRole) {
|
||||||
role.roles.create({name: 'admin'}, function (err, role2) {
|
Role.create({name: 'admin'}, function (err, adminRole) {
|
||||||
Role.find(console.log);
|
userRole.principals.create({principalType: 'role', principalId: adminRole.id}, function (err, mapping) {
|
||||||
role.roles(console.log);
|
Role.find(function(err, roles) {
|
||||||
|
assert.equal(roles.length, 2);
|
||||||
|
});
|
||||||
|
RoleMapping.find(function(err, mappings) {
|
||||||
|
assert.equal(mappings.length, 1);
|
||||||
|
});
|
||||||
|
userRole.principals(function(err, principals) {
|
||||||
|
assert.equal(principals.length, 1);
|
||||||
|
});
|
||||||
|
userRole.roles(function(err, roles) {
|
||||||
|
assert.equal(roles.length, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Defines role/user relations", function () {
|
it("should define role/user relations", function () {
|
||||||
var ds = loopback.createDataSource({connector: loopback.Memory});
|
var ds = loopback.createDataSource({connector: loopback.Memory});
|
||||||
User.attachTo(ds);
|
User.attachTo(ds);
|
||||||
Role.attachTo(ds);
|
Role.attachTo(ds);
|
||||||
|
|
||||||
Role.create({name: 'user'}, function (err, role) {
|
User.create({name: 'Raymond', email: 'x@y.com', password: 'foobar'}, function (err, user) {
|
||||||
role.users.create({name: 'Raymond'}, function (err, user) {
|
console.log('User: ', user.id);
|
||||||
console.log('User: ', user);
|
Role.create({name: 'userRole'}, function (err, role) {
|
||||||
|
role.principals.create({principalType: 'user', principalId: user.id}, function (err, p) {
|
||||||
Role.find(console.log);
|
Role.find(console.log);
|
||||||
|
role.principals(console.log);
|
||||||
role.users(console.log);
|
role.users(console.log);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue