initial implementation
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
yarn-error.log
|
||||
Vendored
+63
@@ -0,0 +1,63 @@
|
||||
#!groovy
|
||||
|
||||
pipeline {
|
||||
agent none
|
||||
|
||||
environment {
|
||||
git_commit_message = ''
|
||||
git_commit_diff = ''
|
||||
git_commit_author = ''
|
||||
git_commit_author_name = ''
|
||||
git_commit_author_email = ''
|
||||
ANDROID_HOME = '/opt/android-sdk/'
|
||||
}
|
||||
|
||||
stages {
|
||||
|
||||
stage('Packaging') {
|
||||
agent {
|
||||
label 'master'
|
||||
}
|
||||
steps {
|
||||
sh "echo 'Packaging'"
|
||||
deleteDir()
|
||||
checkout scm
|
||||
sh "git fetch --tags"
|
||||
sh "makechangelog.uxf > debian/changelog"
|
||||
sh "cat debian/changelog"
|
||||
sh "mkdir src"
|
||||
sh "sed -n 1p debian/changelog | grep -oP '\\((.*?)\\)' > src/version.txt"
|
||||
sh "echo '####################################################'"
|
||||
sh "cat src/version.txt"
|
||||
sh "echo '####################################################'"
|
||||
|
||||
sh 'rm config/vnc-notificationproxy.js'
|
||||
sh 'cp -P conf.template/vnc-notificationproxy.js config/vnc-notificationproxy.js'
|
||||
// yarn fails on node4 with kurento stuff
|
||||
sh 'yarn install'
|
||||
lock('debianbuild') {
|
||||
sh "cd debian; debuild --check-dirname-level 0 --no-tgz-check --no-lintian -kjenkins@vnc.biz -p'gpg --no-tty --passphrase q3tx65wurstbrot'; cd .."
|
||||
}
|
||||
sh "mkdir -p ../pkgarchive/"
|
||||
sh "scp ../*.deb repo@factorypackages.rz.vnc.biz:/srv/repo/apt/incoming/"
|
||||
sh "ssh repo@factorypackages.rz.vnc.biz /srv/repo/bin/import-new-packages.sh"
|
||||
sh "mv ../*.deb ../pkgarchive/"
|
||||
sh "rm ../*.dsc"
|
||||
sh "rm ../*amd64.build"
|
||||
sh "rm ../*amd64.changes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
always {
|
||||
emailext (
|
||||
to: 'stefan.saenger@vnc.biz',
|
||||
subject: "${env.JOB_NAME} #${env.BUILD_NUMBER}",
|
||||
body: "Build URL: ${env.BUILD_URL}.",
|
||||
attachLog: false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
var logger = require('morgan');
|
||||
var moment = require('moment');
|
||||
var md5 = require('md5');
|
||||
var jwt = require('jsonwebtoken');
|
||||
var express = require('express');
|
||||
require('express-async-errors');
|
||||
var bodyParser = require('body-parser');
|
||||
var request = require('request');
|
||||
var Pool = require('pg-pool');
|
||||
var LDAP = require('ldapjs');
|
||||
|
||||
var app = express();
|
||||
var env = process.env.NODE_ENV || 'development';
|
||||
var config = require('../config/vnc-hybrid-authenticator.js')[env];
|
||||
console.log(moment().format("LTS") + ' Using configuration', config);
|
||||
|
||||
|
||||
var servicePort = config.servicePort;
|
||||
|
||||
|
||||
var ldap;
|
||||
ldap = LDAP.createClient({
|
||||
url: config.ldap.ldapUri, // string
|
||||
// do not wait longer than 1 minute for ldap connect
|
||||
connectTimeout: 60 * 1000, // Milliseconds client should wait before timing out on TCP connections (Default: OS default)
|
||||
reconnect: {
|
||||
initialDelay: 200,
|
||||
maxDelay: 1000,
|
||||
failAfter: 10
|
||||
}
|
||||
});
|
||||
// these options are applied in search methods
|
||||
var base = config.ldap.searchBase; // default base for all future searches
|
||||
//filter: config.ldap.filter, // default filter for all future searches
|
||||
var scope = 'sub'; // scope: LDAP.SUBTREE
|
||||
|
||||
|
||||
var dbpool = new Pool({
|
||||
database: config.database.name,
|
||||
user: config.database.user,
|
||||
password: config.database.pass,
|
||||
host: config.database.host,
|
||||
port: config.database.port,
|
||||
ssl: true,
|
||||
max: 20, // set pool max size to 20
|
||||
min: 4, // set min pool size to 4
|
||||
idleTimeoutMillis: config.database.idleTimeoutMillis, // close idle clients after 1 second
|
||||
connectionTimeoutMillis: config.database.connectionTimeoutMillis, // return an error after 1 second if connection could not be established
|
||||
})
|
||||
|
||||
|
||||
app.use(bodyParser.json()); // for parsing application/json
|
||||
app.use(logger('dev'));
|
||||
|
||||
function IsValidLDAPUser(username) {
|
||||
return new Promise( function (resolve, reject) {
|
||||
var uid = username.split("@")[0];
|
||||
var domain = username.split("@")[1];
|
||||
var dcstring = "ou=people,dc=";
|
||||
var dcstrings = domain.split(".");
|
||||
if (dcstrings.length > 0) {
|
||||
dcstring += dcstrings.join(",dc=");
|
||||
}
|
||||
var search_options = { scope : 'sub' };
|
||||
var _filter = '(&(objectClass=inetOrgPerson)';
|
||||
if (config.ldap.ldapType === 'zimbra') {
|
||||
_filter += '(!(zimbraIsSystemResource=TRUE))(!(zimbraIsSystemAccount=TRUE))';
|
||||
_filter += '(!(objectClass=zimbraDistributionList))(!(objectClass=zimbraCalendarResource))';
|
||||
_filter += '(&(zimbraAccountStatus=active))(&(zimbraMailStatus=enabled))';
|
||||
_filter += '(|(uid=' + uid + ')(mail=' + username + ')))';
|
||||
search_options.filter = _filter;
|
||||
// search_options.attributes = ['uid', 'mail', 'givenName', 'sn', 'displayName', 'zimbraAccountStatus'];
|
||||
search_options.attributes = ['uid', 'mail', 'zimbraAccountStatus'];
|
||||
} else if (config.ldap.ldapType === 'MS-AD') {
|
||||
_filter += '(|(sAMAccountName=' + uid + ')(mail=' + username + ')))';
|
||||
search_options.filter = _filter;
|
||||
search_options.attributes = ['sAMAccountName', 'mail', 'givenName', 's', 'displayName' ];
|
||||
} else {
|
||||
console.error(moment().format("LTS") + " [profile ldap] unsupported config.ldap.ldapType : '"+config.ldap.ldapType+"'");
|
||||
resolve(false);
|
||||
}
|
||||
console.log(moment().format("LTS") + ' ldap filter: ', _filter);
|
||||
ldap.bind(
|
||||
config.ldap.bindDn,
|
||||
config.ldap.bindPassword,
|
||||
err => {
|
||||
if (err) {
|
||||
console.error(moment().format("LTS") + " [profile ldap.bind]", err);
|
||||
resolve(false);
|
||||
} else {
|
||||
var resultEntries = [];
|
||||
console.log(moment().format("LTS") + ' [profile ldap.search] dcstring:', dcstring);
|
||||
ldap.search(dcstring, search_options, (error, searchResult) => {
|
||||
if (error) {
|
||||
console.error(moment().format("LTS") + " [profile ldap.search error] ", error);
|
||||
resolve(false);
|
||||
}
|
||||
searchResult.on('searchEntry' , e => {
|
||||
var resultEntry = {};
|
||||
for (e2 of e.attributes) {
|
||||
resultEntry[e2.type == "sAMAccountName" ? "uid" : e2.type] = e2.vals;
|
||||
}
|
||||
console.log(moment().format("LTS") + ' [profile ldap.search] entry:', resultEntry);
|
||||
resultEntries.push(resultEntry);
|
||||
});
|
||||
|
||||
searchResult.on('searchReference' , ref => console.log(moment().format("LTS") + ' [profile ldap.search] referral: ' + ref));
|
||||
searchResult.on('error' , err => { console.error(moment().format("LTS") + ' [profile ldap.search] on error: ' + err); resolve(false);})
|
||||
searchResult.on('end' , status =>
|
||||
{
|
||||
console.log('[search-ldap.end] status=' + status.status + " ("+status.errorMessage+")", resultEntries.length+ " entries");
|
||||
if (resultEntries.length === 1) {
|
||||
console.log(moment().format("LTS") + " [valid LDAP user] ", username);
|
||||
resolve(true);
|
||||
} else {
|
||||
console.log(moment().format("LTS") + " [no valid LDAP user] - sending 401");
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function IsValidLDAPAuth(username, password) {
|
||||
return new Promise( function (resolve, reject) {
|
||||
var uid = username.split("@")[0];
|
||||
var domain = username.split("@")[1];
|
||||
var dcstring = "ou=people,dc=";
|
||||
var dcstrings = domain.split(".");
|
||||
if (dcstrings.length > 0) {
|
||||
dcstring += dcstrings.join(",dc=");
|
||||
}
|
||||
var search_options = { scope : 'sub' };
|
||||
var uiddn = "";
|
||||
if (config.ldap.ldapType === 'zimbra') {
|
||||
uiddn = "uid=" + uid + "," + dcstring;
|
||||
} else if (config.ldap.ldapType === 'MS-AD') {
|
||||
uiddn = "sAMAccountName=" + uid + "," + dcstring;
|
||||
} else {
|
||||
console.error(moment().format("LTS") + " [profile ldap] unsupported config.ldap.ldapType : '"+config.ldap.ldapType+"'");
|
||||
resolve(false);
|
||||
}
|
||||
ldap.bind( uiddn, password,
|
||||
err => {
|
||||
if (err) {
|
||||
console.error(moment().format("LTS") + " [user ldap.bind] fail ", err);
|
||||
resolve(false);
|
||||
} else {
|
||||
console.error(moment().format("LTS") + " [user ldap.bind]", err);
|
||||
resolve(true);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function IsValidOldSecret(username, password) {
|
||||
try {
|
||||
var secretTimeseed = password.split("-")[0];
|
||||
var oldHash = password.split("-")[1];
|
||||
var OldYear = secretTimeseed.substring(4,8);
|
||||
var OldDay = secretTimeseed.substring(0,2);
|
||||
var OldMonth = secretTimeseed.substring(2,4);
|
||||
var OldHour = secretTimeseed.substring(8,10);
|
||||
var oldMoment = moment.utc(OldYear + "-" + OldMonth + "-" + OldDay + " " + OldHour).valueOf();
|
||||
var nowMoment = moment.utc().valueOf();
|
||||
if (oldMoment >= nowMoment) {
|
||||
// OldSecretTest is still within valid time Timeseed as moment
|
||||
var uid=username.split("@")[0].toLowerCase();
|
||||
var mangledarray = [];
|
||||
for (i = 0; i < uid.length; i++) {
|
||||
mangledarray.push(uid.charCodeAt(i) + 1);
|
||||
}
|
||||
var mangledusername=mangledarray.join('');
|
||||
|
||||
var testSecret = secretTimeseed + mangledusername + config.xmppToken;
|
||||
var testdigest = md5(testSecret);
|
||||
var testhash = testdigest;
|
||||
while (testhash.length < 32) {
|
||||
testhash = '0' + testhash;
|
||||
}
|
||||
if (oldHash === testhash) {
|
||||
console.log(moment().format("LTS") + " [OldSecretTest] oldSecret valid for ", username);
|
||||
return true;
|
||||
} else {
|
||||
console.log(moment().format("LTS") + " [OldSecretTest] oldSecret invalid for ", username);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
console.log(moment().format("LTS") + " [OldSecretTest] oldSecret expired for ", username);
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(moment().format("LTS") + " [OldSecretTest] error: ", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function IsValidJWT(username,password) {
|
||||
try {
|
||||
console.log("decodePlain: ", jwt.decode(password));
|
||||
var decodedToken = jwt.verify(password, "ocu8saithoYa2teeb2ahTh9fee4is7ai");
|
||||
console.log("decodedJWT: ", decodedToken);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(moment().format("LTS") + " [JWTValidation] error: ", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
app.get('/', async function (req, res) {
|
||||
if (req.headers.authorization && req.headers.authorization.startsWith("Basic")) {
|
||||
try {
|
||||
var b64input = req.headers.authorization.split(" ")[1];
|
||||
var decodedInput = new Buffer(b64input, 'base64').toString('ascii');
|
||||
var username = decodedInput.split(":")[0];
|
||||
var password = decodedInput.split(":")[1];
|
||||
let validLdapUser = await IsValidLDAPUser(username);
|
||||
console.log(moment().format("LTS") + ' username ' + username +' - validInLDAP: ', validLdapUser);
|
||||
console.log(moment().format("LTS") + ' username ' + username +' - password: ', password);
|
||||
console.log(moment().format("LTS") + ' username ' + username +' - indesxforOldToken: ', password.indexOf("-"));
|
||||
if (validLdapUser === true) {
|
||||
let validLdapAuth = await IsValidLDAPAuth(username, password);
|
||||
if (validLdapUser && (validLdapAuth || IsValidOldSecret(username, password) || IsValidJWT(username, password))) {
|
||||
res.status(200).json(null);
|
||||
} else {
|
||||
res.status(401).json(null);
|
||||
}
|
||||
} else {
|
||||
res.status(401).json(null);
|
||||
}
|
||||
// res.status(200).json(null);
|
||||
} catch (e) {
|
||||
console.log(moment().format("LTS") + ' Error: ', e);
|
||||
}
|
||||
} else {
|
||||
console.log(moment().format("LTS") + ' no auth header');
|
||||
res.status(401).json(null);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
|
||||
var server = app.listen(servicePort, function () {
|
||||
var host = server.address().address;
|
||||
var port = server.address().port;
|
||||
console.log(moment().format("LTS") + ' Service endpoint is http://%s:%s/notify', host, port);
|
||||
console.log(moment().format("LTS") + ' config', config);
|
||||
});
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
/etc/vnc-hybridauth2.js
|
||||
@@ -0,0 +1,24 @@
|
||||
module.exports = {
|
||||
development: {
|
||||
servicePort: 9544,
|
||||
ldap: {
|
||||
ldapUri: 'ldap://zimbra.uxf.zimbra-vnc.de:389',
|
||||
bindDn: 'uid=zimbra,cn=admins,cn=zimbra',
|
||||
bindPassword: 'cfQ9f0KNR',
|
||||
searchBase: 'ou=people,dc=uxf,dc=zimbra-vnc,dc=de',
|
||||
filter: '(objectClass=inetOrgPerson)',
|
||||
config: 100,
|
||||
// valid ldapType for now: MS-AD, zimbra
|
||||
ldapType: 'zimbra'
|
||||
},
|
||||
database: {
|
||||
host: "talk.uxf.zimbra-vnc.de",
|
||||
port: 5432,
|
||||
name: "prosody",
|
||||
user: "prosody",
|
||||
pass: "oolehohqueithaipeikahtaeshoozais",
|
||||
idleTimeoutMillis: 10000,
|
||||
connectionTimeoutMillis: 10000
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
server
|
||||
{
|
||||
listen 127.0.0.1:9544;
|
||||
server_name vnctalk-hybrid-authenticator2;
|
||||
client_max_body_size 0;
|
||||
|
||||
access_log /var/log/nginx/vnctalk-hybrid-authenticator2-access.log;
|
||||
error_log /var/log/nginx/vnctalk-hybrid-authenticator2-error.log;
|
||||
|
||||
root /usr/share/vnctalk-hybrid-authenticator2/;
|
||||
index index.html index.htm;
|
||||
|
||||
# Turn on Passenger
|
||||
passenger_enabled on;
|
||||
#passenger_min_instances 5;
|
||||
#passenger_max_requests 3000;
|
||||
# Tell Passenger that your app is a Node.js app
|
||||
passenger_app_type node;
|
||||
passenger_sticky_sessions on;
|
||||
passenger_app_root /usr/share/vnctalk-hybrid-authenticator2;
|
||||
passenger_env_var NODE_ENV development;
|
||||
passenger_startup_file app/app.js;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
module.exports = {
|
||||
development: {
|
||||
servicePort: 9544,
|
||||
xmppToken: 'oothoudaeraighahkabahphisaiwaeko',
|
||||
ldap: {
|
||||
ldapUri: 'ldap://zimbra.uxf.zimbra-vnc.de:389',
|
||||
bindDn: 'uid=zimbra,cn=admins,cn=zimbra',
|
||||
bindPassword: 'cfQ9f0KNR',
|
||||
searchBase: 'ou=people,dc=uxf,dc=zimbra-vnc,dc=de',
|
||||
filter: '(objectClass=inetOrgPerson)',
|
||||
config: 100,
|
||||
// valid ldapType for now: MS-AD, zimbra
|
||||
ldapType: 'zimbra'
|
||||
},
|
||||
database: {
|
||||
host: "talk.uxf.zimbra-vnc.de",
|
||||
port: 5432,
|
||||
name: "prosody",
|
||||
user: "prosody",
|
||||
pass: "oolehohqueithaipeikahtaeshoozais",
|
||||
idleTimeoutMillis: 10000,
|
||||
connectionTimeoutMillis: 10000
|
||||
}
|
||||
},
|
||||
development2: {
|
||||
xmppToken: 'irithep1KeiViemohmui3fo3eiyeitah',
|
||||
servicePort: 9544,
|
||||
ldap: {
|
||||
ldapUri: 'ldap://193.254.187.146:389',
|
||||
bindDn: 'uid=zimbra,cn=admins,cn=zimbra',
|
||||
bindPassword: 'tTBhTIWS',
|
||||
searchBase: 'ou=people,dc=dev2,dc=zimbra-vnc,dc=de',
|
||||
filter: '(objectClass=inetOrgPerson)',
|
||||
config: 100,
|
||||
// valid ldapType for now: MS-AD, zimbra
|
||||
ldapType: 'zimbra'
|
||||
},
|
||||
database: {
|
||||
host: "193.254.187.146",
|
||||
port: 5432,
|
||||
name: "prosody",
|
||||
user: "prosody",
|
||||
pass: "ooX8ieyu9uaGee6aechukoh1beePaiZoo",
|
||||
idleTimeoutMillis: 10000,
|
||||
connectionTimeoutMillis: 10000
|
||||
}
|
||||
},
|
||||
gr13: {
|
||||
servicePort: 9544,
|
||||
xmppToken: 'ocu8saithoYa2teeb2ahTh9fee4is7ai',
|
||||
ldap: {
|
||||
ldapUri: 'ldap://192.168.21.142:389',
|
||||
bindDn: 'uid=zimbra,cn=admins,cn=zimbra',
|
||||
bindPassword: 'jSVNIcRSRR',
|
||||
searchBase: 'ou=people,dc=dev,dc=local,dc=gr13,dc=net',
|
||||
filter: '(objectClass=inetOrgPerson)',
|
||||
config: 100,
|
||||
// valid ldapType for now: MS-AD, zimbra
|
||||
ldapType: 'zimbra'
|
||||
},
|
||||
database : {
|
||||
host: "192.168.21.143",
|
||||
port: 5432,
|
||||
name: "prosody",
|
||||
user: "prosody",
|
||||
pass: "vahG7ooli7thee0iek4Ewoo9Sai6oWah",
|
||||
idleTimeoutMillis: 10000,
|
||||
connectionTimeoutMillis: 10000
|
||||
}
|
||||
},
|
||||
};
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
vnctalk-hybrid-authenticator2 (0.1.1-trusty) unstable; urgency=low
|
||||
|
||||
* initial packaging
|
||||
|
||||
-- Stefan Sänger <stefan.saenger@vnc.biz> Fri, 08 Jul 2016 15:54:24 +0200
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
9
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
Source: vnctalk-hybrid-authenticator2
|
||||
Section: misc
|
||||
Priority: optional
|
||||
Maintainer: Stefan Sänger <stefan.saenger@vnc.biz>
|
||||
Build-Depends: cdbs, debhelper (>= 5), nodejs
|
||||
Standards-Version: 3.8.0
|
||||
Homepage: http://www.vnc.biz
|
||||
|
||||
Package: vnctalk-hybrid-authenticator2
|
||||
Architecture: all
|
||||
Depends: ${shlibs:Depends}, ${misc:Depends}, nginx, passenger
|
||||
Description: VNCtalk Hybrid Authenticator V2
|
||||
Hybrid authentication provider
|
||||
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||
Upstream-Name: vnctalk-hybrid-authenticator2
|
||||
Source: https://gitlab.vnc.biz/uxf/vnctalk-hybrid-authenticator2
|
||||
Copyright: VNC AG
|
||||
License: AGPL-3+
|
||||
|
||||
License: AGPL-3+
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
.
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
.
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
app /usr/share/vnctalk-hybrid-authenticator2/
|
||||
conf.template /usr/share/vnctalk-hybrid-authenticator2/
|
||||
config /usr/share/vnctalk-hybrid-authenticator2/
|
||||
node_modules /usr/share/vnctalk-hybrid-authenticator2/
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/make -f
|
||||
|
||||
# Uncomment this to turn on verbose mode.
|
||||
#export DH_VERBOSE=1
|
||||
|
||||
%:
|
||||
dh $@
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "vnctalk-hybrid-authenticator2",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "app.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"start": "NODE_ENV=development node app/app.js",
|
||||
"start:gr13": "NODE_ENV=gr13 node app/app.js"
|
||||
},
|
||||
"author": "VNC Software AG",
|
||||
"license": "",
|
||||
"dependencies": {
|
||||
"body-parser": "1.18.3",
|
||||
"express": "4.13.4",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"express-basic-auth": "^1.1.6",
|
||||
"hmacsha1": "^1.0.0",
|
||||
"jsonwebtoken": "^8.3.0",
|
||||
"ldapjs": "^1.0.2",
|
||||
"md5": "^2.2.1",
|
||||
"moment": "^2.22.2",
|
||||
"morgan": "1.8.2",
|
||||
"pg": "7.6.0",
|
||||
"pg-pool": "2.0.3",
|
||||
"request": "2.83.0"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user