merge of features from hosted branch (#7)

major merge of features from the hosted branch that was created temporarily during the initial launch of jambonz.org
This commit is contained in:
Dave Horton
2021-06-17 15:56:21 -04:00
committed by GitHub
parent ab7c69c0e8
commit ed51d8b13f
105 changed files with 10330 additions and 1601 deletions
+84 -37
View File
@@ -1,56 +1,103 @@
const Strategy = require('passport-http-bearer').Strategy;
const {getMysqlConnection} = require('../db');
const {hashString} = require('../utils/password-utils');
const debug = require('debug')('jambonz:api-server');
const jwt = require('jsonwebtoken');
const sql = `
SELECT *
FROM api_keys
WHERE api_keys.token = ?`;
function makeStrategy(logger) {
function makeStrategy(logger, retrieveKey) {
return new Strategy(
function(token, done) {
logger.info(`validating with token ${token}`);
getMysqlConnection((err, conn) => {
async function(token, done) {
logger.debug(`validating with token ${token}`);
jwt.verify(token, process.env.JWT_SECRET, async(err, decoded) => {
if (err) {
logger.error(err, 'Error retrieving mysql connection');
return done(err);
}
conn.query(sql, [token], (err, results, fields) => {
conn.release();
if (err) {
logger.error(err, 'Error querying for api key');
return done(err);
}
if (0 == results.length) return done(null, false);
if (results.length > 1) {
logger.info(`api key ${token} exists in multiple rows of api_keys table!!`);
if (err.name === 'TokenExpiredError') {
logger.debug('jwt expired');
return done(null, false);
}
// found api key
const scope = [];
if (results[0].account_sid === null && results[0].service_provider_sid === null) {
scope.push.apply(scope, ['admin', 'service_provider', 'account']);
/* its not a jwt obtained through login, check api leys */
checkApiTokens(logger, token, done);
}
else {
/* validated -- make sure it is not on blacklist */
try {
const s = `jwt:${hashString(token)}`;
const result = await retrieveKey(s);
if (result) {
debug(`result from searching for ${s}: ${result}`);
logger.info('jwt invalidated after logout');
return done(null, false);
}
} catch (err) {
debug(err);
logger.info({err}, 'Error checking blacklist for jwt');
}
else if (results[0].service_provider_sid) {
scope.push.apply(scope, ['service_provider', 'account']);
}
else {
scope.push('account');
}
const {user_sid, account_sid, email, name} = decoded;
//logger.debug({user_sid, account_sid}, 'successfully validated jwt');
const scope = ['account'];
const user = {
account_sid: results[0].account_sid,
service_provider_sid: results[0].service_provider_sid,
hasScope: (s) => scope.includes(s),
hasAdminAuth: scope.length === 3,
hasServiceProviderAuth: scope.includes('service_provider') && !scope.includes('admin'),
hasAccountAuth: scope.includes('account') && !scope.includes('service_provider')
account_sid,
user_sid,
jwt: token,
email,
name,
hasScope: (s) => s === 'account',
hasAdminAuth: false,
hasServiceProviderAuth: false,
hasAccountAuth: true
};
logger.info(user, `successfully validated with scope ${scope}`);
return done(null, user, {scope});
});
}
});
});
}
);
}
const checkApiTokens = (logger, token, done) => {
getMysqlConnection((err, conn) => {
if (err) {
logger.error(err, 'Error retrieving mysql connection');
return done(err);
}
conn.query(sql, [token], (err, results, fields) => {
conn.release();
if (err) {
logger.error(err, 'Error querying for api key');
return done(err);
}
if (0 == results.length) return done(null, false);
if (results.length > 1) {
logger.info(`api key ${token} exists in multiple rows of api_keys table!!`);
return done(null, false);
}
// found api key
const scope = [];
if (results[0].account_sid === null && results[0].service_provider_sid === null) {
scope.push.apply(scope, ['admin', 'service_provider', 'account']);
}
else if (results[0].service_provider_sid) {
scope.push.apply(scope, ['service_provider', 'account']);
}
else {
scope.push('account');
}
const user = {
account_sid: results[0].account_sid,
service_provider_sid: results[0].service_provider_sid,
hasScope: (s) => scope.includes(s),
hasAdminAuth: scope.length === 3,
hasServiceProviderAuth: scope.includes('service_provider'),
hasAccountAuth: scope.includes('account') && !scope.includes('service_provider')
};
logger.info(user, `successfully validated with scope ${scope}`);
return done(null, user, {scope});
});
});
};
module.exports = makeStrategy;
+3 -1
View File
@@ -1,5 +1,7 @@
const getMysqlConnection = require('./mysql');
const promisePool = require('./pool');
module.exports = {
getMysqlConnection
getMysqlConnection,
promisePool
};
+2 -1
View File
@@ -1,8 +1,8 @@
const mysql = require('mysql2');
const pool = mysql.createPool({
host: process.env.JAMBONES_MYSQL_HOST,
user: process.env.JAMBONES_MYSQL_USER,
port: process.env.JAMBONES_MYSQL_PORT || 3306,
user: process.env.JAMBONES_MYSQL_USER,
password: process.env.JAMBONES_MYSQL_PASSWORD,
database: process.env.JAMBONES_MYSQL_DATABASE,
connectionLimit: process.env.JAMBONES_MYSQL_CONNECTION_LIMIT || 10
@@ -13,6 +13,7 @@ pool.getConnection((err, conn) => {
conn.ping((err) => {
if (err) return console.error(err, `Error pinging mysql at ${JSON.stringify({
host: process.env.JAMBONES_MYSQL_HOST,
port: process.env.JAMBONES_MYSQL_PORT || 3306,
user: process.env.JAMBONES_MYSQL_USER,
password: process.env.JAMBONES_MYSQL_PASSWORD,
database: process.env.JAMBONES_MYSQL_DATABASE,
+10
View File
@@ -0,0 +1,10 @@
const mysql = require('mysql2');
const pool = mysql.createPool({
host: process.env.JAMBONES_MYSQL_HOST,
port: process.env.JAMBONES_MYSQL_PORT || 3306,
user: process.env.JAMBONES_MYSQL_USER,
password: process.env.JAMBONES_MYSQL_PASSWORD,
database: process.env.JAMBONES_MYSQL_DATABASE,
connectionLimit: process.env.JAMBONES_MYSQL_CONNECTION_LIMIT || 10
});
module.exports = pool.promise();
+176
View File
@@ -1,10 +1,57 @@
const debug = require('debug')('jambonz:api-server');
const Model = require('./model');
const {getMysqlConnection} = require('../db');
const {promisePool} = require('../db');
const uuid = require('uuid').v4;
const {encrypt} = require('../utils/encrypt-decrypt');
const retrieveSql = `SELECT * from accounts acc
LEFT JOIN webhooks AS rh
ON acc.registration_hook_sid = rh.webhook_sid`;
const insertPendingAccountSubscriptionSql = `INSERT account_subscriptions
(account_subscription_sid, account_sid, pending, stripe_subscription_id,
stripe_payment_method_id, last4, exp_month, exp_year, card_type)
VALUES (?,?,1,?,?,?,?,?,?)`;
const activateSubscriptionSql = `UPDATE account_subscriptions
SET pending=0, effective_start_date = CURRENT_TIMESTAMP, stripe_subscription_id = ?
WHERE account_subscription_sid = ?
AND pending=1`;
const queryPendingSubscriptionSql = `SELECT * FROM account_subscriptions
WHERE account_sid = ?
AND effective_end_date IS NULL
AND pending=1`;
const deactivateSubscriptionSql = `UPDATE account_subscriptions
SET pending=1, pending_reason = ?
WHERE account_sid = ?
AND effective_end_date IS NULL
AND pending=0`;
const updatePaymentInfoSql = `UPDATE account_subscriptions
SET last4 = ?, exp_month = ?, exp_year = ?, card_type = ?
WHERE account_sid = ?
AND effective_end_date IS NULL`;
const insertAccountProductsSql = `INSERT account_products
(account_product_sid, account_subscription_sid, product_sid, quantity)
VALUES (?,?,?,?);
`;
const replaceOldSubscriptionSql = `UPDATE account_subscriptions
SET effective_end_date = CURRENT_TIMESTAMP, change_reason = ?
WHERE account_sid = ?
AND effective_end_date IS NULL
AND account_subscription_sid <> ?`;
const retrieveActiveSubscriptionSql = `SELECT *
FROM account_subscriptions
WHERE account_sid = ?
AND effective_end_date IS NULL
AND pending = 0`;
function transmogrifyResults(results) {
return results.map((row) => {
const obj = row.acc;
@@ -73,6 +120,111 @@ class Account extends Model {
});
}
static async updateStripeCustomerId(sid, customerId) {
await promisePool.execute(
'UPDATE accounts SET stripe_customer_id = ? WHERE account_sid = ?',
[customerId, sid]);
}
static async getSubscription(sid) {
const [r] = await promisePool.execute(retrieveActiveSubscriptionSql, [sid]);
debug(r, `Account.getSubscription ${sid}`);
return r.length > 0 ? r[0] : null;
}
static async deactivateSubscription(logger, account_sid, reason) {
logger.debug('deactivateSubscription');
/**
* Two cases:
* (1) A subscription renewal fails. In this case we deactivate subscription
* and the customer is down until they provide payment.
* (2) A customer adds capacity during the month, and the pro-rated amount fails.
* In this case, we leave the new subscription in a pending state
* The customer continues (for the rest of the month at least) at
* previous capacity levels.
*/
const [r] = await promisePool.query(queryPendingSubscriptionSql, account_sid);
if (r.length > 0) {
/* leave new subscription pending */
await promisePool.execute(
'UPDATE account_subscriptions set pending_reason = ? WHERE account_subscription_sid = ?',
[reason, r[0].account_subscription_sid]);
logger.debug('deactivateSubscription - leave pending subscription in pending state');
}
else {
/* deactivate their current active subscription */
const [r] = await promisePool.execute(deactivateSubscriptionSql, [reason, account_sid]);
logger.debug('deactivateSubscription - deactivated subscription; customer will not have service');
return 1 == r.affectedRows;
}
}
static async activateSubscription(logger, account_sid, subscription_id, reason) {
logger.debug('activateSubscription');
const [r] = await promisePool.query(queryPendingSubscriptionSql, account_sid);
if (0 === r.length) return false;
const [r2] = await promisePool.execute(activateSubscriptionSql,
[subscription_id, r[0].account_subscription_sid]);
if (0 === r2.affectedRows) return false;
/* disable the old subscription, if any */
const [r3] = await promisePool.execute(replaceOldSubscriptionSql, [
reason, account_sid, r[0].account_subscription_sid]);
debug(r3, 'Account.activateSubscription - replaced old subscription');
/* update account.plan to paid, if it isnt already */
await promisePool.execute(
'UPDATE accounts SET plan_type = \'paid\' WHERE account_sid = ?',
[account_sid]);
return true;
}
static async updatePaymentInfo(logger, account_sid, pm) {
const {card} = pm;
const last4_encrypted = encrypt(card.last4);
await promisePool.execute(updatePaymentInfoSql,
[last4_encrypted, card.exp_month, card.exp_year, card.brand, account_sid]);
}
static async provisionPendingSubscription(logger, account_sid, products, payment_method, subscription_id) {
logger.debug('provisionPendingSubscription');
const account_subscription_sid = uuid();
const {id, card} = payment_method;
/* add a row to account_subscription */
let last4_encrypted = null;
if (card) {
last4_encrypted = encrypt(card.last4);
}
const [r] = await promisePool.execute(insertPendingAccountSubscriptionSql, [
account_subscription_sid,
account_sid,
subscription_id || null,
id,
last4_encrypted,
card ? card.exp_month : null,
card ? card.exp_year : null,
card ? card.brand : null
]);
debug(r, 'Account.activateSubscription - insert account_subscriptions');
if (r.affectedRows !== 1) {
throw new Error(`failed inserting account_subscriptions for accunt_sid ${account_sid}`);
}
/* add a row for each product to account_products */
await Promise.all(products.map((product) => {
const {product_sid, quantity} = product;
const account_products_sid = uuid();
return promisePool.execute(insertAccountProductsSql, [
account_products_sid, account_subscription_sid, product_sid, quantity
]);
}));
return account_subscription_sid;
}
}
Account.table = 'accounts';
@@ -103,6 +255,30 @@ Account.fields = [
{
name: 'device_calling_application_sid',
type: 'string',
},
{
name: 'is_active',
type: 'number',
},
{
name: 'created_at',
type: 'date',
},
{
name: 'plan_type',
type: 'string',
},
{
name: 'stripe_customer_id',
type: 'string',
},
{
name: 'webhook_secret',
type: 'string',
},
{
name: 'disable_cdrs',
type: 'number',
}
];
+37 -2
View File
@@ -1,9 +1,45 @@
const Model = require('./model');
const {getMysqlConnection} = require('../db');
const sql = 'SELECT * from phone_numbers WHERE account_sid = ?';
class PhoneNumber extends Model {
constructor() {
super();
}
static retrieveAll(account_sid) {
if (!account_sid) return super.retrieveAll();
return new Promise((resolve, reject) => {
getMysqlConnection((err, conn) => {
if (err) return reject(err);
conn.query(sql, account_sid, (err, results, fields) => {
conn.release();
if (err) return reject(err);
resolve(results);
});
});
});
}
/**
* retrieve an application
*/
static retrieve(sid, account_sid) {
if (!account_sid) return super.retrieve(sid);
return new Promise((resolve, reject) => {
getMysqlConnection((err, conn) => {
if (err) return reject(err);
conn.query(`${sql} AND phone_number_sid = ?`, [account_sid, sid], (err, results, fields) => {
conn.release();
if (err) return reject(err);
resolve(results);
});
});
});
}
}
PhoneNumber.table = 'phone_numbers';
@@ -20,8 +56,7 @@ PhoneNumber.fields = [
},
{
name: 'voip_carrier_sid',
type: 'string',
required: true
type: 'string'
},
{
name: 'account_sid',
+63
View File
@@ -0,0 +1,63 @@
const Model = require('./model');
class PredefinedCarrier extends Model {
constructor() {
super();
}
}
PredefinedCarrier.table = 'predefined_carriers';
PredefinedCarrier.fields = [
{
name: 'predefined_carrier_sid',
type: 'string',
primaryKey: true
},
{
name: 'name',
type: 'string',
required: true
},
{
name: 'requires_static_ip',
type: 'number'
},
{
name: 'e164_leading_plus',
type: 'number'
},
{
name: 'requires_register',
type: 'number'
},
{
name: 'register_username',
type: 'string'
},
{
name: 'register_sip_realm',
type: 'string'
},
{
name: 'register_password',
type: 'string'
},
{
name: 'tech_prefix',
type: 'string'
},
{
name: 'inbound_auth_username',
type: 'string'
},
{
name: 'inbound_auth_password',
type: 'string'
},
{
name: 'diversion',
type: 'string'
},
];
module.exports = PredefinedCarrier;
+28
View File
@@ -0,0 +1,28 @@
const Model = require('./model');
class Product extends Model {
constructor() {
super();
}
}
Product.table = 'products';
Product.fields = [
{
name: 'product_sid',
type: 'string',
primaryKey: true
},
{
name: 'name',
type: 'string',
required: true
},
{
name: 'category',
type: 'string',
required: true
},
];
module.exports = Product;
+13
View File
@@ -1,9 +1,18 @@
const Model = require('./model');
const {promisePool} = require('../db');
const retrieveSql = 'SELECT * from sip_gateways WHERE voip_carrier_sid = ?';
class SipGateway extends Model {
constructor() {
super();
}
/**
* list all sip gateways for a voip_carrier
*/
static async retrieveForVoipCarrier(voip_carrier_sid) {
const [rows] = await promisePool.query(retrieveSql, voip_carrier_sid);
return rows;
}
}
SipGateway.table = 'sip_gateways';
@@ -26,6 +35,10 @@ SipGateway.fields = [
name: 'port',
type: 'number'
},
{
name: 'netmask',
type: 'number'
},
{
name: 'inbound',
type: 'number'
+55
View File
@@ -0,0 +1,55 @@
const Model = require('./model');
const {getMysqlConnection} = require('../db');
class Smpp extends Model {
constructor() {
super();
}
/**
* list all SBCs either for a given service provider, or those not associated with a
* service provider (i.e. community SBCs)
*/
static retrieveAll(service_provider_sid) {
const sql = service_provider_sid ?
'SELECT * from smpp_addresses WHERE service_provider_sid = ?' :
'SELECT * from smpp_addresses WHERE service_provider_sid IS NULL';
const args = service_provider_sid ? [service_provider_sid] : [];
return new Promise((resolve, reject) => {
getMysqlConnection((err, conn) => {
if (err) return reject(err);
conn.query(sql, args, (err, results) => {
conn.release();
if (err) return reject(err);
resolve(results);
});
});
});
}
}
Smpp.table = 'smpp_addresses';
Smpp.fields = [
{
name: 'smpp_address_sid',
type: 'string',
primaryKey: true
},
{
name: 'ipv4',
type: 'string',
required: true
},
{
name: 'port',
type: 'number'
},
{
name: 'service_provider_sid',
type: 'string'
}
];
module.exports = Smpp;
+60
View File
@@ -0,0 +1,60 @@
const Model = require('./model');
const {promisePool} = require('../db');
const retrieveSql = 'SELECT * from smpp_gateways WHERE voip_carrier_sid = ?';
class SmppGateway extends Model {
constructor() {
super();
}
/**
* list all sip gateways for a voip_carrier
*/
static async retrieveForVoipCarrier(voip_carrier_sid) {
const [rows] = await promisePool.query(retrieveSql, voip_carrier_sid);
return rows;
}
}
SmppGateway.table = 'smpp_gateways';
SmppGateway.fields = [
{
name: 'smpp_gateway_sid',
type: 'string',
primaryKey: true
},
{
name: 'voip_carrier_sid',
type: 'string'
},
{
name: 'ipv4',
type: 'string',
required: true
},
{
name: 'port',
type: 'number'
},
{
name: 'netmask',
type: 'number'
},
{
name: 'inbound',
type: 'number'
},
{
name: 'outbound',
type: 'number'
},
{
name: 'is_primary',
type: 'number'
},
{
name: 'use_tls',
type: 'number'
}
];
module.exports = SmppGateway;
+92
View File
@@ -0,0 +1,92 @@
const Model = require('./model');
const {promisePool} = require('../db');
const retrieveSql = 'SELECT * from speech_credentials WHERE account_sid = ?';
const retrieveSqlForSP = 'SELECT * from speech_credentials WHERE service_provider_sid = ?';
class SpeechCredential extends Model {
constructor() {
super();
}
/**
* list all credentials for an account
*/
static async retrieveAll(account_sid) {
const [rows] = await promisePool.query(retrieveSql, account_sid);
return rows;
}
static async retrieveAllForSP(service_provider_sid) {
const [rows] = await promisePool.query(retrieveSqlForSP, service_provider_sid);
return rows;
}
static async disableStt(account_sid) {
await promisePool.execute('UPDATE speech_credentials SET use_for_stt = 0 WHERE account_sid = ?', [account_sid]);
}
static async disableTts(account_sid) {
await promisePool.execute('UPDATE speech_credentials SET use_for_tts = 0 WHERE account_sid = ?', [account_sid]);
}
static async ttsTestResult(sid, success) {
await promisePool.execute(
'UPDATE speech_credentials SET last_tested = NOW(), tts_tested_ok = ? WHERE speech_credential_sid = ?',
[success, sid]);
}
static async sttTestResult(sid, success) {
await promisePool.execute(
'UPDATE speech_credentials SET last_tested = NOW(), stt_tested_ok = ? WHERE speech_credential_sid = ?',
[success, sid]);
}
}
SpeechCredential.table = 'speech_credentials';
SpeechCredential.fields = [
{
name: 'speech_credential_sid',
type: 'string',
primaryKey: true
},
{
name: 'account_sid',
type: 'string',
},
{
name: 'service_provider_sid',
type: 'string',
},
{
name: 'vendor',
type: 'string',
required: true,
},
{
name: 'credential',
type: 'string',
},
{
name: 'use_for_tts',
type: 'number'
},
{
name: 'use_for_stt',
type: 'number'
},
{
name: 'tts_tested_ok',
type: 'number'
},
{
name: 'stt_tested_ok',
type: 'number'
},
{
name: 'last_used',
type: 'date'
},
{
name: 'last_tested',
type: 'date'
}
];
module.exports = SpeechCredential;
+62 -1
View File
@@ -1,9 +1,22 @@
const Model = require('./model');
const {promisePool} = require('../db');
const retrieveSql = 'SELECT * from voip_carriers vc WHERE vc.account_sid = ?';
const retrieveSqlForSP = 'SELECT * from voip_carriers vc WHERE vc.service_provider_sid = ?';
class VoipCarrier extends Model {
constructor() {
super();
}
static async retrieveAll(account_sid) {
if (!account_sid) return super.retrieveAll();
const [rows] = await promisePool.query(retrieveSql, account_sid);
return rows;
}
static async retrieveAllForSP(service_provider_sid) {
const [rows] = await promisePool.query(retrieveSqlForSP, service_provider_sid);
return rows;
}
}
VoipCarrier.table = 'voip_carriers';
@@ -26,6 +39,10 @@ VoipCarrier.fields = [
name: 'account_sid',
type: 'string',
},
{
name: 'service_provider_sid',
type: 'string',
},
{
name: 'application_sid',
type: 'string'
@@ -49,7 +66,51 @@ VoipCarrier.fields = [
{
name: 'register_password',
type: 'string'
}
},
{
name: 'tech_prefix',
type: 'string'
},
{
name: 'inbound_auth_username',
type: 'string'
},
{
name: 'inbound_auth_password',
type: 'string'
},
{
name: 'diversion',
type: 'string'
},
{
name: 'is_active',
type: 'number'
},
{
name: 'smpp_system_id',
type: 'string'
},
{
name: 'smpp_password',
type: 'string'
},
{
name: 'smpp_inbound_system_id',
type: 'string'
},
{
name: 'smpp_inbound_password',
type: 'string'
},
{
name: 'smpp_enquire_link_interval',
type: 'number'
},
{
name: 'smpp_system_id',
type: 'string'
},
];
module.exports = VoipCarrier;
+57
View File
@@ -0,0 +1,57 @@
const router = require('express').Router();
const {promisePool} = require('../../db');
const sysError = require('../error');
const retrieveApplicationsSql = `SELECT * from applications app
LEFT JOIN webhooks AS ch
ON app.call_hook_sid = ch.webhook_sid
LEFT JOIN webhooks AS sh
ON app.call_status_hook_sid = sh.webhook_sid
LEFT JOIN webhooks AS mh
ON app.messaging_hook_sid = mh.webhook_sid
WHERE service_provider_sid = ?`;
const transmogrifyResults = (results) => {
return results.map((row) => {
const obj = row.app;
if (row.ch && Object.keys(row.ch).length && row.ch.url !== null) {
Object.assign(obj, {call_hook: row.ch});
}
else obj.call_hook = null;
if (row.sh && Object.keys(row.sh).length && row.sh.url !== null) {
Object.assign(obj, {call_status_hook: row.sh});
}
else obj.call_status_hook = null;
if (row.mh && Object.keys(row.mh).length && row.mh.url !== null) {
Object.assign(obj, {messaging_hook: row.mh});
}
else obj.messaging_hook = null;
delete obj.call_hook_sid;
delete obj.call_status_hook_sid;
delete obj.messaging_hook_sid;
return obj;
});
};
router.get('/:service_provider_sid', async(req, res) => {
const logger = req.app.locals.logger;
const {service_provider_sid} = req.params;
try {
const [r] = await promisePool.query('SELECT * from service_providers where service_provider_sid = ?',
service_provider_sid);
if (r.length === 0) {
logger.info(`/AccountTest invalid service_provider_sid ${service_provider_sid}`);
return res.sendStatus(404);
}
const [numbers] = await promisePool.query('SELECT number FROM phone_numbers WHERE service_provider_sid = ?',
service_provider_sid);
const [results] = await promisePool.query({sql: retrieveApplicationsSql, nestTables: true}, service_provider_sid);
res.json({phonenumbers: numbers.map((n) => n.number), applications: transmogrifyResults(results)});
} catch (err) {
sysError(logger, res, err);
}
});
module.exports = router;
+154 -22
View File
@@ -2,20 +2,65 @@ const router = require('express').Router();
const request = require('request');
const {DbErrorBadRequest, DbErrorUnprocessableRequest} = require('../../utils/errors');
const Account = require('../../models/account');
const Application = require('../../models/application');
const Webhook = require('../../models/webhook');
const ApiKey = require('../../models/api-key');
const ServiceProvider = require('../../models/service-provider');
const {deleteDnsRecords} = require('../../utils/dns-utils');
const {deleteCustomer} = require('../../utils/stripe-utils');
const uuidv4 = require('uuid/v4');
const decorate = require('./decorate');
const snakeCase = require('../../utils/snake-case');
const sysError = require('./error');
const preconditions = {
'add': validateAdd,
'update': validateUpdate,
'delete': validateDelete
};
const sysError = require('../error');
const {promisePool} = require('../../db');
const {hasAccountPermissions, parseAccountSid} = require('./utils');
const short = require('short-uuid');
const VoipCarrier = require('../../models/voip-carrier');
const translator = short();
let idx = 0;
router.use('/:sid/SpeechCredentials', hasAccountPermissions, require('./speech-credentials'));
router.use('/:sid/RecentCalls', hasAccountPermissions, require('./recent-calls'));
router.use('/:sid/Alerts', hasAccountPermissions, require('./alerts'));
router.use('/:sid/Charges', hasAccountPermissions, require('./charges'));
router.use('/:sid/SipRealms', hasAccountPermissions, require('./sip-realm'));
router.use('/:sid/PredefinedCarriers', hasAccountPermissions, require('./add-from-predefined-carrier'));
router.get('/:sid/Applications', async(req, res) => {
const logger = req.app.locals.logger;
try {
const account_sid = parseAccountSid(req);
const results = await Application.retrieveAll(null, account_sid);
res.status(200).json(results);
} catch (err) {
sysError(logger, res, err);
}
});
router.get('/:sid/VoipCarriers', async(req, res) => {
const logger = req.app.locals.logger;
try {
const account_sid = parseAccountSid(req);
const results = await VoipCarrier.retrieveAll(account_sid);
res.status(200).json(results);
} catch (err) {
sysError(logger, res, err);
}
});
router.post('/:sid/VoipCarriers', async(req, res) => {
const logger = req.app.locals.logger;
const payload = req.body;
try {
const account_sid = parseAccountSid(req);
logger.debug({payload}, 'POST /:sid/VoipCarriers');
const uuid = await VoipCarrier.make({
account_sid,
...payload
});
res.status(201).json({sid: uuid});
} catch (err) {
sysError(logger, res, err);
}
});
function coerceNumbers(callInfo) {
if (Array.isArray(callInfo)) {
return callInfo.map((ci) => {
@@ -59,7 +104,7 @@ function validateUpdateCall(opts) {
break;
case 2:
if (opts.call_hook && opts.child_call_hook) break;
// eslint-disable-next-line no-fallthrough
// eslint-disable-next-line no-fallthrough
default:
throw new DbErrorBadRequest('multiple options are not allowed in updateCall');
}
@@ -167,13 +212,14 @@ async function validateCreateCall(logger, sid, req) {
async function validateCreateMessage(logger, sid, req) {
const obj = req.body;
const {lookupAccountByPhoneNumber} = req.app.locals;
//const {lookupAccountByPhoneNumber} = req.app.locals;
if (req.user.account_sid !== sid) {
throw new DbErrorBadRequest(`unauthorized createMessage request for account ${sid}`);
}
if (!obj.from) throw new DbErrorBadRequest('missing from property');
/*
else {
const regex = /^\+(\d+)$/;
const arr = regex.exec(obj.from);
@@ -181,7 +227,7 @@ async function validateCreateMessage(logger, sid, req) {
const account = await lookupAccountByPhoneNumber(from);
if (!account) throw new DbErrorBadRequest(`accountSid ${sid} does not own phone number ${from}`);
}
*/
if (!obj.to) throw new DbErrorBadRequest('missing to property');
if (!obj.text && !obj.media) {
@@ -194,7 +240,7 @@ async function validateAdd(req) {
if (req.user.hasAccountAuth) {
throw new DbErrorUnprocessableRequest('insufficient permissions to create accounts');
}
if (req.user.hasServiceProviderAuth) {
if (req.user.hasServiceProviderAuth && req.user.service_provider_sid) {
/* service providers can only create accounts under themselves */
req.body.service_provider_sid = req.user.service_provider_sid;
}
@@ -212,6 +258,9 @@ async function validateUpdate(req, sid) {
if (req.user.hasAccountAuth && req.user.account_sid !== sid) {
throw new DbErrorUnprocessableRequest('insufficient privileges to update this account');
}
if (req.user.hasAccountAuth && req.body.sip_realm) {
throw new DbErrorBadRequest('use POST /Accounts/:sid/sip_realm/:realm to set or change the sip realm');
}
if (req.user.service_provider_sid && !req.user.hasScope('admin')) {
const result = await Account.retrieve(sid);
@@ -225,8 +274,6 @@ async function validateDelete(req, sid) {
if (req.user.hasAccountAuth && req.user.account_sid !== sid) {
throw new DbErrorUnprocessableRequest('insufficient privileges to update this account');
}
const assignedPhoneNumbers = await Account.getForeignKeyReferences('phone_numbers.account_sid', sid);
if (assignedPhoneNumbers > 0) throw new DbErrorUnprocessableRequest('cannot delete account with phone numbers');
if (req.user.service_provider_sid && !req.user.hasScope('admin')) {
const result = await Account.retrieve(sid);
if (result[0].service_provider_sid !== req.user.service_provider_sid) {
@@ -235,16 +282,16 @@ async function validateDelete(req, sid) {
}
}
decorate(router, Account, ['delete'], preconditions);
/* add */
router.post('/', async(req, res) => {
const logger = req.app.locals.logger;
try {
const secret = `wh_secret_${translator.generate()}`;
await validateAdd(req);
// create webhooks if provided
const obj = Object.assign({}, req.body);
const obj = Object.assign({webhook_secret: secret}, req.body);
for (const prop of ['registration_hook']) {
if (obj[prop]) {
obj[`${prop}_sid`] = await Webhook.make(obj[prop]);
@@ -252,7 +299,7 @@ router.post('/', async(req, res) => {
}
}
//logger.debug(`Attempting to add account ${JSON.stringify(obj)}`);
logger.debug(`Attempting to add account ${JSON.stringify(obj)}`);
const uuid = await Account.make(obj);
res.status(201).json({sid: uuid});
} catch (err) {
@@ -287,6 +334,25 @@ router.get('/:sid', async(req, res) => {
}
});
router.get('/:sid/WebhookSecret', async(req, res) => {
const logger = req.app.locals.logger;
try {
const service_provider_sid = req.user.hasServiceProviderAuth ? req.user.service_provider_sid : null;
const results = await Account.retrieve(req.params.sid, service_provider_sid);
if (results.length === 0) return res.status(404).end();
let {webhook_secret} = results[0];
if (req.query.regenerate) {
const secret = `wh_secret_${translator.generate()}`;
await Account.update(req.params.sid, {webhook_secret: secret});
webhook_secret = secret;
}
return res.status(200).json({webhook_secret});
}
catch (err) {
sysError(logger, res, err);
}
});
/* update */
router.put('/:sid', async(req, res) => {
const sid = req.params.sid;
@@ -342,6 +408,68 @@ router.put('/:sid', async(req, res) => {
}
});
/* delete */
router.delete('/:sid', async(req, res) => {
const sid = req.params.sid;
const logger = req.app.locals.logger;
const sqlDeleteGateways = `DELETE from sip_gateways
WHERE voip_carrier_sid IN
(SELECT voip_carrier_sid from voip_carriers where account_sid = ?)`;
try {
await validateDelete(req, sid);
const [account] = await promisePool.query('SELECT * FROM accounts WHERE account_sid = ?', sid);
const {sip_realm, stripe_customer_id} = account[0];
/* remove dns records */
if (process.env.NODE_ENV !== 'test' || process.env.DME_API_KEY) {
/* retrieve existing dns records */
const [recs] = await promisePool.query('SELECT record_id from dns_records WHERE account_sid = ?', sid);
if (recs.length > 0) {
/* remove existing records from the database and dns provider */
const arr = /(.*)\.(.*\..*)$/.exec(sip_realm);
if (!arr) throw new DbErrorBadRequest(`invalid sip_realm: ${sip_realm}`);
const domain = arr[2];
await promisePool.query('DELETE from dns_records WHERE account_sid = ?', sid);
const deleted = await deleteDnsRecords(logger, domain, recs.map((r) => r.record_id));
if (!deleted) {
logger.error({recs, sip_realm, sid},
'Failed to remove old dns records when changing sip_realm for account');
}
}
}
await promisePool.execute('DELETE from api_keys where account_sid = ?', [sid]);
await promisePool.execute(
// eslint-disable-next-line indent
`DELETE from account_products
WHERE account_subscription_sid IN
(SELECT account_subscription_sid FROM
account_subscriptions WHERE account_sid = ?)
`, [sid]);
await promisePool.execute('DELETE from account_subscriptions WHERE account_sid = ?', [sid]);
await promisePool.execute('DELETE from speech_credentials where account_sid = ?', [sid]);
await promisePool.execute('DELETE from users where account_sid = ?', [sid]);
await promisePool.execute('DELETE from phone_numbers where account_sid = ?', [sid]);
await promisePool.execute('DELETE from call_routes where account_sid = ?', [sid]);
await promisePool.execute('DELETE from ms_teams_tenants where account_sid = ?', [sid]);
await promisePool.execute(sqlDeleteGateways, [sid]);
await promisePool.execute('DELETE from voip_carriers where account_sid = ?', [sid]);
await promisePool.execute('DELETE from applications where account_sid = ?', [sid]);
await promisePool.execute('DELETE from accounts where account_sid = ?', [sid]);
if (stripe_customer_id) {
const response = await deleteCustomer(logger, stripe_customer_id);
logger.info({response}, `deleted stripe customer_id ${stripe_customer_id} for account_si ${sid}`);
}
res.status(204).end();
} catch (err) {
sysError(logger, res, err);
}
});
/* retrieve account level api keys */
router.get('/:sid/ApiKeys', async(req, res) => {
const logger = req.app.locals.logger;
@@ -504,7 +632,7 @@ router.put('/:sid/Calls/:callSid', async(req, res) => {
* create a new Message
*/
router.post('/:sid/Messages', async(req, res) => {
const sid = req.params.sid;
const account_sid = parseAccountSid(req);
const setName = `${(process.env.JAMBONES_CLUSTER_ID || 'default')}:active-fs`;
const {retrieveSet, logger} = req.app.locals;
@@ -516,12 +644,16 @@ router.post('/:sid/Messages', async(req, res) => {
}
const ip = fs[idx++ % fs.length];
logger.info({fs}, `feature servers available for createMessage API request, selecting ${ip}`);
const serviceUrl = `http://${ip}:3000/v1/createMessage/${sid}`;
await validateCreateMessage(logger, sid, req);
const serviceUrl = `http://${ip}:3000/v1/createMessage/${account_sid}`;
await validateCreateMessage(logger, account_sid, req);
const payload = Object.assign({messageSid: uuidv4(), account_sid: sid}, req.body);
const payload = {
message_sid: uuidv4(),
account_sid,
...req.body
};
logger.debug({payload}, `sending createMessage API request to to ${ip}`);
updateLastUsed(logger, sid, req).catch((err) => {});
updateLastUsed(logger, account_sid, req).catch(() => {});
request({
url: serviceUrl,
method: 'POST',
@@ -534,7 +666,7 @@ router.post('/:sid/Messages', async(req, res) => {
}
if (response.statusCode !== 200) {
logger.error({statusCode: response.statusCode}, `Non-success response returned by createMessage ${serviceUrl}`);
return res.sendStatus(500);
return res.sendStatus(response.statusCode);
}
res.status(201).json(body);
});
+117
View File
@@ -0,0 +1,117 @@
const router = require('express').Router();
const debug = require('debug')('jambonz:api-server');
const {DbErrorBadRequest} = require('../../utils/errors');
const {promisePool} = require('../../db');
const {validateEmail, emailSimpleText} = require('../../utils/email-utils');
const sysError = require('../error');
const sqlRetrieveUser = `SELECT * from users user
LEFT JOIN accounts AS account
ON user.account_sid = account.account_sid
WHERE user.user_sid = ?`;
const validateRequest = async(req, res) => {
const payload = req.body || {};
/* valid type */
if (!['email', 'phone'].includes(payload.type)) {
throw new DbErrorBadRequest(`invalid activation type: ${payload.type}`);
}
/* valid user? */
const [rows] = await promisePool.query('SELECT * from users WHERE user_sid = ?',
payload.user_sid);
if (0 === rows.length) throw new DbErrorBadRequest('invalid user_sid');
/* valid email? */
if (payload.type === 'email' && !validateEmail(payload.value)) throw new DbErrorBadRequest('invalid email');
};
router.post('/', async(req, res) => {
const logger = req.app.locals.logger;
const {user_sid, type, code, value} = req.body;
try {
await validateRequest(req, res);
const fields = type === 'email' ?
['email', 'email_validated', 'email_activation_code'] :
['phone', 'phone_validated', 'phone_activation_code'];
const sql =
`UPDATE users set ${fields[0]} = ?, ${fields[1]} = 0, ${fields[2]} = ? WHERE user_sid = ?`;
const [r] = await promisePool.execute(sql, [value, code, user_sid]);
logger.debug({r}, 'Result from adding activation code');
debug({r}, 'Result from adding activation code');
if (process.env.NODE_ENV !== 'test') {
if (type === 'email') {
/* send code via email */
const text = '';
const subject = '';
await emailSimpleText(logger, value, subject, text);
}
else {
/* send code via SMS */
}
}
res.sendStatus(204);
} catch (err) {
sysError(logger, res, err);
}
});
router.put('/:code', async(req, res) => {
const logger = req.app.locals.logger;
const code = req.params.code;
const {user_sid, type} = req.body;
try {
let activateAccount = false;
let deactivateOldUsers = false;
let account_sid;
if (type === 'email') {
/* check whether this is first-time activation of account during sign-up/register */
const [r] = await promisePool.query({sql: sqlRetrieveUser, nestTables: true}, user_sid);
logger.debug({r}, 'activationcode - selected user');
if (r.length) {
const {user, account} = r[0];
account_sid = account.account_sid;
const [otherUsers] = await promisePool.query('SELECT * from users WHERE account_sid = ? AND user_sid <> ?',
[account_sid, user_sid]);
logger.debug({otherUsers}, `activationcode - users other than ${user_sid}`);
if (0 === otherUsers.length && user.provider === 'local' && !user.email_validated) {
logger.debug('activationcode - activating account');
activateAccount = true;
}
else if (otherUsers.length) {
logger.debug('activationcode - adding new user for existing account');
deactivateOldUsers = true;
}
}
}
const fields = type === 'email' ?
['email_validated', 'email_activation_code'] :
['phone_validated', 'phone_activation_code'];
const sql = `UPDATE users set ${fields[0]} = 1, ${fields[1]} = NULL WHERE ${fields[1]} = ? AND user_sid = ?`;
const [r] = await promisePool.execute(sql, [code, user_sid]);
logger.debug({r}, 'Result from validating code');
debug({r}, 'Result from validating code');
if (activateAccount) {
await promisePool.execute('UPDATE accounts SET is_active=1 WHERE account_sid = ?', [account_sid]);
}
else if (deactivateOldUsers) {
const [r] = await promisePool.execute('DELETE FROM users WHERE account_sid = ? AND user_sid <> ?',
[account_sid, user_sid]);
logger.debug({r}, 'Result from deleting old/replaced users');
}
if (1 === r.affectedRows) return res.sendStatus(204);
throw new DbErrorBadRequest('invalid user or activation code');
} catch (err) {
sysError(logger, res, err);
}
});
module.exports = router;
@@ -0,0 +1,71 @@
const router = require('express').Router();
const {DbErrorBadRequest} = require('../../utils/errors');
const PredefinedCarrier = require('../../models/predefined-carrier');
const VoipCarrier = require('../../models/voip-carrier');
const SipGateway = require('../../models/sip-gateway');
const {parseServiceProviderSid} = require('./utils');
const {promisePool} = require('../../db');
const sysError = require('../error');
const sqlSelectCarrierByName = `SELECT * FROM voip_carriers
WHERE account_sid = ?
AND name = ?`;
const sqlSelectCarrierByNameForSP = `SELECT * FROM voip_carriers
WHERE service_provider_sid = ?
AND name = ?`;
const sqlSelectTemplateGateways = `SELECT * FROM predefined_sip_gateways
WHERE predefined_carrier_sid = ?`;
router.post('/:sid', async(req, res) => {
const logger = req.app.locals.logger;
const {sid } = req.params;
let service_provider_sid;
const {account_sid} = req.user;
if (!account_sid) {
if (!req.user.hasScope('service_provider')) {
logger.error({user: req.user}, 'invalid creds');
return res.sendStatus(403);
}
service_provider_sid = parseServiceProviderSid(req);
}
try {
const [template] = await PredefinedCarrier.retrieve(sid);
logger.debug({template}, `Retrieved template carrier for sid ${sid}`);
if (!template) return res.sendStatus(404);
/* make sure not to add the same carrier twice */
const [r2] = account_sid ?
await promisePool.query(sqlSelectCarrierByName, [account_sid, template.name]) :
await promisePool.query(sqlSelectCarrierByNameForSP, [service_provider_sid, template.name]);
if (r2.length > 0) {
logger.info({account_sid}, `Failed to add carrier with name ${template.name}, carrier of that name exists`);
throw new DbErrorBadRequest(`A carrier with name ${template.name} already exists`);
}
/* retrieve all the gateways */
const [r3] = await promisePool.query(sqlSelectTemplateGateways, template.predefined_carrier_sid);
logger.debug({r3}, `retrieved template gateways for ${template.name}`);
/* add a voip_carrier */
// eslint-disable-next-line no-unused-vars
const {requires_static_ip, predefined_carrier_sid, ...obj} = template;
const uuid = await VoipCarrier.make({...obj, account_sid, service_provider_sid});
/* add all the gateways */
for (const gw of r3) {
// eslint-disable-next-line no-unused-vars
const {predefined_carrier_sid, predefined_sip_gateway_sid, ...obj} = gw;
logger.debug({obj}, 'adding gateway');
await SipGateway.make({...obj, voip_carrier_sid: uuid});
}
logger.debug({sid: uuid}, 'Successfully added carrier from predefined list');
res.status(201).json({sid: uuid});
} catch (err) {
logger.error({err}, 'Error adding voip_carrier from template');
sysError(logger, res, err);
}
});
module.exports = router;
+35
View File
@@ -0,0 +1,35 @@
const router = require('express').Router();
const sysError = require('../error');
const {DbErrorBadRequest} = require('../../utils/errors');
const parseAccountSid = (url) => {
const arr = /Accounts\/([^\/]*)/.exec(url);
if (arr) return arr[1];
};
router.get('/', async(req, res) => {
const {logger, queryAlerts} = req.app.locals;
try {
logger.debug({opts: req.query}, 'GET /Alerts');
const account_sid = parseAccountSid(req.originalUrl);
const {page, count, alert_type, days, start, end} = req.query || {};
if (!page || page < 1) throw new DbErrorBadRequest('missing or invalid "page" query arg');
if (!count || count < 25 || count > 500) throw new DbErrorBadRequest('missing or invalid "count" query arg');
const data = await queryAlerts({
account_sid,
page,
page_size: count,
alert_type,
days,
start: days ? undefined : start,
end: days ? undefined : end,
});
res.status(200).json(data);
} catch (err) {
sysError(logger, res, err);
}
});
module.exports = router;
+1 -1
View File
@@ -5,7 +5,7 @@ const Account = require('../../models/account');
const decorate = require('./decorate');
const uuidv4 = require('uuid/v4');
const assert = require('assert');
const sysError = require('./error');
const sysError = require('../error');
const preconditions = {
'add': validateAddToken,
'delete': validateDeleteToken
+12 -5
View File
@@ -4,7 +4,7 @@ const Application = require('../../models/application');
const Account = require('../../models/account');
const Webhook = require('../../models/webhook');
const decorate = require('./decorate');
const sysError = require('./error');
const sysError = require('../error');
const preconditions = {
'add': validateAdd,
'update': validateUpdate,
@@ -33,8 +33,11 @@ async function validateAdd(req) {
}
async function validateUpdate(req, sid) {
if (req.user.account_sid && sid !== req.user.account_sid) {
throw new DbErrorBadRequest('you may not update or delete an application associated with a different account');
if (req.user.account_sid) {
const app = await Application.retrieve(sid);
if (!app || !app.length || app[0].account_sid !== req.user.account_sid) {
throw new DbErrorBadRequest('you may not update or delete an application associated with a different account');
}
}
if (req.body.call_hook && typeof req.body.call_hook !== 'object') {
throw new DbErrorBadRequest('\'call_hook\' must be an object when updating an application');
@@ -45,8 +48,12 @@ async function validateUpdate(req, sid) {
}
async function validateDelete(req, sid) {
if (req.user.account_sid && sid !== req.user.account_sid) {
throw new DbErrorBadRequest('you may not update or delete an application associated with a different account');
if (req.user.hasAccountAuth) {
const result = await Application.retrieve(sid);
if (!result || 0 === result.length) throw new DbErrorBadRequest('application does not exist');
if (result[0].account_sid !== req.user.account_sid) {
throw new DbErrorUnprocessableRequest('cannot delete application owned by a different account');
}
}
const assignedPhoneNumbers = await Application.getForeignKeyReferences('phone_numbers.application_sid', sid);
if (assignedPhoneNumbers > 0) throw new DbErrorUnprocessableRequest('cannot delete application with phone numbers');
+31
View File
@@ -0,0 +1,31 @@
const router = require('express').Router();
const {DbErrorBadRequest} = require('../../utils/errors');
const {promisePool} = require('../../db');
const sysError = require('../error');
router.get('/', async(req, res) => {
const logger = req.app.locals.logger;
const {type, value} = req.query;
try {
if (['email', 'phone'].includes(type)) {
const field = type === 'email' ? 'email' : 'phone';
const sql = `SELECT * from users WHERE ${field} = ?`;
const [r] = await promisePool.execute(sql, [value]);
res.json({available: 0 === r.length});
}
else if (type === 'subdomain') {
const sql = 'SELECT * from accounts WHERE sip_realm = ?';
const [r] = await promisePool.execute(sql, [value]);
res.json({available: 0 === r.length});
}
else throw new DbErrorBadRequest(`invalid type: ${type}`);
} catch (err) {
sysError(logger, res, err);
}
});
module.exports = router;
+32
View File
@@ -0,0 +1,32 @@
const router = require('express').Router();
const sysError = require('../error');
const {promisePool} = require('../../db');
const short = require('short-uuid');
const translator = short('0123456789ABCXZ');
router.post('/', async(req, res) => {
const {logger} = req.app.locals;
logger.debug({payload: req.body}, 'POST /BetaInviteCodes');
try {
const {count} = req.body || {};
const total = Math.max(count || 1, 1);
const codes = [];
let added = 0;
while (added < total) {
const code = translator.new().substring(0, 6);
if (!codes.find((c) => c === code)) {
codes.push(code);
added++;
}
}
const values = codes.map((c) => `('${c}')`).join(',');
const sql = `INSERT INTO beta_invite_codes (invite_code) VALUES ${values}`;
const [r] = await promisePool.query(sql);
res.status(200).json({status: 'ok', added: r.affectedRows, codes});
} catch (err) {
sysError(logger, res, err);
}
});
module.exports = router;
+47
View File
@@ -0,0 +1,47 @@
const router = require('express').Router();
//const debug = require('debug')('jambonz:api-server');
const {DbErrorBadRequest} = require('../../utils/errors');
const {generateHashedPassword, verifyPassword} = require('../../utils/password-utils');
const {promisePool} = require('../../db');
const sysError = require('../error');
const sqlUpdatePassword = `UPDATE users
SET hashed_password= ?
WHERE user_sid = ?`;
router.post('/', async(req, res) => {
const {logger, retrieveKey, deleteKey} = req.app.locals;
const {user_sid} = req.user;
const {old_password, new_password} = req.body;
try {
if (!old_password || !new_password) throw new DbErrorBadRequest('missing old_password or new_password');
/* validate existing password */
{
const [r] = await promisePool.query('SELECT * from users where user_sid = ?', user_sid);
logger.debug({user: [r[0]]}, 'change password for user');
if (r[0].provider !== 'local') {
throw new DbErrorBadRequest('user is using oauth authentication');
}
const isCorrect = await verifyPassword(r[0].hashed_password, old_password);
if (!isCorrect) {
const key = `reset-link:${old_password}`;
const user_sid = await retrieveKey(key);
if (!user_sid) throw new DbErrorBadRequest('old_password is incorrect');
await deleteKey(key);
}
}
/* store new password */
const passwordHash = await generateHashedPassword(new_password);
const [r] = await promisePool.execute(sqlUpdatePassword, [passwordHash, user_sid]);
if (r.affectedRows !== 1) throw new Error('failed to update user with new password');
res.sendStatus(204);
} catch (err) {
sysError(logger, res, err);
return;
}
});
module.exports = router;
+46
View File
@@ -0,0 +1,46 @@
const router = require('express').Router();
const sysError = require('../error');
//const {DbErrorUnprocessableRequest, DbErrorBadRequest} = require('../../utils/errors');
/**
* retrieve charges for an account and/or call
*/
router.get('/', async(req, res) => {
const logger = req.app.locals.logger;
try {
res.status(200).json([
{
charge_sid: 'f8d2a604-ed29-4eac-9efc-8f58b0e438ca',
account_sid: req.user.account_sid,
call_billing_record_sid: 'e4be80a4-6597-49cf-8605-6b94493fada1',
billed_at: '2020-01-01 15:10:10',
billed_activity: 'outbound-call',
call_secs_billed: 392,
amount_charged: 0.0200
},
{
charge_sid: 'd9659f3f-3a94-455c-9e8e-3b36f250ffc8',
account_sid: req.user.account_sid,
call_billing_record_sid: 'e4be80a4-6597-49cf-8605-6b94493fada1',
billed_at: '2020-01-01 15:10:10',
billed_activity: 'tts',
tts_chars_billed: 100,
amount_charged: 0.0130
},
{
charge_sid: 'adcc1e79-eb79-4370-ab74-4c2e9a41339a',
account_sid: req.user.account_sid,
call_billing_record_sid: 'e4be80a4-6597-49cf-8605-6b94493fada1',
billed_at: '2020-01-01 15:10:10',
billed_activity: 'stt',
stt_secs_billed: 30,
amount_charged: 0.0015
}
]);
} catch (err) {
sysError(logger, res, err);
}
});
module.exports = router;
+5 -5
View File
@@ -1,5 +1,5 @@
const assert = require('assert');
const sysError = require('./error');
const sysError = require('../error');
module.exports = decorate;
@@ -58,7 +58,7 @@ function retrieve(router, klass) {
const logger = req.app.locals.logger;
try {
const results = await klass.retrieve(req.params.sid);
if (results.length === 0) return res.status(404).end();
if (results.length === 0) return res.sendStatus(404);
return res.status(200).json(results[0]);
}
catch (err) {
@@ -78,7 +78,7 @@ function update(router, klass, preconditions) {
}
const rowsAffected = await klass.update(sid, req.body);
if (rowsAffected === 0) {
return res.status(404).end();
return res.sendStatus(404);
}
res.status(204).end();
} catch (err) {
@@ -99,9 +99,9 @@ function remove(router, klass, preconditions) {
const rowsAffected = await klass.remove(sid);
if (rowsAffected === 0) {
logger.info(`unable to delete ${klass.name} with sid ${sid}: not found`);
return res.status(404).end();
return res.sendStatus(404);
}
res.status(204).end();
res.sendStatus(204);
} catch (err) {
sysError(logger, res, err);
}
+82
View File
@@ -0,0 +1,82 @@
const router = require('express').Router();
//const debug = require('debug')('jambonz:api-server');
const short = require('short-uuid');
const translator = short();
const {validateEmail, emailSimpleText} = require('../../utils/email-utils');
const {promisePool} = require('../../db');
const sysError = require('../error');
const sql = `SELECT * from users user
LEFT JOIN accounts AS acc
ON acc.account_sid = user.account_sid
WHERE user.email = ?`;
function createOauthEmailText(provider) {
return `Hi there!
Someone (presumably you!) requested to reset their password.
However, the account associated with this email is using oauth identification via ${provider},
Please change your password through that provider, if you wish to.
If you did not make this request, please delete this email. No further action is required.
Best,
Jambonz support team`;
}
function createResetEmailText(link) {
const baseUrl = 'http://localhost:3001';
return `Hi there!
Someone (presumably you!) requested to reset their password.
Please follow the link below to reset your password:
${baseUrl}/reset-password/${link}
This link is valid for 1 hour only.
If you did not make this request, please delete this email. No further action is required.
Best,
Jambonz support team`;
}
router.post('/', async(req, res) => {
const {logger, addKey} = req.app.locals;
const {email} = req.body;
let obj;
try {
if (!email || !validateEmail(email)) {
return res.status(400).json({error: 'invalid or missing email'});
}
const [r] = await promisePool.query({sql, nestTables: true}, email);
if (0 === r.length) {
return res.status(400).json({error: 'email does not exist'});
}
obj = r[0];
if (!obj.acc.is_active) {
return res.status(400).json({error: 'you may not reset the password of an inactive account'});
}
res.sendStatus(204);
} catch (err) {
sysError(logger, res, err);
return;
}
if (obj.user.provider !== 'local') {
/* send email indicating they need to change via their oauth provider */
emailSimpleText(logger, email, 'Reset password request', createOauthEmailText(obj.user.provider));
}
else {
/* generate a link for this user to reset, send email */
const link = translator.generate();
addKey(`reset-link:${link}`, obj.user.user_sid, 3600)
.catch((err) => logger.error({err}, 'Error adding reset link to redis'));
emailSimpleText(logger, email, 'Reset password request', createResetEmailText(link));
}
});
module.exports = router;
+25 -6
View File
@@ -1,26 +1,45 @@
const api = require('express').Router();
function isAdminScope(req, res, next) {
const isAdminScope = (req, res, next) => {
if (req.user.hasScope('admin')) return next();
res.status(403).json({
status: 'fail',
message: 'insufficient privileges'
});
}
};
api.use('/BetaInviteCodes', isAdminScope, require('./beta-invite-codes'));
api.use('/ServiceProviders', isAdminScope, require('./service-providers'));
api.use('/VoipCarriers', isAdminScope, require('./voip-carriers'));
api.use('/SipGateways', isAdminScope, require('./sip-gateways'));
api.use('/PhoneNumbers', isAdminScope, require('./phone-numbers'));
api.use('/VoipCarriers', require('./voip-carriers'));
api.use('/Webhooks', require('./webhooks'));
api.use('/SipGateways', require('./sip-gateways'));
api.use('/SmppGateways', require('./smpp-gateways'));
api.use('/PhoneNumbers', require('./phone-numbers'));
api.use('/ApiKeys', require('./api-keys'));
api.use('/Accounts', require('./accounts'));
api.use('/Applications', require('./applications'));
api.use('/MicrosoftTeamsTenants', require('./tenants'));
api.use('/Sbcs', isAdminScope, require('./sbcs'));
api.use('/Sbcs', require('./sbcs'));
api.use('/Users', require('./users'));
api.use('/register', require('./register'));
api.use('/signin', require('./signin'));
api.use('/login', require('./login'));
api.use('/logout', require('./logout'));
api.use('/forgot-password', require('./forgot-password'));
api.use('/change-password', require('./change-password'));
api.use('/ActivationCode', require('./activation-code'));
api.use('/Availability', require('./availability'));
api.use('/AccountTest', require('./account-test'));
//api.use('/Products', require('./products'));
api.use('/Prices', require('./prices'));
api.use('/StripeCustomerId', require('./stripe-customer-id'));
api.use('/Subscriptions', require('./subscriptions'));
api.use('/Invoices', require('./invoices'));
api.use('/InviteCodes', require('./invite-codes'));
api.use('/PredefinedCarriers', require('./predefined-carriers'));
// messaging
api.use('/Smpps', require('./smpps')); // our smpp server info
api.use('/messaging', require('./sms-inbound')); // inbound SMS from carrier
api.use('/outboundSMS', require('./sms-outbound')); // outbound SMS from feature server
+34
View File
@@ -0,0 +1,34 @@
const router = require('express').Router();
const sysError = require('../error');
const {promisePool} = require('../../db');
const sqlClaim = `UPDATE beta_invite_codes
SET in_use = 1
WHERE invite_code = ?
AND in_use = 0`;
const sqlTest = `SELECT * FROM beta_invite_codes
WHERE invite_code = ?
AND in_use = 0`;
router.post('/', async(req, res) => {
const {logger} = req.app.locals;
try {
const {code, test} = req.body;
logger.debug({code}, 'POST /InviteCodes');
if ('test' === process.env.NODE_ENV) {
if (code.endsWith('0')) return res.sendStatus(404);
res.sendStatus(204);
return;
}
if (test) {
const [r] = await promisePool.execute(sqlTest, [code]);
res.sendStatus(1 === r.length ? 204 : 404);
}
else {
const [r] = await promisePool.execute(sqlClaim, [code]);
res.sendStatus(1 === r.affectedRows ? 204 : 404);
}
} catch (err) {
sysError(logger, res, err);
}
});
module.exports = router;
+26
View File
@@ -0,0 +1,26 @@
const router = require('express').Router();
const assert = require('assert');
const Account = require('../../models/account');
const {
retrieveUpcomingInvoice
} = require('../../utils/stripe-utils');
const sysError = require('../error');
/* retrieve */
router.get('/', async(req, res) => {
const logger = req.app.locals.logger;
const {account_sid} = req.user;
try {
const results = await Account.retrieve(account_sid);
assert.ok(1 === results.length, `account ${account_sid} not found`);
const {stripe_customer_id} = results[0];
if (!stripe_customer_id) return res.sendStatus(404);
const invoice = await retrieveUpcomingInvoice(logger, stripe_customer_id);
res.status(200).json(invoice);
} catch (err) {
if (err.statusCode) return res.sendStatus(err.statusCode);
sysError(logger, res, err);
}
});
module.exports = router;
+6 -19
View File
@@ -1,19 +1,10 @@
const router = require('express').Router();
const crypto = require('crypto');
const {getMysqlConnection} = require('../../db');
const {verifyPassword} = require('../../utils/password-utils');
const retrieveSql = 'SELECT * from users where name = ?';
const tokenSql = 'SELECT token from api_keys where account_sid IS NULL AND service_provider_sid IS NULL';
const sha512 = function(password, salt) {
const hash = crypto.createHmac('sha512', salt); /** Hashing algorithm sha512 */
hash.update(password);
var value = hash.digest('hex');
return {
salt:salt,
passwordHash:value
};
};
router.post('/', (req, res) => {
const logger = req.app.locals.logger;
@@ -28,7 +19,7 @@ router.post('/', (req, res) => {
logger.error({err}, 'Error getting db connection');
return res.sendStatus(500);
}
conn.query(retrieveSql, [username], (err, results) => {
conn.query(retrieveSql, [username], async(err, results) => {
conn.release();
if (err) {
logger.error({err}, 'Error getting db connection');
@@ -40,14 +31,10 @@ router.post('/', (req, res) => {
}
logger.info({results}, 'successfully retrieved account');
const salt = results[0].salt;
const trueHash = results[0].hashed_password;
const forceChange = results[0].force_change;
const isCorrect = await verifyPassword(results[0].hashed_password, password);
if (!isCorrect) return res.sendStatus(403);
const {passwordHash} = sha512(password, salt);
if (trueHash !== passwordHash) return res.sendStatus(403);
if (forceChange) return res.json({user_sid: results[0].user_sid, force_change: true});
const force_change = !!results[0].force_change;
getMysqlConnection((err, conn) => {
if (err) {
@@ -64,7 +51,7 @@ router.post('/', (req, res) => {
logger.error('Database has no admin token provisioned...run reset_admin_password');
return res.sendStatus(500);
}
res.json({user_sid: results[0].user_sid, token: tokenResults[0].token});
res.json({user_sid: results[0].user_sid, force_change, token: tokenResults[0].token});
});
});
});
+23
View File
@@ -0,0 +1,23 @@
const router = require('express').Router();
const debug = require('debug')('jambonz:api-server');
const {hashString} = require('../../utils/password-utils');
const sysError = require('../error');
router.post('/', async(req, res) => {
const {logger, addKey} = req.app.locals;
const {jwt} = req.user;
debug(`adding jwt to blacklist: ${jwt}`);
try {
/* add key to blacklist */
const s = `jwt:${hashString(jwt)}`;
const result = await addKey(s, '1', 3600);
debug(`result from adding ${s}: ${result}`);
res.sendStatus(204);
} catch (err) {
sysError(logger, res, err);
}
});
module.exports = router;
+19 -11
View File
@@ -3,13 +3,14 @@ const {DbErrorUnprocessableRequest, DbErrorBadRequest} = require('../../utils/er
const PhoneNumber = require('../../models/phone-number');
const VoipCarrier = require('../../models/voip-carrier');
const decorate = require('./decorate');
const validateNumber = require('../../utils/phone-number-syntax');
const {e164} = require('../../utils/phone-number-utils');
const preconditions = {
'add': validateAdd,
'delete': checkInUse,
'update': validateUpdate
};
const sysError = require('./error');
const sysError = require('../error');
/* check for required fields when adding */
async function validateAdd(req) {
@@ -19,17 +20,19 @@ async function validateAdd(req) {
req.body.account_sid = req.user.account_sid;
}
if (!req.body.voip_carrier_sid) throw new DbErrorBadRequest('voip_carrier_sid is required');
if (!req.body.number) throw new DbErrorBadRequest('number is required');
validateNumber(req.body.number);
const formattedNumber = e164(req.body.number);
req.body.number = formattedNumber;
} catch (err) {
throw new DbErrorBadRequest(err.message);
}
/* check that voip carrier exists */
const result = await VoipCarrier.retrieve(req.body.voip_carrier_sid);
if (!result || result.length === 0) {
throw new DbErrorBadRequest(`voip_carrier not found for sid ${req.body.voip_carrier_sid}`);
if (req.body.voip_carrier_sid) {
const result = await VoipCarrier.retrieve(req.body.voip_carrier_sid);
if (!result || result.length === 0) {
throw new DbErrorBadRequest(`voip_carrier not found for sid ${req.body.voip_carrier_sid}`);
}
}
}
@@ -37,7 +40,7 @@ async function validateAdd(req) {
async function checkInUse(req, sid) {
const phoneNumber = await PhoneNumber.retrieve(sid);
if (req.user.hasAccountAuth) {
if (phoneNumber.account_sid !== req.user.account_sid) {
if (phoneNumber && phoneNumber.length && phoneNumber[0].account_sid !== req.user.account_sid) {
throw new DbErrorUnprocessableRequest('cannot delete a phone number that belongs to another account');
}
}
@@ -53,10 +56,16 @@ async function validateUpdate(req, sid) {
const phoneNumber = await PhoneNumber.retrieve(sid);
if (req.user.hasAccountAuth) {
if (phoneNumber.account_sid !== req.user.account_sid) {
throw new DbErrorUnprocessableRequest('cannot delete a phone number that belongs to another account');
if (phoneNumber && phoneNumber.length && phoneNumber[0].account_sid !== req.user.account_sid) {
throw new DbErrorUnprocessableRequest('cannot operate on a phone number that belongs to another account');
}
}
// TODO: if we are assigning to an account, verify it exists
// TODO: if we are assigning to an application, verify it is associated to the same account
// TODO: if we are removing from an account, verify we are also removing from application.
}
decorate(router, PhoneNumber, ['add', 'update', 'delete'], preconditions);
@@ -86,5 +95,4 @@ router.get('/:sid', async(req, res) => {
}
});
module.exports = router;
+7
View File
@@ -0,0 +1,7 @@
const router = require('express').Router();
const PredefinedCarrier = require('../../models/predefined-carrier');
const decorate = require('./decorate');
decorate(router, PredefinedCarrier, ['list']);
module.exports = router;
+108
View File
@@ -0,0 +1,108 @@
const router = require('express').Router();
const Product = require('../../models/product');
const {promisePool} = require('../../db');
const sysError = require('../error');
const sqlRetrieveSpecialOffers = `SELECT *
FROM account_offers offer
LEFT JOIN products AS product ON product.product_sid = offer.product_sid
WHERE offer.account_sid = ?`;
const combineProductAndPrice = (localProducts, product, prices) => {
const lp = localProducts.find((lp) => lp.category === product.metadata.jambonz_category);
return {
product_sid: lp.product_sid,
name: lp.name,
category: lp.category,
stripe_product_id: product.id,
description: product.description,
unit_label: product.unit_label,
prices: prices.map((price) => {
return {
stripe_price_id: price.id,
billing_scheme: price.billing_scheme,
currency: price.currency,
recurring: price.recurring,
tiers_mode: price.tiers_mode,
tiers: price.tiers,
type: price.type,
unit_amount: price.unit_amount,
unit_amount_decimal: price.unit_amount_decimal
};
})
};
};
/* list */
router.get('/', async(req, res) => {
const logger = req.app.locals.logger;
const {account_sid} = req.user || {};
const {listProducts, retrieveProduct, retrievePricesForProduct} = require('../../utils/stripe-utils');
try {
const localProducts = await Product.retrieveAll();
/**
* If this request is for a specific account (we have an account_sid)
* then check to see if we have any special offers for this account
*/
const selectedProducts = [];
if (account_sid) {
const [r] = await promisePool.query({sql: sqlRetrieveSpecialOffers, nestTables: true}, account_sid);
logger.debug({r}, `retrieved special offer ids for account_sid ${account_sid}`);
if (r.length > 0) {
/* retrieve all the offers for this account */
const products = await Promise.all(r.map((row) => retrieveProduct(logger, row.offer.stripe_product_id)));
logger.debug({products}, `retrieved special offer products for account_sid ${account_sid}`);
const prices = await Promise.all(products.map((prod) => retrievePricesForProduct(logger, prod.id)));
logger.debug({prices}, `retrieved special offer prices for account_sid ${account_sid}`);
for (let i = 0; i < products.length; i++) {
selectedProducts.push(combineProductAndPrice(localProducts, products[i], prices[i].data));
}
}
}
/**
* we must return at least pricing for sessions and devices, so find and use
* the general pricing if no account-specific product was specified for these
*/
const haveSessionPricing = selectedProducts.find((prod) => prod.category === 'voice_call_session');
const haveDevicePricing = selectedProducts.find((prod) => prod.category === 'device');
if (haveSessionPricing && haveDevicePricing) {
logger.debug({selectedProducts}, 'found account level offers for sessions and devices');
return res.status(200).json(selectedProducts);
}
/* need to get default pricing */
const allProducts = await listProducts(logger);
logger.debug({allProducts}, 'retrieved all products');
const defaultProducts = allProducts.data.filter((prod) =>
['voice_call_session', 'device'].includes(prod.metadata.jambonz_category) &&
'general' === prod.metadata.availability);
logger.debug({defaultProducts}, 'default products');
if (!haveSessionPricing) {
const product = defaultProducts.find((prod) => 'voice_call_session' === prod.metadata.jambonz_category);
if (product) {
logger.debug(`retrieving prices for product id ${product.id}`);
const prices = await retrievePricesForProduct(logger, product.id);
selectedProducts.push(combineProductAndPrice(localProducts, product, prices.data));
}
}
if (!haveDevicePricing) {
const product = defaultProducts.find((prod) => 'device' === prod.metadata.jambonz_category);
if (product) {
logger.debug(`retrieving prices for product id ${product.id}`);
const prices = await retrievePricesForProduct(logger, product.id);
selectedProducts.push(combineProductAndPrice(localProducts, product, prices.data));
}
}
res.status(200).json(selectedProducts);
} catch (err) {
sysError(logger, res, err);
}
});
module.exports = router;
+78
View File
@@ -0,0 +1,78 @@
const assert = require('assert');
const router = require('express').Router();
const Product = require('../../models/product');
const {listProducts, listPrices} = require('../../utils/stripe-utils');
const sysError = require('./error');
/* list */
router.get('/', async(req, res) => {
const logger = req.app.locals.logger;
try {
const [stripeProducts, localProducts] = await Promise.all([listProducts(), Product.retrieveAll()]);
console.log(stripeProducts);
console.log(localProducts);
const arr = localProducts.map((p) => {
const stripe = stripeProducts.data
.find((s) => s.metadata.jambonz_category === p.category);
assert.ok(stripe, `No stripe product found for category ${p.category}`);
Object.assign(p, {
stripe_product_id: stripe.id,
statement_descriptor: stripe.statement_descriptor,
description: stripe.description,
unit_label: stripe.unit_label
});
return p;
});
res.status(200).json(arr);
} catch (err) {
sysError(logger, res, err);
}
});
/* get */
router.get('/:sid', async(req, res) => {
const logger = req.app.locals.logger;
try {
const [allPrices, results] = await Promise.all([listPrices(), Product.retrieve(req.params.sid)]);
if (results.length === 0) return res.sendStatus(404);
const product = results[0];
const prices = allPrices.data
.filter((p) => p.active && p.product.active)
.filter((p) => p.product.metadata.jambonz_category === product.category);
assert(prices.length > 0, `No pricing data found for product ${req.params.sid}`);
const stripe = prices[0].product;
Object.assign(product, {
stripe_product_id: stripe.id,
statement_descriptor: stripe.statement_descriptor,
description: stripe.description,
unit_label: stripe.unit_label
});
// get pricing
Object.assign(product, {
pricing: {
billing_scheme: stripe.billing_scheme,
type: prices[0].type
}
});
product.pricing.fees = prices.map((price) => {
const obj = {
stripe_price_id: price.id,
currency: price.currency,
unit_amount: price.unit_amount,
unit_amount_decimal: price.unit_amount_decimal
};
if (price.tiers) {
obj.tiers = price.tiers;
obj.tiers_mode = price.tiers_mode;
}
return obj;
});
res.status(200).json(product);
} catch (err) {
sysError(logger, res, err);
}
});
module.exports = router;
+37
View File
@@ -0,0 +1,37 @@
const router = require('express').Router();
const sysError = require('../error');
const {DbErrorBadRequest} = require('../../utils/errors');
const parseAccountSid = (url) => {
const arr = /Accounts\/([^\/]*)/.exec(url);
if (arr) return arr[1];
};
router.get('/', async(req, res) => {
const {logger, queryCdrs} = req.app.locals;
try {
logger.debug({opts: req.query}, 'GET /RecentCalls');
const account_sid = parseAccountSid(req.originalUrl);
const {page, count, trunk, direction, days, answered, start, end} = req.query || {};
if (!page || page < 1) throw new DbErrorBadRequest('missing or invalid "page" query arg');
if (!count || count < 25 || count > 500) throw new DbErrorBadRequest('missing or invalid "count" query arg');
const data = await queryCdrs({
account_sid,
page,
page_size: count,
trunk,
direction,
days,
answered,
start: days ? undefined : start,
end: days ? undefined : end,
});
res.status(200).json(data);
} catch (err) {
sysError(logger, res, err);
}
});
module.exports = router;
+344
View File
@@ -0,0 +1,344 @@
const router = require('express').Router();
const debug = require('debug')('jambonz:api-server');
const {DbErrorBadRequest, DbErrorUnprocessableRequest} = require('../../utils/errors');
const {promisePool} = require('../../db');
const {doGithubAuth, doGoogleAuth, doLocalAuth} = require('../../utils/oauth-utils');
const {validateEmail} = require('../../utils/email-utils');
const uuid = require('uuid').v4;
const short = require('short-uuid');
const translator = short();
const jwt = require('jsonwebtoken');
const {setupFreeTrial, createTestCdrs, createTestAlerts} = require('./utils');
const {generateHashedPassword} = require('../../utils/password-utils');
const sysError = require('../error');
const insertUserSql = `INSERT into users
(user_sid, account_sid, name, email, provider, provider_userid, email_validated)
values (?, ?, ?, ?, ?, ?, 1)`;
const insertUserLocalSql = `INSERT into users
(user_sid, account_sid, name, email, email_activation_code, email_validated, provider, hashed_password)
values (?, ?, ?, ?, ?, 0, 'local', ?)`;
const insertAccountSql = `INSERT into accounts
(account_sid, service_provider_sid, name, is_active, webhook_secret, trial_end_date)
values (?, ?, ?, ?, ?, CURDATE() + INTERVAL 21 DAY)`;
const queryRootDomainSql = `SELECT root_domain
FROM service_providers
WHERE service_providers.service_provider_sid = ?`;
const insertSignupHistorySql = `INSERT into signup_history
(email, name)
values (?, ?)`;
const addLocalUser = async(logger, user_sid, account_sid,
name, email, email_activation_code, passwordHash) => {
const [r] = await promisePool.execute(insertUserLocalSql,
[
user_sid,
account_sid,
name,
email,
email_activation_code,
passwordHash
]);
debug({r}, 'Result from adding user');
};
const addOauthUser = async(logger, user_sid, account_sid,
name, email, provider, provider_userid) => {
const [r] = await promisePool.execute(insertUserSql,
[
user_sid,
account_sid,
name,
email,
provider,
provider_userid
]);
logger.debug({r}, 'Result from adding user');
};
const validateRequest = async(req, user_sid) => {
const payload = req.body || {};
/* check required properties are there */
['provider', 'service_provider_sid'].forEach((prop) => {
if (!payload[prop]) throw new DbErrorBadRequest(`missing ${prop}`);
});
/* valid service provider? */
const [rows] = await promisePool.query('SELECT * from service_providers WHERE service_provider_sid = ?',
payload.service_provider_sid);
if (0 === rows.length) throw new DbErrorUnprocessableRequest('invalid service_provider_sid');
/* valid provider? */
if (!['local', 'github', 'google', 'twitter'].includes(payload.provider)) {
throw new DbErrorUnprocessableRequest(`invalid provider: ${payload.provider}`);
}
/* if local provider then email/password */
if ('local' === payload.provider) {
if (!payload.email || !payload.password) throw new DbErrorBadRequest('missing email or password');
/* valid email? */
if (!validateEmail(payload.email)) throw new DbErrorBadRequest('invalid email');
/* valid password? */
if (payload.password.length < 6) throw new DbErrorBadRequest('password must be at least 6 characters');
/* is this email available? */
if (user_sid) {
const [rows] = await promisePool.query('SELECT * from users WHERE email = ? AND user_sid <> ?',
[payload.email, user_sid]);
if (rows.length > 0) throw new DbErrorUnprocessableRequest('account already exists for this email');
}
else {
const [rows] = await promisePool.query('SELECT * from users WHERE email = ?', payload.email);
if (rows.length > 0) throw new DbErrorUnprocessableRequest('account already exists for this email');
}
/* verify that we have a code to email them */
if (!payload.email_activation_code) throw new DbErrorBadRequest('email activation code required');
}
else {
['oauth2_code', 'oauth2_state', 'oauth2_client_id', 'oauth2_redirect_uri'].forEach((prop) => {
if (!payload[prop]) throw new DbErrorBadRequest(`missing ${prop} for provider ${payload.provider}`);
});
}
};
const parseAuthorizationToken = (logger, req) => {
const notfound = {};
const authHeader = req.get('Authorization');
if (!authHeader) return Promise.resolve(notfound);
return new Promise((resolve) => {
const arr = /^Bearer (.*)$/.exec(req.get('Authorization'));
if (!arr) return resolve(notfound);
jwt.verify(arr[1], process.env.JWT_SECRET, async(err, decoded) => {
if (err) return resolve(notfound);
logger.debug({jwt: decoded}, 'register - create new user for existing account');
resolve(decoded);
});
});
};
/**
* called to create a new user and account
* or new user with existing account, in case of "change auth mechanism"
*/
router.post('/', async(req, res) => {
const {logger, writeCdrs, writeAlerts, AlertType} = req.app.locals;
const userProfile = {};
try {
const {user_sid, account_sid} = await parseAuthorizationToken(logger, req);
await validateRequest(req, user_sid);
logger.debug({payload: req.body}, 'POST /register');
if (req.body.provider === 'github') {
const user = await doGithubAuth(logger, req.body);
logger.info({user}, 'retrieved user details from github');
Object.assign(userProfile, {
name: user.name,
email: user.email,
email_validated: user.email_validated,
avatar_url: user.avatar_url,
provider: 'github',
provider_userid: user.login
});
}
else if (req.body.provider === 'google') {
const user = await doGoogleAuth(logger, req.body);
logger.info({user}, 'retrieved user details from google');
Object.assign(userProfile, {
name: user.name,
email: user.email,
email_validated: user.verified_email,
picture: user.picture,
provider: 'google',
provider_userid: user.id
});
}
else if (req.body.provider === 'local') {
const user = await doLocalAuth(logger, req.body);
logger.info({user}, 'retrieved user details for local provider');
debug({user}, 'retrieved user details for local provider');
Object.assign(userProfile, {
name: user.name,
email: user.email,
provider: 'local',
email_activation_code: user.email_activation_code
});
}
if (req.body.provider !== 'local') {
/* when using oauth2, check to see if user already exists */
const [users] = await promisePool.query(
'SELECT * from users WHERE provider = ? AND provider_userid = ?',
[userProfile.provider, userProfile.provider_userid]);
logger.debug({users}, `Result from retrieving user for ${userProfile.provider}:${userProfile.provider_userid}`);
if (1 === users.length) {
/* if changing existing account to oauth, no other user with that provider/userid must exist */
if (user_sid) {
throw new DbErrorUnprocessableRequest('account already exists for this oauth user/provider');
}
Object.assign(userProfile, {
user_sid: users[0].user_sid,
account_sid: users[0].account_sid,
name: users[0].name,
email: users[0].email,
phone: users[0].phone,
pristine: false,
email_validated: users[0].email_validated ? true : false,
phone_validated: users[0].phone_validated ? true : false,
scope: users[0].scope
});
const [accounts] = await promisePool.query('SELECT * from accounts WHERE account_sid = ?',
userProfile.account_sid);
if (accounts.length === 0) throw new DbErrorUnprocessableRequest('user exists with no associated account');
Object.assign(userProfile, {
is_active: accounts[0].is_active == 1,
tutorial_completion: accounts[0].tutorial_completion
});
}
else {
/* you can not register from the sign-in page */
if (req.body.locationBeforeAuth === '/sign-in') {
logger.debug('redirecting user to /register so they accept Ts & Cs');
return res.status(404).json({msg: 'registering a new account not allowed from the sign-in page'});
}
/* new user, but check if we already have an account with that email */
let sql = 'SELECT * from users WHERE email = ?';
const args = [userProfile.email];
if (user_sid) {
sql += ' AND user_sid <> ?';
args.push(user_sid);
}
logger.debug(`sql is ${sql}`);
const [accounts] = await promisePool.execute(sql, args);
if (accounts.length > 0) {
throw new DbErrorBadRequest(`user already exists with email ${userProfile.email}`);
}
}
}
if (userProfile.pristine !== false && !user_sid) {
/* add a new user and account */
/* get root domain */
const [sp] = await promisePool.query(queryRootDomainSql, req.body.service_provider_sid);
if (0 === sp.length) throw new Error(`service_provider not found for sid ${req.body.service_provider_sid}`);
if (!sp[0].root_domain) {
throw new Error(`root_domain missing for service provider ${req.body.service_provider_sid}`);
}
userProfile.root_domain = sp[0].root_domain;
userProfile.account_sid = uuid();
userProfile.user_sid = uuid();
const [r1] = await promisePool.execute(insertAccountSql,
[
userProfile.account_sid,
req.body.service_provider_sid,
userProfile.name || userProfile.email,
req.body.provider !== 'local',
`wh_secret_${translator.generate()}`
]);
logger.debug({r1}, 'Result from adding account');
/* add to signup history */
let isReturningUser = false;
try {
await promisePool.execute(insertSignupHistorySql,
[userProfile.email, userProfile.name || userProfile.email]);
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') {
logger.info(`register: user is signing up for a second trial: ${userProfile.email}`);
isReturningUser = true;
}
}
/* write sample cdrs and alerts in test environment */
if ('test' === process.env.NODE_ENV) {
await createTestCdrs(writeCdrs, userProfile.account_sid);
await createTestAlerts(writeAlerts, AlertType, userProfile.account_sid);
logger.debug('added test data for cdrs and alerts');
}
/* assign starter set of products */
await setupFreeTrial(logger, userProfile.account_sid, isReturningUser);
/* add a user for the account */
if (req.body.provider === 'local') {
/* hash password */
debug(`salting password: ${req.body.password}`);
const passwordHash = await generateHashedPassword(req.body.password);
debug(`hashed password: ${passwordHash}`);
await addLocalUser(logger, userProfile.user_sid, userProfile.account_sid,
userProfile.name, userProfile.email, userProfile.email_activation_code, passwordHash);
debug('added local user');
}
else {
await addOauthUser(logger, userProfile.user_sid, userProfile.account_sid,
userProfile.name, userProfile.email, userProfile.provider,
userProfile.provider_userid);
}
Object.assign(userProfile, {
pristine: true,
is_active: req.body.provider !== 'local',
email_validated: userProfile.provider !== 'local',
phone_validated: false,
tutorial_completion: 0,
scope: 'read-write'
});
}
else if (user_sid) {
/* add a new user for existing account */
userProfile.user_sid = uuid();
userProfile.account_sid = account_sid;
/* changing auth mechanism, add user for existing account */
logger.debug(`register - creating new user for existing account ${account_sid}`);
if (req.body.provider === 'local') {
/* hash password */
const passwordHash = await generateHashedPassword(req.body.password);
await addLocalUser(logger, userProfile.user_sid, userProfile.account_sid,
userProfile.name, userProfile.email, userProfile.email_activation_code,
passwordHash);
/* note: we deactivate the old user once the new email is validated */
}
else {
await addOauthUser(logger, userProfile.user_sid, userProfile.account_sid,
userProfile.name, userProfile.email, userProfile.provider,
userProfile.provider_userid);
/* deactivate the old/replaced user */
const [r] = await promisePool.execute('DELETE FROM users WHERE user_sid = ?', [user_sid]);
logger.debug({r}, 'register - removed old user');
}
}
// generate a json web token for this user
const token = jwt.sign({
user_sid: userProfile.user_sid,
account_sid: userProfile.account_sid,
email: userProfile.email,
name: userProfile.name
}, process.env.JWT_SECRET, { expiresIn: '1h' });
logger.debug({
user_sid: userProfile.user_sid,
account_sid: userProfile.account_sid
}, 'generated jwt');
res.json({jwt: token, ...userProfile});
} catch (err) {
debug(err, 'Error');
sysError(logger, res, err);
}
});
module.exports = router;
+13 -2
View File
@@ -1,7 +1,9 @@
const router = require('express').Router();
const Sbc = require('../../models/sbc');
const decorate = require('./decorate');
const sysError = require('./error');
const sysError = require('../error');
//const {DbErrorBadRequest} = require('../../utils/errors');
//const {promisePool} = require('../../db');
decorate(router, Sbc, ['add', 'delete']);
@@ -9,7 +11,16 @@ decorate(router, Sbc, ['add', 'delete']);
router.get('/', async(req, res) => {
const logger = req.app.locals.logger;
try {
const results = await Sbc.retrieveAll(req.query.service_provider_sid);
const service_provider_sid = req.query.service_provider_sid;
/*
if (req.user.hasAccountAuth) {
const [r] = await promisePool.query('SELECT * from accounts WHERE account_sid = ?', req.user.account_sid);
if (0 === r.length) throw new Error('invalid account_sid');
service_provider_sid = r[0].service_provider_sid;
}
if (!service_provider_sid) throw new DbErrorBadRequest('missing service_provider_sid in query');
*/
const results = await Sbc.retrieveAll(service_provider_sid);
res.status(200).json(results);
} catch (err) {
sysError(logger, res, err);
+47 -1
View File
@@ -2,7 +2,10 @@ const router = require('express').Router();
const {DbErrorUnprocessableRequest} = require('../../utils/errors');
const Webhook = require('../../models/webhook');
const ServiceProvider = require('../../models/service-provider');
const sysError = require('./error');
const Account = require('../../models/account');
const VoipCarrier = require('../../models/voip-carrier');
const {hasServiceProviderPermissions, parseServiceProviderSid} = require('./utils');
const sysError = require('../error');
const decorate = require('./decorate');
const preconditions = {
'delete': noActiveAccounts
@@ -16,6 +19,49 @@ async function noActiveAccounts(req, sid) {
decorate(router, ServiceProvider, ['delete'], preconditions);
router.use('/:sid/SpeechCredentials', hasServiceProviderPermissions, require('./speech-credentials'));
router.use('/:sid/PredefinedCarriers', hasServiceProviderPermissions, require('./add-from-predefined-carrier'));
router.get('/:sid/Accounts', async(req, res) => {
const logger = req.app.locals.logger;
try {
const service_provider_sid = parseServiceProviderSid(req);
const results = await Account.retrieveAll(service_provider_sid);
res.status(200).json(results);
} catch (err) {
sysError(logger, res, err);
}
});
router.get('/:sid/VoipCarriers', async(req, res) => {
const logger = req.app.locals.logger;
try {
const service_provider_sid = parseServiceProviderSid(req);
const results = await VoipCarrier.retrieveAllForSP(service_provider_sid);
res.status(200).json(results);
} catch (err) {
sysError(logger, res, err);
}
});
router.post('/:sid/VoipCarriers', async(req, res) => {
const logger = req.app.locals.logger;
try {
const service_provider_sid = parseServiceProviderSid(req);
const uuid = await VoipCarrier.make({...req.body, service_provider_sid});
res.status(201).json({sid: uuid});
} catch (err) {
sysError(logger, res, err);
}
});
router.get(':sid/Acccounts', async(req, res) => {
const logger = req.app.locals.logger;
try {
const service_provider_sid = parseServiceProviderSid(req);
const results = await Account.retrieveAll(service_provider_sid);
res.status(200).json(results);
} catch (err) {
sysError(logger, res, err);
}
});
/* add */
router.post('/', async(req, res) => {
const logger = req.app.locals.logger;
+85
View File
@@ -0,0 +1,85 @@
const router = require('express').Router();
//const debug = require('debug')('jambonz:api-server');
const {DbErrorBadRequest} = require('../../utils/errors');
const {promisePool} = require('../../db');
const {verifyPassword} = require('../../utils/password-utils');
const jwt = require('jsonwebtoken');
const sysError = require('../error');
const validateRequest = async(req) => {
const {email, password} = req.body || {};
/* check required properties are there */
if (!email || !password) throw new DbErrorBadRequest('missing email or password');
};
router.post('/', async(req, res) => {
const {logger, retrieveKey} = req.app.locals;
const {email, password, link} = req.body;
let user;
try {
if (link) {
const key = `reset-link:${link}`;
const user_sid = await retrieveKey(key);
logger.debug({user_sid}, 'retrieved user from link');
if (!user_sid) {
return res.sendStatus(403);
}
const [r] = await promisePool.query('SELECT * from users WHERE user_sid = ?', user_sid);
if (0 === r.length) return res.sendStatus(404);
user = r[0];
}
else {
validateRequest(req);
const [r] = await promisePool.query(
'SELECT * from users WHERE email = ? AND provider=\'local\' AND email_validated=1', email);
if (0 === r.length) return res.sendStatus(404);
user = r[0];
//debug(`password presented is ${password} and hashed_password in db is ${user.hashed_password}`);
const isCorrect = await verifyPassword(user.hashed_password, password);
if (!isCorrect) return res.sendStatus(403);
}
logger.debug({user}, 'signin: retrieved user');
const [a] = await promisePool.query('SELECT * from accounts WHERE account_sid = ?', user.account_sid);
if (a.length !== 1) throw new Error('database error - account not found for user');
const userProfile = Object.assign({}, {
user_sid: user.user_sid,
name: user.name,
email: user.email,
phone: user.phone,
account_sid: user.account_sid,
force_change: !!user.force_change,
provider: user.provider,
provider_userid: user.provider_userid,
scope: user.scope,
phone_validated: !!user.phone_validated,
email_validated: !!user.email_validated
}, {
is_active: !!a[0].is_active,
tutorial_completion: a[0].tutorial_completion,
pristine: false
});
// generate a json web token for this session
const token = jwt.sign({
user_sid: userProfile.user_sid,
account_sid: userProfile.account_sid
}, process.env.JWT_SECRET, { expiresIn: '1h' });
logger.debug({
user_sid: userProfile.user_sid,
account_sid: userProfile.account_sid
}, 'generated jwt');
res.json({jwt: token, ...userProfile});
} catch (err) {
sysError(logger, res, err);
}
});
module.exports = router;
+47 -2
View File
@@ -1,8 +1,53 @@
const router = require('express').Router();
const SipGateway = require('../../models/sip-gateway');
const {DbErrorBadRequest, DbErrorUnprocessableRequest} = require('../../utils/errors');
const decorate = require('./decorate');
const preconditions = {};
const sysError = require('../error');
decorate(router, SipGateway, ['*'], preconditions);
const validate = async(req, sid) => {
const {lookupCarrierBySid, lookupSipGatewayBySid} = req.app.locals;
let voip_carrier_sid;
if (sid) {
const gateway = await lookupSipGatewayBySid(sid);
if (!gateway) throw new DbErrorBadRequest('invalid sip_gateway_sid');
voip_carrier_sid = gateway.voip_carrier_sid;
}
else {
voip_carrier_sid = req.body.voip_carrier_sid;
if (!voip_carrier_sid) throw new DbErrorBadRequest('missing voip_carrier_sid');
}
if (req.hasAccountAuth) {
const carrier = await lookupCarrierBySid(voip_carrier_sid);
if (!carrier) throw new DbErrorBadRequest('invalid voip_carrier_sid');
if (carrier.account_sid !== req.user.account_sid) {
throw new DbErrorUnprocessableRequest('user can not add gateway for voip_carrier belonging to other account');
}
}
};
const preconditions = {
'add': validate,
'update': validate,
'delete': validate
};
decorate(router, SipGateway, ['add', 'retrieve', 'update', 'delete'], preconditions);
/* list */
router.get('/', async(req, res) => {
const logger = req.app.locals.logger;
const voip_carrier_sid = req.query.voip_carrier_sid;
try {
if (!voip_carrier_sid) {
logger.info('GET /SipGateways missing voip_carrier_sid param');
return res.status(400).json({message: 'missing voip_carrier_sid query param'});
}
const results = await SipGateway.retrieveForVoipCarrier(voip_carrier_sid);
res.status(200).json(results);
} catch (err) {
sysError(logger, res, err);
}
});
module.exports = router;
+67
View File
@@ -0,0 +1,67 @@
const router = require('express').Router();
const {promisePool} = require('../../db');
const {DbErrorBadRequest} = require('../../utils/errors');
const {createDnsRecords, deleteDnsRecords} = require('../../utils/dns-utils');
const uuid = require('uuid').v4;
const sysError = require('../error');
const insertDnsRecords = `INSERT INTO dns_records
(dns_record_sid, account_sid, record_type, record_id)
VALUES `;
router.post('/:sip_realm', async(req, res) => {
const logger = req.app.locals.logger;
const account_sid = req.user.account_sid;
const sip_realm = req.params.sip_realm;
try {
const arr = /(.*)\.(.*\..*)$/.exec(sip_realm);
if (!arr) throw new DbErrorBadRequest(`invalid sip_realm: ${sip_realm}`);
const subdomain = arr[1];
const domain = arr[2];
/* update the account */
const [r] = await promisePool.execute('UPDATE accounts set sip_realm = ? WHERE account_sid = ?',
[sip_realm, account_sid]);
if (r.affectedRows !== 1) throw new Error('failure updating accounts table with sip_realm value');
if (process.env.NODE_ENV !== 'test' || process.env.DME_API_KEY) {
/* update DNS provider */
/* retrieve sbc addresses */
const [sbcs] = await promisePool.query('SELECT ipv4 from sbc_addresses');
if (sbcs.length === 0) throw new Error('no SBC addresses provisioned in the database!');
const ips = sbcs.map((s) => s.ipv4);
/* retrieve existing dns records */
const [old_recs] = await promisePool.query('SELECT record_id from dns_records WHERE account_sid = ?',
account_sid);
if (old_recs.length > 0) {
/* remove existing records from the database and dns provider */
await promisePool.query('DELETE from dns_records WHERE account_sid = ?', account_sid);
const deleted = await deleteDnsRecords(logger, domain, old_recs.map((r) => r.record_id));
if (!deleted) {
logger.error({old_recs, sip_realm, account_sid},
'Failed to remove old dns records when changing sip_realm for account');
}
}
/* add the dns records */
const records = await createDnsRecords(logger, domain, subdomain, ips);
if (!records) throw new Error(`failure updating dns records for ${sip_realm}`);
const values = records.map((r) => {
return `('${uuid()}', '${account_sid}', '${r.type}', ${r.id})`;
}).join(',');
const sql = `${insertDnsRecords}${values};`;
const [result] = await promisePool.execute(sql);
if (result.affectedRows != records.length) throw new Error('failed inserting dns records');
}
res.sendStatus(204);
} catch (err) {
sysError(logger, res, err);
}
});
module.exports = router;
+53
View File
@@ -0,0 +1,53 @@
const router = require('express').Router();
const SmppGateway = require('../../models/smpp_gateway');
const {DbErrorBadRequest, DbErrorUnprocessableRequest} = require('../../utils/errors');
const decorate = require('./decorate');
const sysError = require('../error');
const validate = async(req, sid) => {
const {lookupCarrierBySid, lookupSmppGatewayBySid} = req.app.locals;
let voip_carrier_sid;
if (sid) {
const gateway = await lookupSmppGatewayBySid(sid);
if (!gateway) throw new DbErrorBadRequest('invalid smpp_gateway_sid');
voip_carrier_sid = gateway.voip_carrier_sid;
}
else {
voip_carrier_sid = req.body.voip_carrier_sid;
if (!voip_carrier_sid) throw new DbErrorBadRequest('missing voip_carrier_sid');
}
if (req.hasAccountAuth) {
const carrier = await lookupCarrierBySid(voip_carrier_sid);
if (!carrier) throw new DbErrorBadRequest('invalid voip_carrier_sid');
if (carrier.account_sid !== req.user.account_sid) {
throw new DbErrorUnprocessableRequest('user can not add gateway for voip_carrier belonging to other account');
}
}
};
const preconditions = {
'add': validate,
'update': validate,
'delete': validate
};
decorate(router, SmppGateway, ['add', 'retrieve', 'update', 'delete'], preconditions);
/* list */
router.get('/', async(req, res) => {
const logger = req.app.locals.logger;
const voip_carrier_sid = req.query.voip_carrier_sid;
try {
if (!voip_carrier_sid) {
logger.info('GET /SmppGateways missing voip_carrier_sid param');
return res.status(400).json({message: 'missing voip_carrier_sid query param'});
}
const results = await SmppGateway.retrieveForVoipCarrier(voip_carrier_sid);
res.status(200).json(results);
} catch (err) {
sysError(logger, res, err);
}
});
module.exports = router;
+30
View File
@@ -0,0 +1,30 @@
const router = require('express').Router();
const Smpp = require('../../models/smpp');
const decorate = require('./decorate');
const sysError = require('../error');
//const {DbErrorBadRequest} = require('../../utils/errors');
//const {promisePool} = require('../../db');
decorate(router, Smpp, ['add', 'delete']);
/* list */
router.get('/', async(req, res) => {
const logger = req.app.locals.logger;
try {
const service_provider_sid = req.query.service_provider_sid;
/*
if (req.user.hasAccountAuth) {
const [r] = await promisePool.query('SELECT * from accounts WHERE account_sid = ?', req.user.account_sid);
if (0 === r.length) throw new Error('invalid account_sid');
service_provider_sid = r[0].service_provider_sid;
}
if (!service_provider_sid) throw new DbErrorBadRequest('missing service_provider_sid in query');
*/
const results = await Smpp.retrieveAll(service_provider_sid);
res.status(200).json(results);
} catch (err) {
sysError(logger, res, err);
}
});
module.exports = router;
+1 -1
View File
@@ -2,7 +2,7 @@ const router = require('express').Router();
const request = require('request');
const getProvider = require('../../utils/sms-provider');
const uuidv4 = require('uuid/v4');
const sysError = require('./error');
const sysError = require('../error');
let idx = 0;
+1 -1
View File
@@ -1,6 +1,6 @@
const router = require('express').Router();
const getProvider = require('../../utils/sms-provider');
const sysError = require('./error');
const sysError = require('../error');
router.post('/', async(req, res) => {
const { logger } = req.app.locals;
+248
View File
@@ -0,0 +1,248 @@
const router = require('express').Router();
const SpeechCredential = require('../../models/speech-credential');
const sysError = require('../error');
const {decrypt, encrypt} = require('../../utils/encrypt-decrypt');
const {parseAccountSid, parseServiceProviderSid} = require('./utils');
const {DbErrorUnprocessableRequest, DbErrorBadRequest} = require('../../utils/errors');
const {
testGoogleTts,
testGoogleStt,
testAwsTts,
testAwsStt
} = require('../../utils/speech-utils');
router.post('/', async(req, res) => {
const logger = req.app.locals.logger;
const {use_for_stt, use_for_tts, vendor, service_key, access_key_id, secret_access_key, aws_region} = req.body;
const {account_sid} = req.user;
let service_provider_sid;
if (!account_sid) {
if (!req.user.hasServiceProviderAuth) {
logger.error('POST /SpeechCredentials invalid credentials');
return res.send(403);
}
service_provider_sid = parseServiceProviderSid(req);
}
try {
let encrypted_credential;
if (vendor === 'google') {
let obj;
if (!service_key) throw new DbErrorBadRequest('invalid json key: service_key is required');
try {
obj = JSON.parse(service_key);
if (!obj.client_email || !obj.private_key) {
throw new DbErrorBadRequest('invalid google service account key');
}
}
catch (err) {
throw new DbErrorBadRequest('invalid google service account key - not JSON');
}
encrypted_credential = encrypt(service_key);
}
else if (vendor === 'aws') {
const data = JSON.stringify({
aws_region: aws_region || 'us-east-1',
access_key_id,
secret_access_key
});
encrypted_credential = encrypt(data);
}
else throw new DbErrorBadRequest(`invalid speech vendor ${vendor}`);
const uuid = await SpeechCredential.make({
account_sid,
service_provider_sid,
vendor,
use_for_tts,
use_for_stt,
credential: encrypted_credential
});
res.status(201).json({sid: uuid});
} catch (err) {
sysError(logger, res, err);
}
});
/**
* retrieve all speech credentials for an account
*/
router.get('/', async(req, res) => {
let service_provider_sid;
const account_sid = parseAccountSid(req);
if (!account_sid) service_provider_sid = parseServiceProviderSid(req);
const logger = req.app.locals.logger;
try {
const creds = account_sid ?
await SpeechCredential.retrieveAll(account_sid) :
await SpeechCredential.retrieveAllForSP(service_provider_sid);
res.status(200).json(creds.map((c) => {
const {credential, ...obj} = c;
if ('google' === obj.vendor) {
obj.service_key = decrypt(credential);
}
else if ('aws' === obj.vendor) {
const o = decrypt(credential);
obj.access_key_id = o.access_key_id;
obj.secret_access_key = o.secret_access_key;
}
return obj;
}));
} catch (err) {
sysError(logger, res, err);
}
});
/**
* retrieve a specific speech credential
*/
router.get('/:sid', async(req, res) => {
const sid = req.params.sid;
const logger = req.app.locals.logger;
try {
const cred = await SpeechCredential.retrieve(sid);
if (0 === cred.length) return res.sendStatus(404);
const {credential, ...obj} = cred[0];
if ('google' === obj.vendor) {
obj.service_key = decrypt(credential);
}
else if ('aws' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.access_key_id = o.access_key_id;
obj.secret_access_key = o.secret_access_key;
}
res.status(200).json(obj);
} catch (err) {
sysError(logger, res, err);
}
});
/**
* delete a speech credential
*/
router.delete('/:sid', async(req, res) => {
const sid = req.params.sid;
const logger = req.app.locals.logger;
try {
const count = await SpeechCredential.remove(sid);
if (0 === count) return res.sendStatus(404);
res.sendStatus(204);
} catch (err) {
sysError(logger, res, err);
}
});
/**
* update a speech credential -- we only allow use_for_tts and use_for_stt to be updated
*/
router.put('/:sid', async(req, res) => {
const sid = req.params.sid;
const logger = req.app.locals.logger;
try {
const {use_for_tts, use_for_stt} = req.body;
if (typeof use_for_tts === 'undefined' && typeof use_for_stt === 'undefined') {
throw new DbErrorUnprocessableRequest('use_for_tts and use_for_stt are the only updateable fields');
}
const obj = {};
if (typeof use_for_tts !== 'undefined') {
obj.use_for_tts = use_for_tts;
}
if (typeof use_for_stt !== 'undefined') {
obj.use_for_stt = use_for_stt;
}
const rowsAffected = await SpeechCredential.update(sid, obj);
if (rowsAffected === 0) {
return res.sendStatus(404);
}
res.status(204).end();
} catch (err) {
sysError(logger, res, err);
}
});
/**
* Test a credential
*/
router.get('/:sid/test', async(req, res) => {
const sid = req.params.sid;
const logger = req.app.locals.logger;
try {
const creds = await SpeechCredential.retrieve(sid);
if (!creds || 0 === creds.length) return res.sendStatus(404);
const cred = creds[0];
const credential = JSON.parse(decrypt(cred.credential));
const results = {
tts: {
status: 'not tested'
},
stt: {
status: 'not tested'
}
};
if (cred.vendor === 'google') {
if (!credential.client_email || !credential.private_key) {
throw new DbErrorUnprocessableRequest('uploaded file is not a google service key');
}
if (cred.use_for_tts) {
try {
await testGoogleTts(logger, credential);
results.tts.status = 'ok';
SpeechCredential.ttsTestResult(sid, true);
} catch (err) {
results.tts = {status: 'fail', reason: err.message};
SpeechCredential.ttsTestResult(sid, false);
}
}
if (cred.use_for_stt) {
try {
await testGoogleStt(logger, credential);
results.stt.status = 'ok';
SpeechCredential.sttTestResult(sid, true);
} catch (err) {
results.stt = {status: 'fail', reason: err.message};
SpeechCredential.sttTestResult(sid, false);
}
}
}
else if (cred.vendor === 'aws') {
if (cred.use_for_tts) {
try {
await testAwsTts(logger, {
accessKeyId: credential.access_key_id,
secretAccessKey: credential.secret_access_key,
region: credential.aws_region || process.env.AWS_REGION
});
results.tts.status = 'ok';
SpeechCredential.ttsTestResult(sid, true);
} catch (err) {
results.tts = {status: 'fail', reason: err.message};
SpeechCredential.ttsTestResult(sid, false);
}
}
if (cred.use_for_stt) {
try {
await testAwsStt(logger, {
accessKeyId: credential.access_key_id,
secretAccessKey: credential.secret_access_key,
region: credential.aws_region || process.env.AWS_REGION
});
results.stt.status = 'ok';
SpeechCredential.sttTestResult(sid, true);
} catch (err) {
results.stt = {status: 'fail', reason: err.message};
SpeechCredential.sttTestResult(sid, false);
}
}
}
res.status(200).json(results);
} catch (err) {
sysError(logger, res, err);
}
});
module.exports = router;
+48
View File
@@ -0,0 +1,48 @@
const router = require('express').Router();
const Account = require('../../models/account');
const sysError = require('../error');
/* list */
router.get('/', async(req, res) => {
const {createCustomer} = require('../../utils/stripe-utils');
const logger = req.app.locals.logger;
try {
const {account_sid, email, name} = req.user;
logger.debug({account_sid, email, name}, 'GET /StripeCustomerId');
const results = await Account.retrieve(account_sid);
if (results.length === 0) return res.sendStatus(404);
const account = results[0];
/* is account already provisioned in Stripe ? */
if (account.stripe_customer_id) return res.status(200).json({stripe_customer_id: account.stripe_customer_id});
/* no - provision it now */
const customer = await createCustomer(logger, account_sid, email, name);
account.stripe_customer_id = customer.id;
await Account.updateStripeCustomerId(account_sid, customer.id);
res.status(200).json({stripe_customer_id: customer.id});
} catch (err) {
sysError(logger, res, err);
}
});
/* delete */
router.delete('/', async(req, res) => {
const {deleteCustomer} = require('../../utils/stripe-utils');
const logger = req.app.locals.logger;
const {account_sid} = req.user;
try {
const acc = await Account.retrieve(account_sid);
logger.debug({acc}, 'retrieved account');
if (!acc || 0 === acc.length || !acc[0].stripe_customer_id) return res.sendStatus(404);
const {stripe_customer_id} = acc[0];
logger.info(`deleting stripe customer id ${stripe_customer_id}`);
await deleteCustomer(logger, stripe_customer_id);
await Account.updateStripeCustomerId(account_sid, null);
res.sendStatus(204);
} catch (err) {
sysError(logger, res, err);
}
});
module.exports = router;
+334
View File
@@ -0,0 +1,334 @@
const router = require('express').Router();
const {DbErrorBadRequest} = require('../../utils/errors');
const Account = require('../../models/account');
const {
createCustomer,
retrieveCustomer,
updateCustomer,
createSubscription,
retrieveSubscription,
updateSubscription,
retrieveInvoice,
payOutstandingInvoicesForCustomer,
attachPaymentMethod,
detachPaymentMethod,
retrievePaymentMethod,
retrieveUpcomingInvoice
} = require('../../utils/stripe-utils');
const {setupFreeTrial} = require('./utils');
const sysError = require('../error');
const actions = [
'upgrade-to-paid',
'downgrade-to-free',
'update-payment-method',
'update-quantities'
];
const handleError = async(logger, method, res, err) => {
if ('StatusError' === err.name) {
const text = await err.text();
let details;
if (text) {
details = JSON.parse(text);
logger.info({details}, `${method} failed`);
}
if (402 === err.statusCode && details) {
return res.status(err.statusCode).json(details);
}
return res.sendStatus(err.statusCode);
}
sysError(logger, res, err);
};
/**
* We handle 3 possible outcomes
* - the initial payment was successful
* - there was a card error on the initial payment (i.e. decline)
* - there is a requirement for additional authentication (e.g. SCA)
* see: https://stripe.com/docs/billing/migration/strong-customer-authentication
* @param {*} req
* @param {*} res
* @param {*} subscription
*/
const handleSubscriptionOutcome = async(req, res, subscription) => {
const logger = req.app.locals.logger;
const {account_sid} = subscription.metadata;
const {status, latest_invoice} = subscription;
const {payment_intent} = latest_invoice;
/* success case */
if ('active' == status && 'paid' === latest_invoice.status && 'succeeded' === payment_intent.status) {
await Account.activateSubscription(logger, account_sid, subscription.id, 'upgrade to paid plan');
return res.status(201).json({
status: 'success',
chargedAmount: latest_invoice.amount_paid,
currency: payment_intent.currency,
statementDescriptor: payment_intent.statement_descriptor
});
}
/* card error */
if ('incomplete' == status && 'open' === latest_invoice.status &&
'requires_payment_method' === payment_intent.status) {
return res.status(201).json({
status: 'card error',
subscription: subscription.id,
client_secret: payment_intent.client_secret,
reason: payment_intent.last_payment_error.message
});
}
/* more authentication required */
if ('incomplete' == status && 'open' === latest_invoice.status && 'requires_action' === payment_intent.status) {
return res.status(201).json({
status: 'action required',
subscription: subscription.id,
client_secret: payment_intent.client_secret
});
}
throw new Error(
`handleSubscriptionOutcome unexpected status ${status}:${latest_invoice.status}:${payment_intent.status}`);
};
/**
* Transition from free --> paid
* Create customer in Stripe, if needed
* Set the default payment method
* Create a subscription
* @param {*} req
* @param {*} res
*/
const upgradeToPaidPlan = async(req, res) => {
const logger = req.app.locals.logger;
const {account_sid, name, email} = req.user;
const {payment_method_id, products} = req.body;
const arr = await Account.retrieve(req.user.account_sid);
const account = arr[0];
/* retrieve stripe customer id locally, provision on Stripe if needed */
logger.debug({account}, 'upgradeToPaidPlan retrieved account');
let stripe_customer_id = account.stripe_customer_id;
if (!stripe_customer_id) {
logger.debug('upgradeToPaidPlan provisioning customer');
const customer = await createCustomer(logger, account_sid, email, name);
logger.debug(`upgradeToPaidPlan provisioned customer_id ${customer.id}`);
await Account.updateStripeCustomerId(account_sid, customer.id);
stripe_customer_id = customer.id;
}
/* attach the payment method to the customer and make it their default */
const pm = await attachPaymentMethod(logger, payment_method_id, stripe_customer_id);
const customer = await updateCustomer(logger, stripe_customer_id, {
invoice_settings: {
default_payment_method: req.body.payment_method_id,
}
});
logger.debug({customer}, 'successfully updated customer');
/* create a pending subscription -- will be activated on invoice.paid */
const account_subscription_sid = await Account.provisionPendingSubscription(logger, account_sid, products, pm);
/* create the subscription in Stripe */
const items = products.map((product) => {
return {
price: product.price_id,
quantity: product.quantity,
metadata: {
product_sid: product.product_sid
}
};
});
logger.debug({items}, 'creating subscription');
const subscription = await createSubscription(logger, stripe_customer_id,
{account_sid, account_subscription_sid}, items);
logger.debug({subscription}, 'created subscription');
await handleSubscriptionOutcome(req, res, subscription);
};
const downgradeToFreePlan = async(req, res) => {
const logger = req.app.locals.logger;
const {account_sid} = req.user;
try {
await setupFreeTrial(logger, account_sid);
return res.status(200).json({status: 'success'});
} catch (err) {
handleError(logger, 'downgradeToFreePlan', res, err);
}
};
const updatePaymentMethod = async(req, res) => {
const logger = req.app.locals.logger;
const {account_sid} = req.user;
try {
const {payment_method_id} = req.body;
const arr = await Account.retrieve(req.user.account_sid);
const account = arr[0];
if (!account.stripe_customer_id) {
throw new DbErrorBadRequest(`Account ${account_sid} is not provisioned in Stripe`);
}
const customer = await retrieveCustomer(logger, account.stripe_customer_id);
//logger.debug({customer}, 'retrieved customer');
/* attach the payment method to the customer */
const pm = await attachPaymentMethod(logger, payment_method_id, account.stripe_customer_id);
logger.debug({pm}, 'attached payment method to customer');
/* update last4 etc in our db */
await Account.updatePaymentInfo(logger, account_sid, pm);
/* make it the customer's default payment method */
await updateCustomer(logger, account.stripe_customer_id, {
invoice_settings: {
default_payment_method: req.body.payment_method_id,
}
});
/* detach the customer's old payment method */
const old_pm = customer.default_source || customer.invoice_settings.default_payment_method;
if (old_pm) await detachPaymentMethod(logger, old_pm);
/* if the customer has an unpaid invoice, try to pay it */
const success = await payOutstandingInvoicesForCustomer(logger, account.stripe_customer_id);
res.status(200).json({
status: success ? 'success' : 'failed to pay outstanding invoices'
});
} catch (err) {
handleError(logger, 'updatePaymentMethod', res, err);
}
};
const updateQuantities = async(req, res) => {
/**
* see https://stripe.com/docs/billing/subscriptions/upgrade-downgrade#immediate-payment
* and https://stripe.com/docs/billing/subscriptions/pending-updates
*/
const logger = req.app.locals.logger;
const {account_sid} = req.user;
const {products, dry_run} = req.body;
if (!products || !Array.isArray(products) ||
0 === products.length ||
products.find((p) => !p.price_id || !p.product_sid)) {
logger.info({products}, 'Subscription:updateQuantities invalid products');
return res.sendStatus(400);
}
try {
const account_subscription = await Account.getSubscription(req.user.account_sid);
if (!account_subscription || !account_subscription.stripe_subscription_id) {
logger.info(`Subscription:updateQuantities No active subscription found for account_sid ${account_sid}`);
return res.sendStatus(400);
}
const subscription_id = account_subscription.stripe_subscription_id;
const subscription = await retrieveSubscription(logger, subscription_id);
logger.debug({subscription}, 'retrieved existing subscription');
const pm = await retrievePaymentMethod(logger, account_subscription.stripe_payment_method_id);
logger.debug({pm}, 'retrieved existing payment method');
const items = products.map((product) => {
const existingItem = subscription.items.data.find((i) => i.price.id === product.price_id);
const obj = {
quantity: product.quantity,
};
return Object.assign(obj, existingItem ? {id: existingItem.id} : {price_id: product.price_id});
});
if (dry_run) {
const invoice = await retrieveUpcomingInvoice(logger, subscription.customer, subscription.id, items);
logger.debug({invoice}, 'dry run - upcoming invoice');
const dt = new Date(invoice.next_payment_attempt * 1000);
const sum = (acc, current) => acc + current.amount;
const prorated_cost = invoice.lines.data
.filter((l) => l.proration === true)
.reduce(sum, 0);
const monthly_cost = invoice.lines.data
.filter((l) => l.proration === false)
.reduce(sum, 0);
return res.status(201).json({
currency: invoice.currency,
prorated_cost,
monthly_cost,
next_invoice_date: dt.toDateString()
});
}
/* create a pending subscription */
await Account.provisionPendingSubscription(logger, account_sid, products,
pm, subscription_id);
/* update the subscription in Stripe */
const updated = await updateSubscription(logger, subscription_id, items);
logger.debug({updated}, 'updated subscription');
/* get latest invoice, to see if payment is needed */
const invoice = await retrieveInvoice(logger, updated.latest_invoice);
logger.debug({invoice}, 'latest invoice');
if ('paid' === invoice.status) {
logger.debug('activating pending subscription to new quantities since no invoice outstanding');
await Account.activateSubscription(logger, account_sid, subscription_id, 'selected new capacities');
return res.status(201).json({
status: 'success'
});
}
else {
return res.status(201).json({
status: 'failed',
reason: 'payment required'
});
}
} catch (err) {
handleError(logger, 'updatePaymentMethod', res, err);
}
};
/* create */
router.post('/', async(req, res) => {
const logger = req.app.locals.logger;
try {
const {action, payment_method_id, products} = req.body;
if (!actions.includes(action)) throw new DbErrorBadRequest('invalid or missing action');
if ('update-payment-method' === action && typeof payment_method_id !== 'string') {
throw new DbErrorBadRequest('missing payment_method_id');
}
if ('upgrade-to-paid' === action && (!Array.isArray(products) || 0 === products.length)) {
throw new DbErrorBadRequest('missing products');
}
if ('update-quantities' === action && (!Array.isArray(products) || 0 === products.length)) {
throw new DbErrorBadRequest('missing products');
}
switch (action) {
case 'upgrade-to-paid':
await upgradeToPaidPlan(req, res);
break;
case 'downgrade-to-free':
await downgradeToFreePlan(req, res);
break;
case 'update-payment-method':
await updatePaymentMethod(req, res);
break;
case 'update-quantities':
await updateQuantities(req, res);
break;
}
} catch (err) {
handleError(logger, 'POST /Subscription', res, err);
}
});
/* get */
router.get('/', async(req, res) => {
const logger = req.app.locals.logger;
const {account_sid} = req.user;
try {
const subscription = await Account.getSubscription(account_sid);
if (!subscription || !subscription.stripe_subscription_id) return res.sendStatus(404);
const sub = await retrieveSubscription(logger, subscription.stripe_subscription_id);
res.status(200).json(sub);
} catch (err) {
handleError(logger, 'GET /Subscription', res, err);
}
});
module.exports = router;
+165 -81
View File
@@ -1,95 +1,179 @@
//const assert = require('assert');
//const debug = require('debug')('jambonz:api-server');
const router = require('express').Router();
const crypto = require('crypto');
const {getMysqlConnection} = require('../../db');
const {DbErrorBadRequest} = require('../../utils/errors');
const {generateHashedPassword, verifyPassword} = require('../../utils/password-utils');
const {promisePool} = require('../../db');
const {decrypt} = require('../../utils/encrypt-decrypt');
const sysError = require('../error');
const retrieveMyDetails = `SELECT *
FROM users user
JOIN accounts AS account ON account.account_sid = user.account_sid
LEFT JOIN service_providers as sp ON account.service_provider_sid = sp.service_provider_sid
WHERE user.user_sid = ?`;
const retrieveSql = 'SELECT * from users where user_sid = ?';
const updateSql = 'UPDATE users set hashed_password = ?, salt = ?, force_change = false WHERE user_sid = ?';
const tokenSql = 'SELECT token from api_keys where account_sid IS NULL AND service_provider_sid IS NULL';
const retrieveProducts = `SELECT *
FROM account_products
JOIN products ON account_products.product_sid = products.product_sid
JOIN account_subscriptions ON account_products.account_subscription_sid = account_subscriptions.account_subscription_sid
WHERE account_subscriptions.account_sid = ?
AND account_subscriptions.effective_end_date IS NULL
AND account_subscriptions.pending=0`;
const updateSql = 'UPDATE users set hashed_password = ?, force_change = false WHERE user_sid = ?';
const retrieveStaticIps = 'SELECT * FROM account_static_ips WHERE account_sid = ?';
const genRandomString = (len) => {
return crypto.randomBytes(Math.ceil(len / 2))
.toString('hex') /** convert to hexadecimal format */
.slice(0, len); /** return required number of characters */
};
const validateRequest = async(user_sid, payload) => {
const {old_password, new_password, name, email, email_activation_code} = payload;
const sha512 = function(password, salt) {
const hash = crypto.createHmac('sha512', salt); /** Hashing algorithm sha512 */
hash.update(password);
var value = hash.digest('hex');
return {
salt:salt,
passwordHash:value
};
};
const [r] = await promisePool.query(retrieveSql, user_sid);
if (r.length === 0) return null;
const user = r[0];
const saltHashPassword = (userpassword) => {
var salt = genRandomString(16); /** Gives us salt of length 16 */
return sha512(userpassword, salt);
};
router.put('/:user_sid', (req, res) => {
const logger = req.app.locals.logger;
const {old_password, new_password} = req.body;
if (!old_password || !new_password) {
logger.info('Bad PUT to /Users is missing old_password or new password');
return res.sendStatus(400);
if ((old_password && !new_password) || (new_password && !old_password)) {
throw new DbErrorBadRequest('new_password and old_password both required');
}
if (new_password && name) throw new DbErrorBadRequest('can not change name and password simultaneously');
if (new_password && user.provider !== 'local') {
throw new DbErrorBadRequest('can not change password when using oauth2');
}
getMysqlConnection((err, conn) => {
if (err) {
logger.error({err}, 'Error getting db connection');
return res.sendStatus(500);
}
conn.query(retrieveSql, [req.params.user_sid], (err, results) => {
conn.release();
if (err) {
logger.error({err}, 'Error getting db connection');
return res.sendStatus(500);
}
if (0 === results.length) {
logger.info(`Failed to find user with sid ${req.params.user_sid}`);
return res.sendStatus(404);
}
if ((email && !email_activation_code) || (email_activation_code && !email)) {
throw new DbErrorBadRequest('email and email_activation_code both required');
}
if (!name && !new_password && !email) throw new DbErrorBadRequest('no updates requested');
logger.info({results}, 'successfully retrieved user');
const old_salt = results[0].salt;
const old_hashed_password = results[0].hashed_password;
return user;
};
const {passwordHash} = sha512(old_password, old_salt);
if (old_hashed_password !== passwordHash) return res.sendStatus(403);
router.get('/me', async(req, res) => {
const logger = req.app.locals.logger;
const {user_sid} = req.user;
getMysqlConnection((err, conn) => {
if (err) {
logger.error({err}, 'Error getting db connection');
return res.sendStatus(500);
}
const {salt, passwordHash} = saltHashPassword(new_password);
conn.query(updateSql, [passwordHash, salt, req.params.user_sid], (err, r) => {
conn.release();
if (err) {
logger.error({err}, 'Error getting db connection');
return res.sendStatus(500);
}
if (0 === r.changedRows) {
logger.error('Failed updating database with new password');
return res.sendStatus(500);
}
conn.query(tokenSql, (err, tokenResults) => {
conn.release();
if (err) {
logger.error({err}, 'Error getting db connection');
return res.sendStatus(500);
}
if (0 === tokenResults.length) {
logger.error('Database has no admin token provisioned...run reset_admin_password');
return res.sendStatus(500);
}
res.json({user_sid: results[0].user_sid, token: tokenResults[0].token});
});
});
});
if (!user_sid) return res.sendStatus(403);
try {
const [r] = await promisePool.query({sql: retrieveMyDetails, nestTables: true}, user_sid);
logger.debug(r, 'retrieved user details');
const payload = r[0];
const {user, account, sp} = payload;
['hashed_password', 'salt', 'phone_activation_code', 'email_activation_code', 'account_sid'].forEach((prop) => {
delete user[prop];
});
});
['email_validated', 'phone_validated', 'force_change'].forEach((prop) => user[prop] = !!user[prop]);
['is_active'].forEach((prop) => account[prop] = !!account[prop]);
account.root_domain = sp.root_domain;
delete payload.sp;
/* get api keys */
const [keys] = await promisePool.query('SELECT * from api_keys WHERE account_sid = ?', account.account_sid);
payload.api_keys = keys.map((k) => {
return {
api_key_sid: k.api_key_sid,
//token: k.token.replace(/.(?=.{4,}$)/g, '*'),
token: k.token,
last_used: k.last_used,
created_at: k.created_at
};
});
/* get products */
const [products] = await promisePool.query({sql: retrieveProducts, nestTables: true}, account.account_sid);
if (!products.length || !products[0].account_subscriptions) {
throw new Error('account is missing a subscription');
}
const account_subscription = products[0].account_subscriptions;
payload.subscription = {
status: 'active',
account_subscription_sid: account_subscription.account_subscription_sid,
start_date: account_subscription.effective_start_date,
products: products.map((prd) => {
return {
name: prd.products.name,
units: prd.products.unit_label,
quantity: prd.account_products.quantity
};
})
};
if (account_subscription.pending) {
Object.assign(payload.subscription, {
status: 'suspended',
suspend_reason: account_subscription.pending_reason
});
}
const {
last4,
exp_month,
exp_year,
card_type,
stripe_statement_descriptor
} = account_subscription;
if (last4) {
const real_last4 = decrypt(last4);
Object.assign(payload.subscription, {
last4: real_last4,
exp_month,
exp_year,
card_type,
statement_descriptor: stripe_statement_descriptor
});
}
/* get static ips */
const [static_ips] = await promisePool.query(retrieveStaticIps, account.account_sid);
payload.static_ips = static_ips.map((r) => r.public_ipv4);
logger.debug({payload}, 'returning user details');
res.json(payload);
} catch (err) {
sysError(logger, res, err);
}
});
router.put('/:user_sid', async(req, res) => {
const logger = req.app.locals.logger;
const {user_sid} = req.params;
const {old_password, new_password, name, email, email_activation_code} = req.body;
if (req.user.user_sid && req.user.user_sid !== user_sid) return res.sendStatus(403);
try {
const user = await validateRequest(user_sid, req.body);
if (!user) return res.sendStatus(404);
if (new_password) {
const old_hashed_password = user.hashed_password;
const isCorrect = await verifyPassword(old_hashed_password, old_password);
if (!isCorrect) {
//debug(`PUT /Users/:sid pwd ${old_password} does not match hash ${old_hashed_password}`);
return res.sendStatus(403);
}
const passwordHash = await generateHashedPassword(new_password);
//debug(`updating hashed_password to ${passwordHash}`);
const r = await promisePool.execute(updateSql, [passwordHash, user_sid]);
if (0 === r.changedRows) throw new Error('database update failed');
}
if (name) {
const r = await promisePool.execute('UPDATE users SET name = ? WHERE user_sid = ?', [name, user_sid]);
if (0 === r.changedRows) throw new Error('database update failed');
}
if (email) {
const r = await promisePool.execute(
'UPDATE users SET email = ?, email_activation_code = ?, email_validated = 0 WHERE user_sid = ?',
[email, email_activation_code, user_sid]);
if (0 === r.changedRows) throw new Error('database update failed');
if (process.env.NODE_ENV !== 'test') {
//TODO: send email with activation code
}
}
res.sendStatus(204);
} catch (err) {
sysError(logger, res, err);
}
});
+178
View File
@@ -0,0 +1,178 @@
const uuid = require('uuid').v4;
const Account = require('../../models/account');
const {promisePool} = require('../../db');
const {cancelSubscription, detachPaymentMethod} = require('../../utils/stripe-utils');
const freePlans = require('../../utils/free_plans');
const insertAccountSubscriptionSql = `INSERT INTO account_subscriptions
(account_subscription_sid, account_sid)
values (?, ?)`;
const replaceOldSubscriptionSql = `UPDATE account_subscriptions
SET effective_end_date = CURRENT_TIMESTAMP, change_reason = ?
WHERE account_subscription_sid = ?`;
const setupFreeTrial = async(logger, account_sid, isReturningUser) => {
const sid = uuid();
/* see if we have an existing subscription */
const account_subscription = await Account.getSubscription(account_sid);
const planType = account_subscription || isReturningUser ? 'free' : 'trial';
logger.debug({account_subscription}, `setupFreeTrial: assigning ${account_sid} to ${planType} plan`);
/* create a subscription */
await promisePool.execute(insertAccountSubscriptionSql, [sid, account_sid]);
/* add products to it */
const [products] = await promisePool.query('SELECT * from products');
const name2Product = new Map();
products.forEach((p) => name2Product.set(p.category, p.product_sid));
await Promise.all(freePlans[planType].map((p) => {
const data = {
account_product_sid: uuid(),
account_subscription_sid: sid,
product_sid: name2Product.get(p.category),
quantity: p.quantity
};
return promisePool.query('INSERT INTO account_products SET ?', data);
}));
logger.debug({products}, 'setupFreeTrial: added products');
/* disable the old subscription, if any */
if (account_subscription) {
const {
account_subscription_sid,
stripe_subscription_id,
stripe_payment_method_id
} = account_subscription;
await promisePool.execute(replaceOldSubscriptionSql, [
'downgraded to free plan', account_subscription_sid]);
logger.debug('setupFreeTrial: deactivated previous plan');
const promises = [];
if (stripe_subscription_id) {
logger.debug(`setupFreeTrial: deactivating subscription ${stripe_subscription_id}`);
promises.push(cancelSubscription(logger, stripe_subscription_id));
}
if (stripe_payment_method_id) {
promises.push(detachPaymentMethod(logger, stripe_payment_method_id));
}
if (promises.length) await Promise.all(promises);
}
/* update account.plan */
await promisePool.execute(
'UPDATE accounts SET plan_type = ? WHERE account_sid = ?',
[planType, account_sid]);
};
const createTestCdrs = async(writeCdrs, account_sid) => {
const points = 2000;
const data = [];
const start = new Date(Date.now() - (30 * 24 * 60 * 60 * 1000));
const now = new Date();
const increment = (now.getTime() - start.getTime()) / points;
for (let i = 0 ; i < points; i++) {
const attempted_at = new Date(start.getTime() + (i * increment));
const failed = 0 === i % 5;
data.push({
call_sid: 'b6f48929-8e86-4d62-ae3b-64fb574d91f6',
from: '15083084809',
to: '18882349999',
answered: !failed,
sip_callid: '685cd008-0a66-4974-b37a-bdd6d9a3c4aa@192.168.1.100',
sip_status: 200,
duration: failed ? 0 : 45,
attempted_at: attempted_at.getTime(),
answered_at: attempted_at.getTime() + 3000,
terminated_at: attempted_at.getTime() + 45000,
termination_reason: 'caller hungup',
host: '192.168.1.100',
remote_host: '3.55.24.34',
account_sid,
direction: 0 === i % 2 ? 'inbound' : 'outbound',
trunk: 0 === i % 2 ? 'twilio' : 'user'
});
}
await writeCdrs(data);
};
const createTestAlerts = async(writeAlerts, AlertType, account_sid) => {
const points = 100;
const data = [];
const start = new Date(Date.now() - (30 * 24 * 60 * 60 * 1000));
const now = new Date();
const increment = (now.getTime() - start.getTime()) / points;
for (let i = 0 ; i < points; i++) {
const timestamp = new Date(start.getTime() + (i * increment));
const scenario = i % 5;
switch (scenario) {
case 0:
data.push({timestamp, account_sid,
alert_type: AlertType.WEBHOOK_STATUS_FAILURE, url: 'http://foo.bar', status: 404});
break;
case 1:
data.push({timestamp, account_sid, alert_type: AlertType.WEBHOOK_CONNECTION_FAILURE, url: 'http://foo.bar'});
break;
case 2:
data.push({timestamp, account_sid, alert_type: AlertType.TTS_NOT_PROVISIONED, vendor: 'google'});
break;
case 3:
data.push({timestamp, account_sid, alert_type: AlertType.CARRIER_NOT_PROVISIONED});
break;
case 4:
data.push({timestamp, account_sid, alert_type: AlertType.CALL_LIMIT, count: 50});
break;
default:
break;
}
}
await writeAlerts(data);
};
const parseServiceProviderSid = (req) => {
const arr = /ServiceProviders\/([^\/]*)/.exec(req.originalUrl);
if (arr) return arr[1];
};
const parseAccountSid = (req) => {
const arr = /Accounts\/([^\/]*)/.exec(req.originalUrl);
if (arr) return arr[1];
};
const hasAccountPermissions = (req, res, next) => {
if (req.user.hasScope('admin')) return next();
if (req.user.hasScope('account')) {
const account_sid = parseAccountSid(req);
if (account_sid === req.user.account_sid) return next();
}
res.status(403).json({
status: 'fail',
message: 'insufficient privileges'
});
};
const hasServiceProviderPermissions = (req, res, next) => {
if (req.user.hasScope('admin')) return next();
if (req.user.hasScope('service_provider')) {
const service_provider_sid = parseServiceProviderSid(req);
if (service_provider_sid === req.user.service_provider_sid) return next();
}
res.status(403).json({
status: 'fail',
message: 'insufficient privileges'
});
};
module.exports = {
setupFreeTrial,
createTestCdrs,
createTestAlerts,
parseAccountSid,
parseServiceProviderSid,
hasAccountPermissions,
hasServiceProviderPermissions
};
+71 -11
View File
@@ -1,15 +1,18 @@
const router = require('express').Router();
const {DbErrorBadRequest, DbErrorUnprocessableRequest} = require('../../utils/errors');
const VoipCarrier = require('../../models/voip-carrier');
const {promisePool} = require('../../db');
const decorate = require('./decorate');
const preconditions = {
'add': validate,
'update': validate,
'delete': noActiveAccounts
};
const sysError = require('../error');
async function validate(req) {
const validate = async(req) => {
const {lookupAppBySid, lookupAccountBySid} = req.app.locals;
/* account level user can only act on carriers associated to his/her account */
if (req.user.hasAccountAuth) {
req.body.account_sid = req.user.account_sid;
}
if (req.body.application_sid && !req.body.account_sid) {
throw new DbErrorBadRequest('account_sid missing');
}
@@ -24,14 +27,71 @@ async function validate(req) {
const account = await lookupAccountBySid(req.body.account_sid);
if (!account) throw new DbErrorBadRequest('unknown account_sid');
}
}
};
/* can not delete a voip provider if it has any active phone numbers */
async function noActiveAccounts(req, sid) {
const validateUpdate = async(req, sid) => {
const {lookupCarrierBySid} = req.app.locals;
await validate(req);
if (req.user.hasAccountAuth) {
/* can only update carriers for the user's account */
const carrier = await lookupCarrierBySid(sid);
if (carrier.account_sid != req.user.account_sid) {
throw new DbErrorUnprocessableRequest('carrier belongs to a different user');
}
}
};
const validateDelete = async(req, sid) => {
const {lookupCarrierBySid} = req.app.locals;
if (req.user.hasAccountAuth) {
/* can only update carriers for the user's account */
const carrier = await lookupCarrierBySid(sid);
if (carrier.account_sid != req.user.account_sid) {
throw new DbErrorUnprocessableRequest('carrier belongs to a different user');
}
}
/* can not delete a voip provider if it has any active phone numbers */
const activeAccounts = await VoipCarrier.getForeignKeyReferences('phone_numbers.voip_carrier_sid', sid);
if (activeAccounts > 0) throw new DbErrorUnprocessableRequest('cannot delete voip carrier with active phone numbers');
}
decorate(router, VoipCarrier, ['*'], preconditions);
/* remove all the sip and smpp gateways from the carrier first */
await promisePool.execute('DELETE FROM sip_gateways WHERE voip_carrier_sid = ?', [sid]);
await promisePool.execute('DELETE FROM smpp_gateways WHERE voip_carrier_sid = ?', [sid]);
};
const preconditions = {
'add': validate,
'update': validateUpdate,
'delete': validateDelete
};
decorate(router, VoipCarrier, ['add', 'update', 'delete'], preconditions);
/* list */
router.get('/', async(req, res) => {
const logger = req.app.locals.logger;
try {
const results = await VoipCarrier.retrieveAll(req.user.hasAccountAuth ? req.user.account_sid : null);
res.status(200).json(results);
} catch (err) {
sysError(logger, res, err);
}
});
/* retrieve */
router.get('/:sid', async(req, res) => {
const logger = req.app.locals.logger;
try {
const account_sid = req.user.hasAccountAuth ? req.user.account_sid : null;
const results = await VoipCarrier.retrieve(req.params.sid, account_sid);
if (results.length === 0) return res.status(404).end();
return res.status(200).json(results[0]);
}
catch (err) {
sysError(logger, res, err);
}
});
module.exports = router;
+21
View File
@@ -0,0 +1,21 @@
const router = require('express').Router();
const Webhook = require('../../models/webhook');
const decorate = require('./decorate');
const sysError = require('../error');
decorate(router, Webhook, ['add']);
/* retrieve */
router.get('/:sid', async(req, res) => {
const logger = req.app.locals.logger;
try {
const results = await Webhook.retrieve(req.params.sid);
if (results.length === 0) return res.status(404).end();
return res.status(200).json(results[0]);
}
catch (err) {
sysError(logger, res, err);
}
});
module.exports = router;
+24
View File
@@ -0,0 +1,24 @@
const {DbErrorBadRequest, DbErrorUnprocessableRequest, DbErrorForbidden} = require('../utils/errors');
function sysError(logger, res, err) {
if (err instanceof DbErrorBadRequest) {
logger.info(err, 'invalid client request');
return res.status(400).json({msg: err.message});
}
if (err instanceof DbErrorUnprocessableRequest) {
logger.info(err, 'unprocessable request');
return res.status(422).json({msg: err.message});
}
if (err instanceof DbErrorForbidden) {
logger.info(err, 'forbidden');
return res.status(403).json({msg: err.message});
}
if (err.code === 'ER_DUP_ENTRY') {
logger.info(err, 'duplicate entry on insert');
return res.status(422).json({msg: err.message});
}
logger.error(err, 'Database error');
res.status(500).json({msg: err.message});
}
module.exports = sysError;
+2
View File
@@ -4,10 +4,12 @@ const YAML = require('yamljs');
const path = require('path');
const swaggerDocument = YAML.load(path.resolve(__dirname, '../swagger/swagger.yaml'));
const api = require('./api');
const stripe = require('./stripe');
const routes = express.Router();
routes.use('/v1', api);
routes.use('/stripe', stripe);
routes.use('/swagger', swaggerUi.serve);
routes.get('/swagger', swaggerUi.setup(swaggerDocument));
+5
View File
@@ -0,0 +1,5 @@
const router = require('express').Router();
router.use('/webhook', require('./webhook'));
module.exports = router;
+71
View File
@@ -0,0 +1,71 @@
const router = require('express').Router();
//const debug = require('debug')('jambonz:api-server');
const Account = require('../../models/account');
const {retrieveSubscription} = require('../../utils/stripe-utils');
const stripeFactory = require('stripe');
const express = require('express');
const sysError = require('../error');
/** Invoice events */
const handleInvoicePaymentSucceeded = async(logger, obj) => {
const {subscription} = obj;
logger.debug({obj}, `payment for ${obj.billing_reason} succeeded`);
const sub = await retrieveSubscription(logger, subscription);
if ('active' === sub.status) {
const {account_sid} = sub.metadata;
if (await Account.activateSubscription(logger, account_sid, sub.id,
'subscription_create' === obj.billing_reason ? 'upgrade to paid plan' : 'change plan details')) {
logger.info(`handleInvoicePaymentSucceeded: activated subscription for account ${account_sid}`);
}
}
};
/**
* Two cases:
* (1) A subscription renewal fails. In this case we deactivate subscription
* and the customer is down until they provide payment.
* (2) A customer adds capacity during the month, and the pro-rated amount fails.
* In this case, we leave the new subscription in a pending state
* The customer continues (for the rest of the month at least) at
* previous capacity levels.
*/
const handleInvoicePaymentFailed = async(logger, obj) => {
const {subscription} = obj;
const sub = await retrieveSubscription(logger, subscription);
logger.debug({obj}, `payment for ${obj.billing_reason} failed, subscription status is ${sub.status}`);
const {account_sid} = sub.metadata;
if (await Account.deactivateSubscription(logger, account_sid, 'payment failed')) {
logger.info(`handleInvoicePaymentFailed: deactivated subscription for account ${account_sid}`);
}
};
const handleInvoiceEvents = async(logger, evt) => {
if (evt.type === 'invoice.payment_succeeded') handleInvoicePaymentSucceeded(logger, evt.data.object);
else if (evt.type === 'invoice.payment_failed') handleInvoicePaymentFailed(logger, evt.data.object);
};
router.post('/', express.raw({type: 'application/json'}), async(req, res) => {
const {logger} = req.app.locals;
const sig = req.get('stripe-signature');
let evt;
try {
if (!process.env.STRIPE_WEBHOOK_SECRET) throw new Error('missing webhook secret');
const stripe = stripeFactory(process.env.STRIPE_API_KEY);
evt = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
res.sendStatus(204);
} catch (err) {
sysError(logger, res, err);
}
/* process event */
logger.info(`received webhook: ${evt.type}`);
if (evt.type.startsWith('invoice.')) handleInvoiceEvents(logger, evt);
else {
logger.debug(evt, 'unhandled stripe webook');
}
});
module.exports = router;
+2040 -34
View File
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
if (!process.env.JAMBONES_HOSTING) return;
const bent = require('bent');
const crypto = require('crypto');
const assert = require('assert');
const domains = new Map();
const debug = require('debug')('jambonz:api-server');
const checkAsserts = () => {
assert.ok(process.env.DME_API_KEY, 'missing env DME_API_KEY for dns operations');
assert.ok(process.env.DME_API_SECRET, 'missing env DME_API_SECRET for dns operations');
assert.ok(process.env.DME_BASE_URL, 'missing env DME_BASE_URL for dns operations');
};
const createAuthHeaders = () => {
const now = (new Date()).toUTCString();
const hash = crypto.createHmac('SHA1', process.env.DME_API_SECRET);
hash.update(now);
return {
'x-dnsme-apiKey': process.env.DME_API_KEY,
'x-dnsme-requestDate': now,
'x-dnsme-hmac': hash.digest('hex')
};
};
const getDnsDomainId = async(logger, name) => {
checkAsserts();
const headers = createAuthHeaders();
const get = bent(process.env.DME_BASE_URL, 'GET', 'json', headers);
try {
const result = await get('/dns/managed');
debug(result, 'getDnsDomainId: all domains');
if (Array.isArray(result.data)) {
const domain = result.data.find((o) => o.name === name);
if (domain) return domain.id;
debug(`getDnsDomainId: failed to find domain ${name}`);
}
} catch (err) {
logger.error({err}, 'Error retrieving domains');
}
};
/**
* Add the DNS records for a given subdomain
* We will add an A record and an SRV record for each SBC public IP address
* Note: this assumes we have manually added DNS A records:
* sbc01.root.domain, sbc0.root.domain, etc to dnsmadeeasy
*/
const createDnsRecords = async(logger, domain, name, value, ttl = 3600) => {
checkAsserts();
try {
if (!domains.has(domain)) {
const domainId = await getDnsDomainId(logger, domain);
if (!domainId) return false;
domains.set(domain, domainId);
}
const domainId = domains.get(domain);
value = Array.isArray(value) ? value : [value];
const a_records = value.map((v) => {
return {
type: 'A',
gtdLocation: 'DEFAULT',
name,
value: v,
ttl
};
});
const srv_records = [
{
type: 'SRV',
gtdLocation: 'DEFAULT',
name: `_sip._udp.${name}`,
value: `${name}`,
port: 5060,
priority: 10,
weight: 100,
ttl
}
];
const headers = createAuthHeaders();
const records = [...a_records, ...srv_records];
const post = bent(process.env.DME_BASE_URL, 'POST', 201, 400, headers);
logger.debug({records}, 'Attemting to create dns records');
const res = await post(`/dns/managed/${domainId}/records/createMulti`,
[...a_records, ...srv_records]);
if (201 === res.statusCode) {
const str = await res.text();
return JSON.parse(str);
}
logger.error({res}, 'Error creating records');
} catch (err) {
logger.error({err}, 'Error retrieving domains');
}
};
const deleteDnsRecords = async(logger, domain, recIds) => {
checkAsserts();
const headers = createAuthHeaders();
const del = bent(process.env.DME_BASE_URL, 'DELETE', 200, headers);
try {
if (!domains.has(domain)) {
const domainId = await getDnsDomainId(logger, domain);
if (!domainId) return false;
domains.set(domain, domainId);
}
const domainId = domains.get(domain);
const url = `/dns/managed/${domainId}/records?${recIds.map((r) => `ids=${r}`).join('&')}`;
await del(url);
return true;
} catch (err) {
console.error(err);
logger.error({err}, 'Error deleting records');
}
};
module.exports = {
getDnsDomainId,
createDnsRecords,
deleteDnsRecords
};
+34
View File
@@ -0,0 +1,34 @@
const formData = require('form-data');
const Mailgun = require('mailgun.js');
const mailgun = new Mailgun(formData);
const validateEmail = (email) => {
// eslint-disable-next-line max-len
const re = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return re.test(email);
};
const emailSimpleText = async(logger, to, subject, text) => {
const mg = mailgun.client({
username: 'api',
key: process.env.MAILGUN_API_KEY
});
if (!process.env.MAILGUN_API_KEY) throw new Error('MAILGUN_API_KEY env variable is not defined!');
if (!process.env.MAILGUN_DOMAIN) throw new Error('MAILGUN_DOMAIN env variable is not defined!');
try {
const res = await mg.messages.create(process.env.MAILGUN_DOMAIN, {
from: 'jambonz Support <support@jambonz.org>',
to,
subject,
text
});
logger.debug({res}, 'sent email');
} catch (err) {
logger.info({err}, 'Error sending email');
}
};
module.exports = {
validateEmail,
emailSimpleText
};
+29
View File
@@ -0,0 +1,29 @@
const crypto = require('crypto');
const algorithm = 'aes-256-ctr';
const iv = crypto.randomBytes(16);
const secretKey = crypto.createHash('sha256')
.update(String(process.env.JWT_SECRET))
.digest('base64')
.substr(0, 32);
const encrypt = (text) => {
const cipher = crypto.createCipheriv(algorithm, secretKey, iv);
const encrypted = Buffer.concat([cipher.update(text), cipher.final()]);
const data = {
iv: iv.toString('hex'),
content: encrypted.toString('hex')
};
return JSON.stringify(data);
};
const decrypt = (data) => {
const hash = JSON.parse(data);
const decipher = crypto.createDecipheriv(algorithm, secretKey, Buffer.from(hash.iv, 'hex'));
const decrpyted = Buffer.concat([decipher.update(Buffer.from(hash.content, 'hex')), decipher.final()]);
return decrpyted.toString();
};
module.exports = {
encrypt,
decrypt
};
+8 -1
View File
@@ -16,8 +16,15 @@ class DbErrorUnprocessableRequest extends DbError {
}
}
class DbErrorForbidden extends DbError {
constructor(msg) {
super(msg);
}
}
module.exports = {
DbError,
DbErrorBadRequest,
DbErrorUnprocessableRequest
DbErrorUnprocessableRequest,
DbErrorForbidden
};
+22
View File
@@ -0,0 +1,22 @@
{
"trial": [
{
"category": "voice_call_session",
"quantity": 20
},
{
"category": "device",
"quantity": 0
}
],
"free": [
{
"category": "voice_call_session",
"quantity": 1
},
{
"category": "device",
"quantity": 1
}
]
}
+112
View File
@@ -0,0 +1,112 @@
const assert = require('assert');
const bent = require('bent');
const postJSON = bent('POST', 'json', 200);
const getJSON = bent('GET', 'json', 200);
const {emailSimpleText} = require('./email-utils');
const {DbErrorForbidden} = require('../utils/errors');
const doGithubAuth = async(logger, payload) => {
assert.ok(process.env.GITHUB_CLIENT_SECRET, 'env var GITHUB_CLIENT_SECRET is required');
try {
/* exchange the code for an access token */
const obj = await postJSON('https://github.com/login/oauth/access_token', {
client_id: payload.oauth2_client_id,
client_secret: process.env.GITHUB_CLIENT_SECRET,
code: payload.oauth2_code,
state: payload.oauth2_state,
redirect_uri: payload.oauth2_redirect_uri
});
if (!obj.access_token) {
logger.error({obj}, 'Error retrieving access_token from github');
if (obj.error === 'bad_verification_code') throw new Error('bad verification code');
throw new Error(obj.error || 'error retrieving access_token');
}
logger.debug({obj}, 'got response from github for access_token');
/* use the access token to get basic public info as well as primary email */
const userDetails = await getJSON('https://api.github.com/user', null, {
Authorization: `Bearer ${obj.access_token}`,
Accept: 'application/json',
'User-Agent': 'jambonz 1.0'
});
const emails = await getJSON('https://api.github.com/user/emails', null, {
Authorization: `Bearer ${obj.access_token}`,
Accept: 'application/json',
'User-Agent': 'jambonz 1.0'
});
const primary = emails.find((e) => e.primary);
if (primary) Object.assign(userDetails, {
email: primary.email,
email_validated: primary.validated
});
logger.info({userDetails}, 'retrieved user details from github');
return userDetails;
} catch (err) {
logger.info({err}, 'Error authenticating via github');
throw new DbErrorForbidden(err.message);
}
};
const doGoogleAuth = async(logger, payload) => {
assert.ok(process.env.GOOGLE_OAUTH_CLIENT_SECRET, 'env var GOOGLE_OAUTH_CLIENT_SECRET is required');
try {
/* exchange the code for an access token */
const obj = await postJSON('https://oauth2.googleapis.com/token', {
client_id: payload.oauth2_client_id,
client_secret: process.env.GOOGLE_OAUTH_CLIENT_SECRET,
code: payload.oauth2_code,
state: payload.oauth2_state,
redirect_uri: payload.oauth2_redirect_uri,
grant_type: 'authorization_code'
});
if (!obj.access_token) {
logger.error({obj}, 'Error retrieving access_token from github');
if (obj.error === 'bad_verification_code') throw new Error('bad verification code');
throw new Error(obj.error || 'error retrieving access_token');
}
logger.debug({obj}, 'got response from google for access_token');
/* use the access token to get basic public info as well as primary email */
const userDetails = await getJSON('https://www.googleapis.com/oauth2/v2/userinfo', null, {
Authorization: `Bearer ${obj.access_token}`,
Accept: 'application/json',
'User-Agent': 'jambonz 1.0'
});
logger.info({userDetails}, 'retrieved user details from google');
return userDetails;
} catch (err) {
logger.info({err}, 'Error authenticating via google');
throw new DbErrorForbidden(err.message);
}
};
const doLocalAuth = async(logger, payload) => {
const {name, email, password, email_activation_code} = payload;
const text = `Hi there
Welcome to jambonz! Your account activation code is ${email_activation_code}
Best,
The jambonz team`;
if ('test' !== process.env.NODE_ENV || process.env.MAILGUN_API_KEY) {
await emailSimpleText(logger, email, 'Account activation code', text);
}
return {
name,
email,
password,
email_activation_code
};
};
module.exports = {
doGithubAuth,
doGoogleAuth,
doLocalAuth
};
+24
View File
@@ -0,0 +1,24 @@
const crypto = require('crypto');
const { argon2i } = require('argon2-ffi');
const util = require('util');
const getRandomBytes = util.promisify(crypto.randomBytes);
const generateHashedPassword = async(password) => {
const salt = await getRandomBytes(32);
const passwordHash = await argon2i.hash(password, salt);
return passwordHash;
};
const verifyPassword = async(passwordHash, password) => {
const isCorrect = await argon2i.verify(passwordHash, password);
return isCorrect;
};
const hashString = (s) => crypto.createHash('md5').update(s).digest('hex');
module.exports = {
generateHashedPassword,
verifyPassword,
hashString
};
+22
View File
@@ -0,0 +1,22 @@
//const PNF = require('google-libphonenumber').PhoneNumberFormat;
//const phoneUtil = require('google-libphonenumber').PhoneNumberUtil.getInstance();
const validateNumber = (number) => {
if (typeof number !== 'string') throw new Error('phone number must be a string');
if (!/^\d+$/.test(number)) throw new Error('phone number must only include digits');
};
const e164 = (number) => {
if (number.startsWith('+')) return number.slice(1);
return number;
/*
const num = phoneUtil.parseAndKeepRawInput(number, 'US');
if (!phoneUtil.isValidNumber(num)) throw new Error(`not a valid US telephone number: ${number}`);
return phoneUtil.format(num, PNF.E164).slice(1);
*/
};
module.exports = {
validateNumber,
e164
};
+60
View File
@@ -0,0 +1,60 @@
const ttsGoogle = require('@google-cloud/text-to-speech');
const sttGoogle = require('@google-cloud/speech').v1p1beta1;
const Polly = require('aws-sdk/clients/polly');
const AWS = require('aws-sdk');
const fs = require('fs');
const testGoogleTts = async(logger, credentials) => {
const client = new ttsGoogle.TextToSpeechClient({credentials});
await client.listVoices();
};
const testGoogleStt = async(logger, credentials) => {
const client = new sttGoogle.SpeechClient({credentials});
const config = {
sampleRateHertz: 8000,
languageCode: 'en-US',
model: 'default',
};
const audio = {
content: fs.readFileSync(`${__dirname}/../../data/test_audio.wav`).toString('base64'),
};
const request = {
config: config,
audio: audio,
};
// Detects speech in the audio file
const [response] = await client.recognize(request);
if (!Array.isArray(response.results) || 0 === response.results.length) {
throw new Error('failed to transcribe speech');
}
};
const testAwsTts = (logger, credentials) => {
const polly = new Polly(credentials);
return new Promise((resolve, reject) => {
polly.describeVoices({LanguageCode: 'en-US'}, (err, data) => {
if (err) return reject(err);
resolve();
});
});
};
const testAwsStt = (logger, credentials) => {
const transcribeservice = new AWS.TranscribeService(credentials);
return new Promise((resolve, reject) => {
transcribeservice.listVocabularies((err, data) => {
if (err) return reject(err);
logger.info({data}, 'retrieved language models');
resolve();
});
});
};
module.exports = {
testGoogleTts,
testGoogleStt,
testAwsTts,
testAwsStt
};
+224
View File
@@ -0,0 +1,224 @@
if (!process.env.JAMBONES_HOSTING) return;
const assert = require('assert');
assert.ok(process.env.STRIPE_API_KEY || process.env.NODE_ENV === 'test',
'missing env STRIPE_API_KEY for billing operations');
assert.ok(process.env.STRIPE_BASE_URL || process.env.NODE_ENV === 'test',
'missing env STRIPE_BASE_URL for billing operations');
const bent = require('bent');
const formurlencoded = require('form-urlencoded').default;
const qs = require('qs');
const toBase64 = (str) => Buffer.from(str || '', 'utf8').toString('base64');
const basicAuth = () => {
const header = `Basic ${toBase64(process.env.STRIPE_API_KEY)}`;
return {Authorization: header};
};
const postForm = bent(process.env.STRIPE_BASE_URL || 'http://127.0.0.1', 'POST', 'string',
Object.assign({'Content-Type': 'application/x-www-form-urlencoded'}, basicAuth()), 200);
const getJSON = bent(process.env.STRIPE_BASE_URL || 'http://127.0.0.1', 'GET', 'json', basicAuth(), 200);
const deleteJSON = bent(process.env.STRIPE_BASE_URL || 'http://127.0.0.1', 'DELETE', 'json', basicAuth(), 200);
//const debug = require('debug')('jambonz:api-server');
const listProducts = async(logger) => await getJSON('/products?active=true');
const listPrices = async(logger) => await getJSON('/prices?active=true&expand[]=data.tiers&expand[]=data.product');
const retrievePricesForProduct = async(logger, id) =>
await getJSON(`/prices?product=${id}&active=true&expand[]=data.tiers&expand[]=data.product`);
const retrieveProduct = async(logger, id) =>
await getJSON(`/products/${id}`);
const retrieveCustomer = async(logger, id) =>
await getJSON(`/customers/${id}`);
const retrieveUpcomingInvoice = async(logger, customer_id, subscription_id, items) => {
const params = Object.assign(
{customer: customer_id},
subscription_id ? {subscription: subscription_id} : {},
items ? {subscription_items: items} : {});
const queryString = qs.stringify(params, {encode: false});
logger.debug({params, qs}, 'retrieving upcoming invoice');
return await getJSON(`/invoices/upcoming?${queryString}`);
};
const retrieveInvoice = async(logger, id) =>
await getJSON(`/invoices/${id}`);
const createCustomer = async(logger, account_sid, email, name) => {
const obj = {
email,
metadata: {account_sid}
};
if (name) obj.name = name;
logger.debug({obj}, 'provisioning customer');
const result = await postForm('/customers', formurlencoded(obj));
return JSON.parse(result);
};
const updateCustomer = async(logger, id, obj) => {
logger.debug({obj}, `updating customer ${id}`);
const result = await postForm(`/customers/${id}`, formurlencoded(obj));
return JSON.parse(result);
};
const deleteCustomer = async(logger, id) =>
await deleteJSON(`/customers/${id}`);
const attachPaymentMethod = async(logger, payment_method_id, customer_id) => {
const obj = {
customer: customer_id
};
const result = await postForm(`/payment_methods/${payment_method_id}/attach`,
formurlencoded(obj));
return JSON.parse(result);
};
const detachPaymentMethod = async(logger, payment_method_id) => {
const result = await postForm(`/payment_methods/${payment_method_id}/detach`);
return JSON.parse(result);
};
const createSubscription = async(logger, customer, metadata, items) => {
assert.ok(Array.isArray(items) && items.length > 0);
const obj = {
customer,
metadata,
items
};
const result = await postForm('/subscriptions?expand[]=latest_invoice&expand[]=latest_invoice.payment_intent',
formurlencoded(obj));
return JSON.parse(result);
};
/*
const deleteInvoiceItem = async(logger, id) => {
return JSON.parse(await deleteJSON(`/invoiceitems/${id}`));
};
*/
const updateSubscription = async(logger, id, items) => {
assert.ok(Array.isArray(items) && items.length > 0);
const obj = {
proration_behavior: 'always_invoice',
payment_behavior: 'pending_if_incomplete',
items
};
const result = await postForm(`/subscriptions/${id}`,
formurlencoded(obj));
return JSON.parse(result);
};
const payInvoice = async(logger, id) => {
const result = await postForm(`/invoices/${id}/pay`);
return JSON.parse(result);
};
const payOutstandingInvoicesForCustomer = async(logger, customer_id) => {
let success = true;
const customer = await retrieveCustomer(logger, customer_id);
const {subscriptions} = customer;
logger.debug({subscriptions}, 'payOutstandingInvoicesForCustomer - subscriptions');
if (subscriptions && subscriptions.data.length > 0) {
const promises = subscriptions.data
.filter((s) => ['incomplete', 'past_due'].includes(s.status) || s.pending_update)
.map((s) => payInvoice(logger, s.latest_invoice));
const invoices = await Promise.all(promises);
if (invoices.find((i) => 'paid' !== i.status)) {
success = false;
}
}
return success;
};
const retrieveSubscription = async(logger, id) =>
await getJSON(`/subscriptions/${id}?expand[]=latest_invoice`);
const cancelSubscription = async(logger, id) =>
await deleteJSON(`/subscriptions/${id}`);
const retrievePaymentMethod = async(logger, id) => await getJSON(`/payment_methods/${id}`);
const calculateInvoiceAmount = async(logger, products) => {
assert.ok(Array.isArray(products) && products.length, 'calculateInvoiceAmount: products must be array');
assert.ok(!products.find((p) => !p.priceId || !p.quantity), 'calculateInvoiceAmount: invalid products array');
const prices = await Promise.all(products.map((p) => {
return getJSON(`/prices/${p.priceId}?expand[]=tiers`);
}));
logger.debug({prices, products}, 'calculateInvoiceAmount retrieved prices');
const total = prices.reduce((acc, pr) => {
const product = products.find((product) => product.priceId === pr.id);
logger.debug({product}, 'calculating price for line item');
if (pr.billing_scheme === 'per_unit') {
const lineItemCost = pr.unit_amount * product.quantity;
logger.debug(`per-unit pricing: ${product.quantity} * ${pr.unit_amount} = ${lineItemCost} usd`);
return acc + lineItemCost;
}
else if (pr.billing_scheme === 'tiered') {
const tier = pr.tiers.find((t) => product.quantity <= t.up_to || t.up_to === null);
if (typeof tier.flat_amount === 'number') {
const lineItemCost = tier.flat_amount;
logger.debug({tier}, `tiered pricing, flat amount: ${product.quantity} = ${lineItemCost} usd`);
return acc + lineItemCost;
}
else {
const lineItemCost = tier.unit_amount * product.quantity;
logger.debug({tier},
`tiered pricing, per-unit based: ${product.quantity} * ${tier.unit_amount} = ${lineItemCost} usd`);
return acc + (tier.unit_amount * product.quantity);
}
}
else {
// TODO: handle volume pricing
assert(false, `calculateInvoiceAmount: billing_scheme ${pr.billing_scheme} not implemented!!`);
}
}, 0);
logger.debug(`calculateInvoiceAmount total cost ${total}`);
return {amount: total, currency: prices[0].currency};
};
const createPaymentIntent = async(logger,
{account_sid, stripe_customer_id, amount, email, currency, stripe_payment_method_id}) => {
const obj = {
amount,
currency,
customer: stripe_customer_id,
payment_method: stripe_payment_method_id,
receipt_email: email,
metadata: {
account_sid
},
setup_future_usage: 'off_session'
};
const result = await postForm('/payment_intents', formurlencoded(obj));
return JSON.parse(result);
};
module.exports = {
listProducts,
listPrices,
createCustomer,
retrieveCustomer,
updateCustomer,
deleteCustomer,
createSubscription,
retrieveSubscription,
cancelSubscription,
updateSubscription,
retrievePaymentMethod,
calculateInvoiceAmount,
createPaymentIntent,
attachPaymentMethod,
detachPaymentMethod,
retrieveUpcomingInvoice,
payOutstandingInvoicesForCustomer,
retrieveInvoice,
retrieveProduct,
retrievePricesForProduct
};