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
+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
};