Merge pull request #2 from dariusk/1-fix-create-object
Fix guids on message object, make IDs dereferencable
This commit is contained in:
commit
f9260b4128
12 changed files with 233 additions and 157 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
package-lock.json
|
package-lock.json
|
||||||
*.db
|
*.db
|
||||||
|
config.json
|
||||||
|
|
|
@ -20,6 +20,10 @@ Clone the repository, then `cd` into its root directory. Install dependencies:
|
||||||
|
|
||||||
`npm i`
|
`npm i`
|
||||||
|
|
||||||
|
Copy `config-template.json` to `config.json`.
|
||||||
|
|
||||||
|
`cp config-template.json config.json`
|
||||||
|
|
||||||
Update your `config.json` file:
|
Update your `config.json` file:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
|
@ -45,7 +49,7 @@ Enter "test" in the "Create Account" section and hit the "Create Account" button
|
||||||
|
|
||||||
## Local testing
|
## Local testing
|
||||||
|
|
||||||
You can use a service like [ngrok](https://ngrok.com/) to test things out before you deploy on a real server. All you need to do is install ngrok and run `ngrok http 3000` (or whatever port you're using if you changed it). Then go to your `config.json` and update the `DOMAIN` field to whatever `abcdef.ngrok.io` domain that ngrok gives you and restart your server.
|
You can use a service like [ngrok](https://ngrok.com/) to test things out before you deploy on a real server. All you need to do is install ngrok and run `ngrok http 3000` (or whatever port you're using if you changed it). Then go to your `config.json` and update the `DOMAIN` field to whatever `abcdef.ngrok.io` domain that ngrok gives you and restart your server. *For local testing you do not need to specify `PRIVKEY_PATH` or `CERT_PARTH`.*
|
||||||
|
|
||||||
## Admin Page
|
## Admin Page
|
||||||
|
|
||||||
|
|
9
index.js
9
index.js
|
@ -2,8 +2,8 @@ const config = require('./config.json');
|
||||||
const { USER, PASS, DOMAIN, PRIVKEY_PATH, CERT_PATH, PORT } = config;
|
const { USER, PASS, DOMAIN, PRIVKEY_PATH, CERT_PATH, PORT } = config;
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const app = express();
|
const app = express();
|
||||||
const sqlite3 = require('sqlite3').verbose();
|
const Database = require('better-sqlite3');
|
||||||
const db = new sqlite3.Database('bot-node.db');
|
const db = new Database('bot-node.db');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const routes = require('./routes'),
|
const routes = require('./routes'),
|
||||||
bodyParser = require('body-parser'),
|
bodyParser = require('body-parser'),
|
||||||
|
@ -27,7 +27,9 @@ try {
|
||||||
}
|
}
|
||||||
|
|
||||||
// if there is no `accounts` table in the DB, create an empty table
|
// if there is no `accounts` table in the DB, create an empty table
|
||||||
db.run('CREATE TABLE IF NOT EXISTS accounts (name TEXT PRIMARY KEY, privkey TEXT, pubkey TEXT, webfinger TEXT, actor TEXT, apikey TEXT, followers TEXT, messages TEXT)');
|
db.prepare('CREATE TABLE IF NOT EXISTS accounts (name TEXT PRIMARY KEY, privkey TEXT, pubkey TEXT, webfinger TEXT, actor TEXT, apikey TEXT, followers TEXT, messages TEXT)').run();
|
||||||
|
// if there is no `messages` table in the DB, create an empty table
|
||||||
|
db.prepare('CREATE TABLE IF NOT EXISTS messages (guid TEXT PRIMARY KEY, message TEXT)').run();
|
||||||
|
|
||||||
app.set('db', db);
|
app.set('db', db);
|
||||||
app.set('domain', DOMAIN);
|
app.set('domain', DOMAIN);
|
||||||
|
@ -65,6 +67,7 @@ app.use('/api/admin', cors({ credentials: true, origin: true }), basicUserAuth,
|
||||||
app.use('/admin', express.static('public/admin'));
|
app.use('/admin', express.static('public/admin'));
|
||||||
app.use('/.well-known/webfinger', cors(), routes.webfinger);
|
app.use('/.well-known/webfinger', cors(), routes.webfinger);
|
||||||
app.use('/u', cors(), routes.user);
|
app.use('/u', cors(), routes.user);
|
||||||
|
app.use('/m', cors(), routes.message);
|
||||||
app.use('/api/inbox', cors(), routes.inbox);
|
app.use('/api/inbox', cors(), routes.inbox);
|
||||||
|
|
||||||
http.createServer(app).listen(app.get('port'), function(){
|
http.createServer(app).listen(app.get('port'), function(){
|
||||||
|
|
|
@ -4,13 +4,13 @@
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^5.4.0",
|
||||||
"body-parser": "^1.18.3",
|
"body-parser": "^1.18.3",
|
||||||
"cors": "^2.8.4",
|
"cors": "^2.8.4",
|
||||||
"express": "^4.16.3",
|
"express": "^4.16.3",
|
||||||
"express-basic-auth": "^1.1.5",
|
"express-basic-auth": "^1.1.5",
|
||||||
"generate-rsa-keypair": "^0.1.2",
|
"generate-rsa-keypair": "^0.1.2",
|
||||||
"request": "^2.87.0",
|
"request": "^2.87.0"
|
||||||
"sqlite3": "^4.0.2"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.10.0"
|
"node": ">=10.10.0"
|
||||||
|
|
|
@ -15,6 +15,7 @@ function createActor(name, domain, pubkey) {
|
||||||
'type': 'Person',
|
'type': 'Person',
|
||||||
'preferredUsername': `${name}`,
|
'preferredUsername': `${name}`,
|
||||||
'inbox': `https://${domain}/api/inbox`,
|
'inbox': `https://${domain}/api/inbox`,
|
||||||
|
'followers': `https://${domain}/u/${name}/followers`,
|
||||||
|
|
||||||
'publicKey': {
|
'publicKey': {
|
||||||
'id': `https://${domain}/u/${name}#main-key`,
|
'id': `https://${domain}/u/${name}#main-key`,
|
||||||
|
@ -51,16 +52,13 @@ router.post('/create', function (req, res) {
|
||||||
let actorRecord = createActor(account, domain, pair.public);
|
let actorRecord = createActor(account, domain, pair.public);
|
||||||
let webfingerRecord = createWebfinger(account, domain);
|
let webfingerRecord = createWebfinger(account, domain);
|
||||||
const apikey = crypto.randomBytes(16).toString('hex');
|
const apikey = crypto.randomBytes(16).toString('hex');
|
||||||
db.run('insert or replace into accounts(name, actor, apikey, pubkey, privkey, webfinger) values($name, $actor, $apikey, $pubkey, $privkey, $webfinger)', {
|
try {
|
||||||
$name: `${account}@${domain}`,
|
db.prepare('insert or replace into accounts(name, actor, apikey, pubkey, privkey, webfinger) values(?, ?, ?, ?, ?, ?)').run(`${account}@${domain}`, JSON.stringify(actorRecord), apikey, pair.public, pair.private, JSON.stringify(webfingerRecord));
|
||||||
$apikey: apikey,
|
|
||||||
$pubkey: pair.public,
|
|
||||||
$privkey: pair.private,
|
|
||||||
$actor: JSON.stringify(actorRecord),
|
|
||||||
$webfinger: JSON.stringify(webfingerRecord)
|
|
||||||
}, (err, accounts) => {
|
|
||||||
res.status(200).json({msg: 'ok', apikey});
|
res.status(200).json({msg: 'ok', apikey});
|
||||||
});
|
}
|
||||||
|
catch(e) {
|
||||||
|
res.status(200).json({error: e});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
@ -11,21 +11,20 @@ router.post('/sendMessage', function (req, res) {
|
||||||
let apikey = req.body.apikey;
|
let apikey = req.body.apikey;
|
||||||
let message = req.body.message;
|
let message = req.body.message;
|
||||||
// check to see if your API key matches
|
// check to see if your API key matches
|
||||||
db.get('select apikey from accounts where name = $name', {$name: `${acct}@${domain}`}, (err, result) => {
|
let result = db.prepare('select apikey from accounts where name = ?').get(`${acct}@${domain}`);
|
||||||
if (result.apikey === apikey) {
|
if (result.apikey === apikey) {
|
||||||
sendCreateMessage(message, acct, domain, req, res);
|
sendCreateMessage(message, acct, domain, req, res);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
res.status(403).json({msg: 'wrong api key'});
|
res.status(403).json({msg: 'wrong api key'});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function signAndSend(message, name, domain, req, res, targetDomain, inbox) {
|
function signAndSend(message, name, domain, req, res, targetDomain, inbox) {
|
||||||
// get the private key
|
// get the private key
|
||||||
let db = req.app.get('db');
|
let db = req.app.get('db');
|
||||||
let inboxFragment = inbox.replace('https://'+targetDomain,'');
|
let inboxFragment = inbox.replace('https://'+targetDomain,'');
|
||||||
db.get('select privkey from accounts where name = $name', {$name: `${name}@${domain}`}, (err, result) => {
|
let result = db.prepare('select privkey from accounts where name = ?').get(`${name}@${domain}`);
|
||||||
if (result === undefined) {
|
if (result === undefined) {
|
||||||
console.log(`No record found for ${name}.`);
|
console.log(`No record found for ${name}.`);
|
||||||
}
|
}
|
||||||
|
@ -59,36 +58,45 @@ function signAndSend(message, name, domain, req, res, targetDomain, inbox) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMessage(text, name, domain) {
|
function createMessage(text, name, domain, req, res, follower) {
|
||||||
const guid = crypto.randomBytes(16).toString('hex');
|
const guidCreate = crypto.randomBytes(16).toString('hex');
|
||||||
|
const guidNote = crypto.randomBytes(16).toString('hex');
|
||||||
|
let db = req.app.get('db');
|
||||||
let d = new Date();
|
let d = new Date();
|
||||||
|
|
||||||
return {
|
let noteMessage = {
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
'id': `https://${domain}/m/${guidNote}`,
|
||||||
|
|
||||||
'id': `https://${domain}/${guid}`,
|
|
||||||
'type': 'Create',
|
|
||||||
'actor': `https://${domain}/u/${name}`,
|
|
||||||
|
|
||||||
'object': {
|
|
||||||
'id': `https://${domain}/${guid}`,
|
|
||||||
'type': 'Note',
|
'type': 'Note',
|
||||||
'published': d.toISOString(),
|
'published': d.toISOString(),
|
||||||
'attributedTo': `https://${domain}/u/${name}`,
|
'attributedTo': `https://${domain}/u/${name}`,
|
||||||
'content': text,
|
'content': text,
|
||||||
'to': 'https://www.w3.org/ns/activitystreams#Public'
|
'to': ['https://www.w3.org/ns/activitystreams#Public'],
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let createMessage = {
|
||||||
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||||
|
|
||||||
|
'id': `https://${domain}/m/${guidCreate}`,
|
||||||
|
'type': 'Create',
|
||||||
|
'actor': `https://${domain}/u/${name}`,
|
||||||
|
'to': ['https://www.w3.org/ns/activitystreams#Public'],
|
||||||
|
'cc': [follower],
|
||||||
|
|
||||||
|
'object': noteMessage
|
||||||
|
};
|
||||||
|
|
||||||
|
db.prepare('insert or replace into messages(guid, message) values(?, ?)').run( guidCreate, JSON.stringify(createMessage));
|
||||||
|
db.prepare('insert or replace into messages(guid, message) values(?, ?)').run( guidNote, JSON.stringify(noteMessage));
|
||||||
|
|
||||||
|
return createMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendCreateMessage(text, name, domain, req, res) {
|
function sendCreateMessage(text, name, domain, req, res) {
|
||||||
let message = createMessage(text, name, domain);
|
|
||||||
let db = req.app.get('db');
|
let db = req.app.get('db');
|
||||||
|
|
||||||
db.get('select followers from accounts where name = $name', {$name: `${name}@${domain}`}, (err, result) => {
|
let result = db.prepare('select followers from accounts where name = ?').get(`${name}@${domain}`);
|
||||||
let followers = JSON.parse(result.followers);
|
let followers = JSON.parse(result.followers);
|
||||||
console.log(followers);
|
console.log(followers);
|
||||||
console.log('type',typeof followers);
|
console.log('type',typeof followers);
|
||||||
|
@ -101,11 +109,11 @@ function sendCreateMessage(text, name, domain, req, res) {
|
||||||
let inbox = follower+'/inbox';
|
let inbox = follower+'/inbox';
|
||||||
let myURL = new URL(follower);
|
let myURL = new URL(follower);
|
||||||
let targetDomain = myURL.hostname;
|
let targetDomain = myURL.hostname;
|
||||||
|
let message = createMessage(text, name, domain, req, res, follower);
|
||||||
signAndSend(message, name, domain, req, res, targetDomain, inbox);
|
signAndSend(message, name, domain, req, res, targetDomain, inbox);
|
||||||
}
|
}
|
||||||
res.status(200).json({msg: 'ok'});
|
res.status(200).json({msg: 'ok'});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
@ -5,9 +5,12 @@ const express = require('express'),
|
||||||
router = express.Router();
|
router = express.Router();
|
||||||
|
|
||||||
function signAndSend(message, name, domain, req, res, targetDomain) {
|
function signAndSend(message, name, domain, req, res, targetDomain) {
|
||||||
|
// get the URI of the actor object and append 'inbox' to it
|
||||||
|
let inbox = message.object.actor+'/inbox';
|
||||||
|
let inboxFragment = inbox.replace('https://'+targetDomain,'');
|
||||||
// get the private key
|
// get the private key
|
||||||
let db = req.app.get('db');
|
let db = req.app.get('db');
|
||||||
db.get('select privkey from accounts where name = $name', {$name: `${name}@${domain}`}, (err, result) => {
|
let result = db.prepare('select privkey from accounts where name = ?').get(`${name}@${domain}`);
|
||||||
if (result === undefined) {
|
if (result === undefined) {
|
||||||
return res.status(404).send(`No record found for ${name}.`);
|
return res.status(404).send(`No record found for ${name}.`);
|
||||||
}
|
}
|
||||||
|
@ -15,14 +18,14 @@ function signAndSend(message, name, domain, req, res, targetDomain) {
|
||||||
let privkey = result.privkey;
|
let privkey = result.privkey;
|
||||||
const signer = crypto.createSign('sha256');
|
const signer = crypto.createSign('sha256');
|
||||||
let d = new Date();
|
let d = new Date();
|
||||||
let stringToSign = `(request-target): post /inbox\nhost: ${targetDomain}\ndate: ${d.toUTCString()}`;
|
let stringToSign = `(request-target): post ${inboxFragment}\nhost: ${targetDomain}\ndate: ${d.toUTCString()}`;
|
||||||
signer.update(stringToSign);
|
signer.update(stringToSign);
|
||||||
signer.end();
|
signer.end();
|
||||||
const signature = signer.sign(privkey);
|
const signature = signer.sign(privkey);
|
||||||
const signature_b64 = signature.toString('base64');
|
const signature_b64 = signature.toString('base64');
|
||||||
let header = `keyId="https://${domain}/u/${name}",headers="(request-target) host date",signature="${signature_b64}"`;
|
let header = `keyId="https://${domain}/u/${name}",headers="(request-target) host date",signature="${signature_b64}"`;
|
||||||
request({
|
request({
|
||||||
url: `https://${targetDomain}/inbox`,
|
url: inbox,
|
||||||
headers: {
|
headers: {
|
||||||
'Host': targetDomain,
|
'Host': targetDomain,
|
||||||
'Date': d.toUTCString(),
|
'Date': d.toUTCString(),
|
||||||
|
@ -41,7 +44,6 @@ function signAndSend(message, name, domain, req, res, targetDomain) {
|
||||||
});
|
});
|
||||||
return res.status(200);
|
return res.status(200);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendAcceptMessage(thebody, name, domain, req, res, targetDomain) {
|
function sendAcceptMessage(thebody, name, domain, req, res, targetDomain) {
|
||||||
|
@ -76,7 +78,7 @@ router.post('/', function (req, res) {
|
||||||
// Add the user to the DB of accounts that follow the account
|
// Add the user to the DB of accounts that follow the account
|
||||||
let db = req.app.get('db');
|
let db = req.app.get('db');
|
||||||
// get the followers JSON for the user
|
// get the followers JSON for the user
|
||||||
db.get('select followers from accounts where name = $name', {$name: `${name}@${domain}`}, (err, result) => {
|
let result = db.prepare('select followers from accounts where name = ?').get(`${name}@${domain}`);
|
||||||
if (result === undefined) {
|
if (result === undefined) {
|
||||||
console.log(`No record found for ${name}.`);
|
console.log(`No record found for ${name}.`);
|
||||||
}
|
}
|
||||||
|
@ -92,12 +94,15 @@ router.post('/', function (req, res) {
|
||||||
followers = [req.body.actor];
|
followers = [req.body.actor];
|
||||||
}
|
}
|
||||||
let followersText = JSON.stringify(followers);
|
let followersText = JSON.stringify(followers);
|
||||||
|
try {
|
||||||
// update into DB
|
// update into DB
|
||||||
db.run('update accounts set followers=$followers where name = $name', {$name: `${name}@${domain}`, $followers: followersText}, (err, result) => {
|
let newFollowers = db.prepare('update accounts set followers=? where name = ?').run(followersText, `${name}@${domain}`);
|
||||||
console.log('updated followers!', err, result);
|
console.log('updated followers!', newFollowers);
|
||||||
});
|
}
|
||||||
|
catch(e) {
|
||||||
|
console.log('error', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ module.exports = {
|
||||||
api: require('./api'),
|
api: require('./api'),
|
||||||
admin: require('./admin'),
|
admin: require('./admin'),
|
||||||
user: require('./user'),
|
user: require('./user'),
|
||||||
|
message: require('./message'),
|
||||||
inbox: require('./inbox'),
|
inbox: require('./inbox'),
|
||||||
webfinger: require('./webfinger'),
|
webfinger: require('./webfinger'),
|
||||||
};
|
};
|
||||||
|
|
22
routes/message.js
Normal file
22
routes/message.js
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
'use strict';
|
||||||
|
const express = require('express'),
|
||||||
|
router = express.Router();
|
||||||
|
|
||||||
|
router.get('/:guid', function (req, res) {
|
||||||
|
let guid = req.params.guid;
|
||||||
|
if (!guid) {
|
||||||
|
return res.status(400).send('Bad request.');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
let db = req.app.get('db');
|
||||||
|
let result = db.prepare('select message from messages where guid = ?').get(guid);
|
||||||
|
if (result === undefined) {
|
||||||
|
return res.status(404).send(`No record found for ${guid}.`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
res.json(JSON.parse(result.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
|
@ -10,15 +10,50 @@ router.get('/:name', function (req, res) {
|
||||||
else {
|
else {
|
||||||
let db = req.app.get('db');
|
let db = req.app.get('db');
|
||||||
let domain = req.app.get('domain');
|
let domain = req.app.get('domain');
|
||||||
|
let username = name;
|
||||||
name = `${name}@${domain}`;
|
name = `${name}@${domain}`;
|
||||||
db.get('select actor from accounts where name = $name', {$name: name}, (err, result) => {
|
let result = db.prepare('select actor from accounts where name = ?').get(name);
|
||||||
if (result === undefined) {
|
if (result === undefined) {
|
||||||
return res.status(404).send(`No record found for ${name}.`);
|
return res.status(404).send(`No record found for ${name}.`);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
res.json(JSON.parse(result.actor));
|
let tempActor = JSON.parse(result.actor);
|
||||||
|
// Added this followers URI for Pleroma compatibility, see https://github.com/dariusk/rss-to-activitypub/issues/11#issuecomment-471390881
|
||||||
|
// New Actors should have this followers URI but in case of migration from an old version this will add it in on the fly
|
||||||
|
if (tempActor.followers === undefined) {
|
||||||
|
tempActor.followers = `https://${domain}/u/${username}/followers`;
|
||||||
}
|
}
|
||||||
});
|
res.json(tempActor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:name/followers', function (req, res) {
|
||||||
|
let name = req.params.name;
|
||||||
|
if (!name) {
|
||||||
|
return res.status(400).send('Bad request.');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
let db = req.app.get('db');
|
||||||
|
let domain = req.app.get('domain');
|
||||||
|
let result = db.prepare('select followers from accounts where name = ?').get(`${name}@${domain}`);
|
||||||
|
console.log(result);
|
||||||
|
result.followers = result.followers || '[]';
|
||||||
|
let followers = JSON.parse(result.followers);
|
||||||
|
let followersCollection = {
|
||||||
|
"type":"OrderedCollection",
|
||||||
|
"totalItems":followers.length,
|
||||||
|
"id":`https://${domain}/u/${name}/followers`,
|
||||||
|
"first": {
|
||||||
|
"type":"OrderedCollectionPage",
|
||||||
|
"totalItems":followers.length,
|
||||||
|
"partOf":`https://${domain}/u/${name}/followers`,
|
||||||
|
"orderedItems": followers,
|
||||||
|
"id":`https://${domain}/u/${name}/followers?page=1`
|
||||||
|
},
|
||||||
|
"@context":["https://www.w3.org/ns/activitystreams"]
|
||||||
|
};
|
||||||
|
res.json(followersCollection);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -10,14 +10,13 @@ router.get('/', function (req, res) {
|
||||||
else {
|
else {
|
||||||
let name = resource.replace('acct:','');
|
let name = resource.replace('acct:','');
|
||||||
let db = req.app.get('db');
|
let db = req.app.get('db');
|
||||||
db.get('select webfinger from accounts where name = $name', {$name: name}, (err, result) => {
|
let result = db.prepare('select webfinger from accounts where name = ?').get(name);
|
||||||
if (result === undefined) {
|
if (result === undefined) {
|
||||||
return res.status(404).send(`No record found for ${name}.`);
|
return res.status(404).send(`No record found for ${name}.`);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
res.json(JSON.parse(result.webfinger));
|
res.json(JSON.parse(result.webfinger));
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue