Compare commits

...

44 Commits

Author SHA1 Message Date
Sam Machin
fcff3d4b32 add proxy detail from registered client (#458) 2025-06-02 08:08:48 -04:00
Hoan Luu Huu
2dd06df641 Fix Application Model Pagination Issue with LEFT JOINs (#461) 2025-06-01 08:31:27 -04:00
Hoan Luu Huu
579a586a03 fixed filter for carriers for an account (#460)
* fixed filter for carriers for an account

* wip

* wip
2025-05-30 07:24:55 -04:00
Hoan Luu Huu
3e1b383284 fix microsoft fetch list voice from hardcode westus region (#459) 2025-05-29 10:07:58 -04:00
Hoan Luu Huu
c51b7bab82 admin can create call on behalf of account (#446)
* admin and service provider can create call on behalf of account

* wip

* admin and service provider can create call on behalf of account

* wip

* wip

* wip
2025-05-28 10:22:52 -04:00
Hoan Luu Huu
bb5dba7c20 support fetch tts/stt deepgram models from rest api (#457) 2025-05-28 09:59:02 -04:00
Hoan Luu Huu
c7e279d0ee support S3 compatible region (#453)
* support S3 compatible region

* wip
2025-05-28 08:04:15 -04:00
Hoan Luu Huu
6700ff35be support fetching application with pagination (#450)
* support fetching application with pagination

* pagination for voip carrier

* wip

* wip

* wip

* support phone number pagination

* wip

* wip

* wip
2025-05-28 07:28:48 -04:00
Sam Machin
3f2a304830 add rate limit by real ip or apikey (#455) 2025-05-23 12:36:35 -04:00
Hoan Luu Huu
f23c4fbd48 forward updateCall error response from feature server to client (#454)
* forward updateCall error response from feature server to client

* wip

* wip

* update review comment
2025-05-23 06:27:12 -04:00
Hoan Luu Huu
0c2f5becdc fixed updateCall cannot response 202 Accepted (#451) 2025-05-21 08:13:04 -04:00
Hoan Luu Huu
cd6772c10f wip (#449) 2025-05-19 09:51:06 -04:00
Dave Horton
b708f7beb6 update version 2025-05-14 15:39:38 -04:00
Hoan Luu Huu
431cc9e4f4 support filter phone numbers (#447) 2025-05-14 07:54:48 -04:00
Vasudev Anubrolu
35b10d55d5 feat/864 speech utils version up (#445) 2025-05-13 11:02:34 -04:00
Dave Horton
533e202474 update db-helpers 2025-05-13 10:35:00 -04:00
Dave Horton
e506fc8b66 fix bug updating application with env vars (#440) 2025-05-13 10:15:55 -04:00
Hoan Luu Huu
76a2054745 update speech-utils and verb specification (#444) 2025-05-13 09:44:21 -04:00
Hoan Luu Huu
be300ebd51 fixed save obscurbed azure connection_string for bucket credential (#443)
* fixed save obscurbed azure connection_string for bucket credential

* wip
2025-05-09 07:46:59 -04:00
Hoan Luu Huu
27c3664391 fixed API server crash when admin query voip-carrier (#442) 2025-05-09 07:42:24 -04:00
Dave Horton
48e39f37d3 when app url is websocket we still need to send to http schema not ws (#439)
* when app url is websocket we still need to send to http schema not ws

* fix bug from a previous PR
2025-05-08 13:41:36 -04:00
Vasudev Anubrolu
2b4b3056e9 feat/864 update speech utils version (#438) 2025-05-08 12:47:52 -04:00
Vasudev Anubrolu
3cad5219b4 feat/864 playht on prem (#432)
* feat/864 playht on prem

* feat/864 update speech utils version

---------

Co-authored-by: vasudevan-Kore <vasudev.anubrolu@kore.com>
2025-05-08 10:00:07 -04:00
Sam Machin
30a799030c Feat/app env vars (#430)
* initial changes for env var support

* WIP

* Update applications.js

* JSON stringify before encrypting

* use call_hook.url

* env vars working

GET /v1/AppEnv?url=[URL] to trigger options request to URL and return app-schema
POST /v1/Applications with {env_vars: [OBJECT} to create app with env vars
PUT /v1/Applications/[SID] with {env_vars: [OBJECT} to change env vars
GET returns env vars

POST and PUT will also trigger an OPTIONS request to the call_hook url to get schema and then validate the env_vars against it

* update appenv cannot finish request.

* wip

* wip

* wip

* wip

---------

Co-authored-by: Dave Horton <daveh@beachdognet.com>
Co-authored-by: Quan HL <quan.luuhoang8@gmail.com>
Co-authored-by: Hoan Luu Huu <110280845+xquanluu@users.noreply.github.com>
2025-05-08 08:41:50 -04:00
Hoan Luu Huu
e41caf8887 Fixed download pcap and jaeger trace when using fetch lib (#436)
* fixed cannot download pcap file

* wip
2025-05-07 20:08:06 -04:00
Anton Voylenko
217c11a5e1 fix: missing token update and missing return (#435) 2025-05-07 14:14:40 -04:00
Hoan Luu Huu
561de0532f support rime Arcana (#434) 2025-05-06 08:49:32 -04:00
Sam Machin
ce2ea8bd62 validate call hook urls (#431) 2025-05-01 07:22:01 -04:00
Hoan Luu Huu
c21f5b871f add new voice for nvidia tts (#428)
* add new voice for nvidia tts

* update nvidia tts voices
2025-04-29 09:33:05 -04:00
Sam Machin
9a2e48b538 update AWS transcribe languages (#427)
Using info from https://docs.aws.amazon.com/transcribe/latest/dg/supported-languages.html
2025-04-28 09:11:47 -04:00
Sam Machin
29adbfc6ae Add Aura-2 Voices (#426) 2025-04-28 07:26:15 -04:00
Hoan Luu Huu
ffda2398f4 replace bent by native node fetch (#401)
* replace bent by native node fetch

* wip

* wip

* wip
2025-04-24 06:50:15 -04:00
Sam Machin
b05b32d73e Update speech-utils.js (#425) 2025-04-23 10:29:19 -04:00
Hoan Luu Huu
b8bf18f8ca update speech utils version 0.2.6 (#424) 2025-04-23 08:23:19 -04:00
Hoan Luu Huu
1e532212f9 support whisper tts model (#405) 2025-04-22 09:47:13 -04:00
Hoan Luu Huu
92347c26bf fix speechmatic cannot update stt url (#410) 2025-04-22 09:35:02 -04:00
Sam Machin
bc51b60e9b fetch voip carriers where api key doesn't have the service_provdier_sid availible (#412) 2025-04-22 09:34:36 -04:00
Sam Machin
f0ec0a916f Update sip-gateways.js (#421) 2025-04-22 09:30:55 -04:00
rammohan-y
c94f14f27d Added new model Playht Dialog-1-0 model (#423)
https://github.com/jambonz/jambonz-api-server/issues/419
2025-04-22 09:30:18 -04:00
Sam Machin
06873186ac fix the creds test and return correct error (#420) 2025-04-22 07:38:54 -04:00
rammohan-y
956da4334f Support ssl connection to mysql (#414)
* Support ssl connection to mysql
https://github.com/jambonz/jambonz-api-server/issues/413

* using 0/1 for rejectUnauthorized

* added validations for JAMBONES_MYSQL_REJECT_UNAUTHORIZED

* simplied assignment of opts.ssl

* Updated db-helpers to 0.9.11

https://github.com/jambonz/jambonz-feature-server/issues/1151
2025-04-14 08:21:55 -04:00
Hoan Luu Huu
c144758d44 fix: reject if user.provider !== local when login (#408) 2025-04-09 09:53:24 -04:00
rammohan-y
e24f3472ae user should be able to change the temprary password (#407)
Refer to https://github.com/jambonz/jambonz-api-server/issues/406
2025-04-09 09:32:15 -04:00
rammohan-y
4c935c7fda Feat/371 view only user implementation using user_permissions (#381)
* https://github.com/jambonz/jambonz-api-server/issues/371

Implemented view_only permission feature

* calling prepare-permissions in create-test-db.js

* check if there is only 1 permission and if it is VIEW_ONLY then consider user as read-only user

* setting is_view_only flag for view user by userid
2025-04-01 09:29:06 -04:00
78 changed files with 5923 additions and 5406 deletions

30
app.js
View File

@@ -7,6 +7,7 @@ const nocache = require('nocache');
const rateLimit = require('express-rate-limit');
const cors = require('cors');
const passport = require('passport');
const {verifyViewOnlyUser} = require('./lib/middleware');
const routes = require('./lib/routes');
const Registrar = require('@jambonz/mw-registrar');
@@ -127,11 +128,27 @@ const unless = (paths, middleware) => {
};
};
const RATE_LIMIT_BY = process.env.RATE_LIMIT_BY || 'system';
const limiter = rateLimit({
windowMs: (process.env.RATE_LIMIT_WINDOWS_MINS || 5) * 60 * 1000, // 5 minutes
max: process.env.RATE_LIMIT_MAX_PER_WINDOW || 600, // Limit each IP to 600 requests per `window`
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
keyGenerator: (req, res) => {
switch (RATE_LIMIT_BY) {
case 'system':
return '127.0.0.1';
case 'apikey':
// uses shared limit for requests without an authorization header
const token = req.headers.authorization?.split(' ')[1] || '127.0.0.1';
return token;
case 'ip':
return req.headers['x-real-ip'];
default:
return '127.0.0.1';
}
}
});
// Setup websocket for recording audio
@@ -172,6 +189,19 @@ app.use('/v1', unless(
'/InviteCodes',
'/PredefinedCarriers'
], passport.authenticate('bearer', {session: false})));
app.use('/v1', unless(
[
'/register',
'/forgot-password',
'/signin',
'/login',
'/messaging',
'/outboundSMS',
'/AccountTest',
'/InviteCodes',
'/PredefinedCarriers',
'/logout'
], verifyViewOnlyUser));
app.use('/', routes);
app.use((err, req, res, next) => {
logger.error(err, 'burped error');

View File

@@ -516,6 +516,7 @@ fallback_speech_synthesis_label VARCHAR(64),
fallback_speech_recognizer_vendor VARCHAR(64),
fallback_speech_recognizer_language VARCHAR(64),
fallback_speech_recognizer_label VARCHAR(64),
env_vars TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
record_all_calls BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (application_sid)
@@ -743,4 +744,4 @@ ALTER TABLE accounts ADD FOREIGN KEY device_calling_application_sid_idxfk (devic
ALTER TABLE accounts ADD FOREIGN KEY siprec_hook_sid_idxfk (siprec_hook_sid) REFERENCES applications (application_sid);
SET FOREIGN_KEY_CHECKS=1;
SET FOREIGN_KEY_CHECKS=1;

View File

@@ -2492,7 +2492,7 @@
</location>
<size>
<width>345.00</width>
<height>540.00</height>
<height>560.00</height>
</size>
<zorder>0</zorder>
<SQLField>
@@ -2685,6 +2685,11 @@
<notNull><![CDATA[0]]></notNull>
<uid><![CDATA[65AA5173-6523-49F7-9D95-78C4B3A7C7E6]]></uid>
</SQLField>
<SQLField>
<name><![CDATA[env_vars]]></name>
<type><![CDATA[TEXT]]></type>
<uid><![CDATA[C22DCA56-385D-45EE-A36F-2B9C6167AFAA]]></uid>
</SQLField>
<SQLField>
<name><![CDATA[created_at]]></name>
<type><![CDATA[DATETIME]]></type>
@@ -3163,9 +3168,9 @@
<SQLEditorFileFormatVersion><![CDATA[4]]></SQLEditorFileFormatVersion>
<uid><![CDATA[58C99A00-06C9-478C-A667-C63842E088F3]]></uid>
<windowHeight><![CDATA[1055.000000]]></windowHeight>
<windowLocationX><![CDATA[58.000000]]></windowLocationX>
<windowLocationY><![CDATA[24.000000]]></windowLocationY>
<windowScrollOrigin><![CDATA[{0, 278}]]></windowScrollOrigin>
<windowLocationX><![CDATA[1845.000000]]></windowLocationX>
<windowLocationY><![CDATA[37.000000]]></windowLocationY>
<windowScrollOrigin><![CDATA[{0, 544}]]></windowScrollOrigin>
<windowWidth><![CDATA[1670.000000]]></windowWidth>
</SQLDocumentInfo>
<AllowsIndexRenamingOnInsert><![CDATA[1]]></AllowsIndexRenamingOnInsert>

View File

@@ -0,0 +1,11 @@
/* remove VIEW_ONLY permission for admin user as it will prevent write operations*/
delete from user_permissions;
delete from permissions;
insert into permissions (permission_sid, name, description)
values
('ffbc342a-546a-11ed-bdc3-0242ac120002', 'VIEW_ONLY', 'Can view data but not make changes'),
('ffbc3a10-546a-11ed-bdc3-0242ac120002', 'PROVISION_SERVICES', 'Can provision services'),
('ffbc3c5e-546a-11ed-bdc3-0242ac120002', 'PROVISION_USERS', 'Can provision users');

View File

@@ -6,6 +6,7 @@ const {readFile} = require('fs/promises');
const {execSync} = require('child_process');
const {version:desiredVersion} = require('../package.json');
const logger = require('pino')();
const fs = require('fs');
logger.info(`upgrade-jambonz-db: desired version ${desiredVersion}`);
@@ -22,6 +23,20 @@ const opts = {
port: process.env.JAMBONES_MYSQL_PORT || 3306,
multipleStatements: true
};
const rejectUnauthorized = process.env.JAMBONES_MYSQL_REJECT_UNAUTHORIZED;
const ssl_ca_file = process.env.JAMBONES_MYSQL_SSL_CA_FILE;
const ssl_cert_file = process.env.JAMBONES_MYSQL_SSL_CERT_FILE;
const ssl_key_file = process.env.JAMBONES_MYSQL_SSL_KEY_FILE;
const sslFilesProvided = Boolean(ssl_ca_file && ssl_cert_file && ssl_key_file);
if (rejectUnauthorized !== undefined || sslFilesProvided) {
opts.ssl = {
...(rejectUnauthorized !== undefined && { rejectUnauthorized: rejectUnauthorized === '0' ? false : true }),
...(ssl_ca_file && { ca: fs.readFileSync(ssl_ca_file) }),
...(ssl_cert_file && { cert: fs.readFileSync(ssl_cert_file) }),
...(ssl_key_file && { key: fs.readFileSync(ssl_key_file) })
};
}
const sql = {
'7006': [
@@ -210,6 +225,9 @@ const sql = {
'ALTER TABLE google_custom_voices ADD COLUMN use_voice_cloning_key BOOLEAN DEFAULT false',
'ALTER TABLE voip_carriers ADD COLUMN dtmf_type ENUM(\'rfc2833\',\'tones\',\'info\') NOT NULL DEFAULT \'rfc2833\'',
'ALTER TABLE voip_carriers ADD COLUMN outbound_sip_proxy VARCHAR(255)',
],
9004: [
'ALTER TABLE applications ADD COLUMN env_vars TEXT',
]
};

View File

@@ -35,8 +35,8 @@ function makeStrategy(logger) {
debug(err);
logger.info({err}, 'Error checking redis for jwt');
}
const { user_sid, service_provider_sid, account_sid, email, name, scope, permissions } = decoded;
const { user_sid, service_provider_sid, account_sid, email,
name, scope, permissions, is_view_only } = decoded;
const user = {
service_provider_sid,
account_sid,
@@ -45,6 +45,7 @@ function makeStrategy(logger) {
email,
name,
permissions,
is_view_only,
hasScope: (s) => s === scope,
hasAdminAuth: scope === 'admin',
hasServiceProviderAuth: scope === 'service_provider',

View File

@@ -1,4 +1,5 @@
const logger = require('./logger');
const {UserPermissionError} = require('./utils/errors');
function delayLoginMiddleware(req, res, next) {
if (req.path.includes('/login') || req.path.includes('/signin')) {
@@ -27,6 +28,26 @@ function delayLoginMiddleware(req, res, next) {
next();
}
function verifyViewOnlyUser(req, res, next) {
// Skip check for GET requests
if (req.method === 'GET') {
return next();
}
// current user is changing their password which shuould be allowed
if (req.body?.old_password && req.body?.new_password) {
return next();
}
// Check if user is read-only
if (req.user && !!req.user.is_view_only) {
const upError = new UserPermissionError('User has view-only access');
upError.status = 403;
throw upError;
}
next();
}
module.exports = {
delayLoginMiddleware
delayLoginMiddleware,
verifyViewOnlyUser
};

View File

@@ -36,25 +36,98 @@ class Application extends Model {
super();
}
static _criteriaBuilder(obj, args) {
let sql = '';
if (obj.account_sid) {
sql += ' AND app.account_sid = ?';
args.push(obj.account_sid);
}
if (obj.service_provider_sid) {
sql += ' AND app.account_sid in (SELECT account_sid from accounts WHERE service_provider_sid = ?)';
args.push(obj.service_provider_sid);
}
if (obj.name) {
sql += ' AND app.name LIKE ?';
args.push(`%${obj.name}%`);
}
return sql;
}
static countAll(obj) {
let sql = 'SELECT COUNT(*) AS count FROM applications app WHERE 1 = 1';
const args = [];
sql += Application._criteriaBuilder(obj, args);
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[0].count);
});
});
});
}
/**
* list all applications - for all service providers, for one service provider, or for one account,
* or by an optional name
*/
static retrieveAll(service_provider_sid, account_sid, name) {
static retrieveAll(obj) {
const { page, page_size = 50 } = obj || {};
// If pagination is requested, first get the application IDs
if (page !== null && page !== undefined) {
let idSql = 'SELECT application_sid, name FROM applications app WHERE 1 = 1';
const idArgs = [];
idSql += Application._criteriaBuilder(obj, idArgs);
idSql += ' ORDER BY app.name';
const limit = Number(page_size);
const offset = Number(page > 0 ? (page - 1) : page) * limit;
idSql += ' LIMIT ? OFFSET ?';
idArgs.push(limit);
idArgs.push(offset);
return new Promise((resolve, reject) => {
getMysqlConnection((err, conn) => {
if (err) return reject(err);
// Get paginated application IDs
conn.query(idSql, idArgs, (err, idResults) => {
if (err) {
conn.release();
return reject(err);
}
if (idResults.length === 0) {
conn.release();
return resolve([]);
}
// Get full data for these applications
const appIds = idResults.map((row) => row.application_sid);
const placeholders = appIds.map(() => '?').join(',');
const fullSql = `${retrieveSql}
WHERE app.application_sid IN (${placeholders}) ORDER BY app.name`;
conn.query({sql: fullSql, nestTables: true}, appIds, (err, results) => {
conn.release();
if (err) return reject(err);
const r = transmogrifyResults(results);
resolve(r);
});
});
});
});
}
// No pagination - use original query
let sql = retrieveSql + ' WHERE 1 = 1';
const args = [];
if (account_sid) {
sql = `${sql} AND app.account_sid = ?`;
args.push(account_sid);
}
else if (service_provider_sid) {
sql = `${sql} AND account_sid in (SELECT account_sid from accounts WHERE service_provider_sid = ?)`;
args.push(service_provider_sid);
}
if (name) {
sql = `${sql} AND app.name = ?`;
args.push(name);
}
sql += Application._criteriaBuilder(obj, args);
sql += ' ORDER BY app.application_sid';
return new Promise((resolve, reject) => {
getMysqlConnection((err, conn) => {
if (err) return reject(err);

45
lib/models/permissions.js Normal file
View File

@@ -0,0 +1,45 @@
const Model = require('./model');
const {promisePool} = require('../db');
const sqlAll = `
SELECT * from permissions
`;
const sqlByName = `
SELECT * from permissions where name = ?
`;
class Permissions extends Model {
constructor() {
super();
}
static async retrieveAll() {
const [rows] = await promisePool.query(sqlAll);
return rows;
}
static async retrieveByName(name) {
const [rows] = await promisePool.query(sqlByName, [name]);
return rows;
}
}
Permissions.table = 'permissions';
Permissions.fields = [
{
name: 'permission_sid',
type: 'string',
primaryKey: true
},
{
name: 'name',
type: 'string',
required: true
},
{
name: 'description',
type: 'string',
required: true
}
];
module.exports = Permissions;

View File

@@ -26,6 +26,49 @@ class PhoneNumber extends Model {
return rows;
}
static _criteriaBuilder(obj, params) {
let sql = '';
if (obj.service_provider_sid) {
sql += ' AND account_sid IN (SELECT account_sid FROM accounts WHERE service_provider_sid = ?)';
params.push(obj.service_provider_sid);
}
if (obj.account_sid) {
sql += ' AND account_sid = ?';
params.push(obj.account_sid);
}
if (obj.filter) {
sql += ' AND number LIKE ?';
params.push(`%${obj.filter}%`);
}
return sql;
}
static async countAll(obj) {
let sql = 'SELECT COUNT(*) AS count FROM phone_numbers WHERE 1 = 1';
const args = [];
sql += PhoneNumber._criteriaBuilder(obj, args);
const [rows] = await promisePool.query(sql, args);
return rows[0].count;
}
static async retrieveAllByCriteria(obj) {
let sql = 'SELECT * FROM phone_numbers WHERE 1=1';
const params = [];
const { page, page_size = 50 } = obj || {};
sql += PhoneNumber._criteriaBuilder(obj, params);
sql += ' ORDER BY number';
if (page !== null && page !== undefined) {
const limit = Number(page_size);
const offset = Number(page > 0 ? (page - 1) : page) * limit;
sql += ' LIMIT ? OFFSET ?';
params.push(limit);
params.push(offset);
}
const [rows] = await promisePool.query(sql, params);
return rows;
}
/**
* retrieve a phone number
*/

View File

@@ -0,0 +1,53 @@
const Model = require('./model');
const {promisePool} = require('../db');
const sqlAll = `
SELECT * from user_permissions
`;
const sqlByUserIdPermissionSid = `
SELECT * from user_permissions where user_sid = ? and permission_sid = ?
`;
const sqlByUserId = `
SELECT * from user_permissions where user_sid = ?
`;
class UserPermissions extends Model {
constructor() {
super();
}
static async retrieveAll() {
const [rows] = await promisePool.query(sqlAll);
return rows;
}
static async retrieveByUserIdPermissionSid(user_sid, permission_sid) {
const [rows] = await promisePool.query(sqlByUserIdPermissionSid, [user_sid, permission_sid]);
return rows;
}
static async retrieveByUserId(user_sid) {
const [rows] = await promisePool.query(sqlByUserId, [user_sid]);
return rows;
}
}
UserPermissions.table = 'user_permissions';
UserPermissions.fields = [
{
name: 'user_permissions_sid',
type: 'string',
primaryKey: true
},
{
name: 'user_sid',
type: 'string',
required: true
},
{
name: 'permission_sid',
type: 'string',
required: true
}
];
module.exports = UserPermissions;

View File

@@ -8,6 +8,57 @@ class VoipCarrier extends Model {
constructor() {
super();
}
static _criteriaBuilder(obj, args) {
let sql = '';
if (obj.account_sid) {
// carrier belong to an account when
// 1. account_sid is set
// 2. account_sid is null and service_provider_sid matches the account's service_provider_sid
sql += ` AND (vc.account_sid = ? OR
(vc.account_sid IS NULL AND vc.service_provider_sid IN
(SELECT service_provider_sid FROM accounts WHERE account_sid = ?))
)`;
args.push(obj.account_sid);
args.push(obj.account_sid);
}
if (obj.service_provider_sid) {
sql += ' AND vc.service_provider_sid = ?';
args.push(obj.service_provider_sid);
}
if (obj.name) {
sql += ' AND vc.name LIKE ?';
args.push(`%${obj.name}%`);
}
return sql;
}
static async countAll(obj) {
let sql = 'SELECT COUNT(*) AS count FROM voip_carriers vc WHERE 1 = 1';
const args = [];
sql += VoipCarrier._criteriaBuilder(obj, args);
const [rows] = await promisePool.query(sql, args);
return rows[0].count;
}
static async retrieveByCriteria(obj) {
let sql = 'SELECT * from voip_carriers vc WHERE 1 =1';
const args = [];
sql += VoipCarrier._criteriaBuilder(obj, args);
if (obj.page !== null && obj.page !== undefined) {
const limit = Number(obj.page_size || 50);
const offset = (Number(obj.page) - 1) * limit;
sql += ' LIMIT ? OFFSET ?';
args.push(limit, offset);
}
const [rows] = await promisePool.query(sql, args);
if (rows) {
rows.map((r) => r.register_status = JSON.parse(r.register_status || '{}'));
}
return rows;
}
static async retrieveAll(account_sid) {
if (!account_sid) return super.retrieveAll();
const [rows] = await promisePool.query(retrieveSql, account_sid);

View File

@@ -26,7 +26,7 @@ const getUploader = (key, metadata, bucket_credential, logger) => {
accessKeyId: bucket_credential.access_key_id,
secretAccessKey: bucket_credential.secret_access_key,
},
region: 'us-east-1',
region: bucket_credential.region || 'us-east-1',
forcePathStyle: true
};
return new S3MultipartUploadStream(logger, uploaderOpts);

View File

@@ -1,6 +1,5 @@
const router = require('express').Router();
const assert = require('assert');
const request = require('request');
const {DbErrorBadRequest, DbErrorForbidden, DbErrorUnprocessableRequest} = require('../../utils/errors');
const Account = require('../../models/account');
const Application = require('../../models/application');
@@ -24,7 +23,8 @@ const {
} = require('./utils');
const short = require('short-uuid');
const VoipCarrier = require('../../models/voip-carrier');
const { encrypt, obscureBucketCredentialsSensitiveData, isObscureKey } = require('../../utils/encrypt-decrypt');
const { encrypt, obscureBucketCredentialsSensitiveData,
isObscureKey, decrypt } = require('../../utils/encrypt-decrypt');
const { testS3Storage, testGoogleStorage, testAzureStorage } = require('../../utils/storage-utils');
const translator = short();
@@ -93,8 +93,34 @@ router.get('/:sid/Applications', async(req, res) => {
try {
const account_sid = parseAccountSid(req);
await validateRequest(req, account_sid);
const results = await Application.retrieveAll(null, account_sid);
res.status(200).json(results);
const {page, page_size, name} = req.query || {};
const isPaginationRequest = page !== null && page !== undefined;
let results = [];
let total = 0;
if (isPaginationRequest) {
total = await Application.countAll({account_sid, name});
results = await Application.retrieveAll({
account_sid, name, page, page_size
});
} else {
results = await Application.retrieveAll({account_sid});
}
const ret = results.map((a) => {
if (a.env_vars) {
a.env_vars = JSON.parse(decrypt(a.env_vars));
return a;
} else {
return a;
}
});
const body = isPaginationRequest ? {
total,
page: Number(page),
page_size: Number(page_size),
data: ret
} : ret;
res.status(200).json(body);
} catch (err) {
sysError(logger, res, err);
}
@@ -222,7 +248,8 @@ router.get('/:sid/RegisteredSipUsers/:client', async(req, res) => {
allow_direct_app_calling: clientDb ? clientDb.allow_direct_app_calling : 0,
allow_direct_queue_calling: clientDb ? clientDb.allow_direct_queue_calling : 0,
allow_direct_user_calling: clientDb ? clientDb.allow_direct_user_calling : 0,
registered_status: user ? 'active' : 'inactive'
registered_status: user ? 'active' : 'inactive',
proxy: user ? user.proxy : null
});
} catch (err) {
sysError(logger, res, err);
@@ -356,7 +383,10 @@ async function validateCreateCall(logger, sid, req) {
const {lookupAppBySid} = req.app.locals;
const obj = req.body;
if (req.user.account_sid !== sid) throw new DbErrorBadRequest(`unauthorized createCall request for account ${sid}`);
if (req.user.hasServiceProviderAuth ||
req.user.hasAccountAuth && req.user.account_sid !== sid) {
throw new DbErrorBadRequest(`unauthorized createCall request for account ${sid}`);
}
obj.account_sid = sid;
if (!obj.from) throw new DbErrorBadRequest('missing from parameter');
@@ -651,12 +681,12 @@ function encryptBucketCredential(obj, storedCredentials = {}) {
name,
access_key_id,
tags,
endpoint
endpoint,
} = obj.bucket_credential;
let {
secret_access_key,
service_key,
connection_string
connection_string,
} = obj.bucket_credential;
switch (vendor) {
@@ -681,7 +711,9 @@ function encryptBucketCredential(obj, storedCredentials = {}) {
secret_access_key = storedCredentials.secret_access_key;
}
const s3Data = JSON.stringify({vendor, endpoint, name, access_key_id,
secret_access_key, tags});
secret_access_key, tags,
...(region && {region})
});
obj.bucket_credential = encrypt(s3Data);
break;
case 'google':
@@ -940,24 +972,25 @@ router.post('/:sid/Calls', async(req, res) => {
await validateCreateCall(logger, sid, req);
updateLastUsed(logger, sid, req).catch((err) => {});
request({
url: serviceUrl,
const response = await fetch(serviceUrl, {
method: 'POST',
json: true,
body: Object.assign(req.body, {account_sid: sid})
}, (err, response, body) => {
if (err) {
logger.error(err, `Error sending createCall POST to ${serviceUrl}`);
return res.sendStatus(500);
}
if (response.statusCode !== 201) {
logger.error({statusCode: response.statusCode}, `Non-success response returned by createCall ${serviceUrl}`);
return res.sendStatus(500);
}
return res.status(201).json(body);
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(Object.assign(req.body, {account_sid: sid}))
});
if (!response.ok) {
logger.error(`Error sending createCall POST to ${serviceUrl}`);
return res.sendStatus(500);
}
if (response.status !== 201) {
logger.error(`Non-success response returned by createCall ${serviceUrl}`);
return res.sendStatus(500);
}
const body = await response.json();
return res.status(201).json(body);
} catch (err) {
sysError(logger, res, err);
}
@@ -1048,22 +1081,54 @@ const updateCall = async(req, res) => {
await validateRequest(req, accountSid);
const callSid = parseCallSid(req);
validateUpdateCall(req.body);
updateLastUsed(logger, accountSid, req).catch((err) => {});
const call = await retrieveCall(accountSid, callSid);
if (call) {
const url = `${call.serviceUrl}/${process.env.JAMBONES_API_VERSION || 'v1'}/updateCall/${callSid}`;
logger.debug({call, url, payload: req.body}, `updateCall: retrieved call info for call sid ${callSid}`);
request({
url: url,
const response = await fetch(url, {
method: 'POST',
json: true,
body: req.body
}).pipe(res);
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(req.body)
});
if (!response.ok) {
try {
const text = await response.text();
logger.error(`Error sending updateCall POST to ${url}, status: ${response.status} body: ${text}`);
// Try to parse as JSON if there's content
if (text) {
try {
const body = JSON.parse(text);
return res.status(response.status).json(body);
} catch {
// Not valid JSON
return res.status(response.status).send(text);
}
}
return res.sendStatus(response.status);
} catch (err) {
logger.error({err}, `updateCall: error reading response from ${url}`);
return res.sendStatus(response.status);
}
}
if (response.status === 200) {
// feature server return json for sip_request command
// with 200 OK
const body = await response.json();
return res.status(200).json(body);
} else {
// rest commander returns 202 Accepted for all other commands
return res.sendStatus(response.status);
}
}
else {
logger.debug(`updateCall: call not found for call sid ${callSid}`);
res.sendStatus(404);
}
updateLastUsed(logger, accountSid, req).catch((err) => {});
} catch (err) {
sysError(logger, res, err);
}
@@ -1090,7 +1155,9 @@ router.post('/:sid/Messages', async(req, res) => {
const setName = `${(process.env.JAMBONES_CLUSTER_ID || 'default')}:fs-service-url`;
const serviceUrl = await getFsUrl(logger, retrieveSet, setName);
if (!serviceUrl) res.json({msg: 'no available feature servers at this time'}).status(480);
if (!serviceUrl) {
return res.status(480).json({msg: 'no available feature servers at this time'});
}
await validateCreateMessage(logger, account_sid, req);
const payload = {
@@ -1100,22 +1167,19 @@ router.post('/:sid/Messages', async(req, res) => {
};
logger.debug({payload}, `sending createMessage API request to to ${serviceUrl}`);
updateLastUsed(logger, account_sid, req).catch(() => {});
request({
url: serviceUrl,
const response = await fetch(serviceUrl, {
method: 'POST',
json: true,
body: payload
}, (err, response, body) => {
if (err) {
logger.error(err, `Error sending createMessage POST to ${serviceUrl}`);
return res.sendStatus(500);
}
if (response.statusCode !== 200) {
logger.error({statusCode: response.statusCode}, `Non-success response returned by createMessage ${serviceUrl}`);
return body ? res.status(response.statusCode).json(body) : res.sendStatus(response.statusCode);
}
res.status(201).json(body);
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
logger.error(`Error sending createMessage POST to ${serviceUrl}`);
return res.sendStatus(500);
}
const body = await response.json();
return res.status(response.status).json(body);
} catch (err) {
sysError(logger, res, err);
}

47
lib/routes/api/appenv.js Normal file
View File

@@ -0,0 +1,47 @@
const router = require('express').Router();
const sysError = require('../error');
const { fetchAppEnvSchema, validateAppEnvSchema } = require('../../utils/appenv_utils');
const URL = require('url').URL;
const isValidUrl = (s) => {
const protocols = ['https:', 'http:', 'ws:', 'wss:'];
try {
const url = new URL(s);
if (protocols.includes(url.protocol)) {
return true;
}
else {
return false;
}
} catch (err) {
return false;
}
};
/* get appenv schema for endpoint */
router.get('/', async(req, res) => {
const logger = req.app.locals.logger;
const url = req.query.url;
if (!isValidUrl(url)) {
sysError(logger, res, 'Invalid URL');
} else {
try {
const appenv = await fetchAppEnvSchema(logger, url);
if (appenv && validateAppEnvSchema(appenv)) {
return res.status(200).json(appenv);
} else if (appenv) {
return res.status(400).json({
msg: 'Invalid appenv schema',
});
} else {
return res.status(204).end(); //No appenv returned from url, normal scenario
}
}
catch (err) {
sysError(logger, res, err);
}
}
});
module.exports = router;

View File

@@ -7,11 +7,13 @@ const {promisePool} = require('../../db');
const decorate = require('./decorate');
const sysError = require('../error');
const { validate } = require('@jambonz/verb-specifications');
const { parseApplicationSid } = require('./utils');
const { parseApplicationSid, isInvalidUrl } = require('./utils');
const preconditions = {
'add': validateAdd,
'update': validateUpdate
};
const { fetchAppEnvSchema, validateAppEnvData } = require('../../utils/appenv_utils');
const {decrypt, encrypt} = require('../../utils/encrypt-decrypt');
const validateRequest = async(req, account_sid) => {
try {
@@ -62,6 +64,14 @@ async function validateAdd(req) {
if (req.body.call_status_hook && typeof req.body.call_hook !== 'object') {
throw new DbErrorBadRequest('\'call_status_hook\' must be an object when adding an application');
}
let urlError = await isInvalidUrl(req.body.call_hook.url);
if (urlError) {
throw new DbErrorBadRequest(`call_hook ${urlError}`);
}
urlError = await isInvalidUrl(req.body.call_status_hook.url);
if (urlError) {
throw new DbErrorBadRequest(`call_status_hook ${urlError}`);
}
}
async function validateUpdate(req, sid) {
@@ -142,6 +152,16 @@ router.post('/', async(req, res) => {
throw new DbErrorBadRequest(err);
}
}
// validate env_vars data if required
if (obj['env_vars']) {
const appenvschema = await fetchAppEnvSchema(logger, req.body.call_hook.url);
const errors = await validateAppEnvData(appenvschema, obj['env_vars']);
if (errors) {
throw new DbErrorBadRequest(errors);
} else {
obj['env_vars'] = encrypt(JSON.stringify(obj['env_vars']));
}
}
const uuid = await Application.make(obj);
res.status(201).json({sid: uuid});
@@ -153,12 +173,34 @@ router.post('/', async(req, res) => {
/* list */
router.get('/', async(req, res) => {
const logger = req.app.locals.logger;
const {page, page_size, name} = req.query || {};
const isPaginationRequest = page !== null && page !== undefined;
try {
const service_provider_sid = req.user.hasServiceProviderAuth ? req.user.service_provider_sid : null;
const account_sid = req.user.hasAccountAuth ? req.user.account_sid : null;
const name = req.query.name;
const results = await Application.retrieveAll(service_provider_sid, account_sid, name);
res.status(200).json(results);
let results = [];
let total = 0;
if (isPaginationRequest) {
total = await Application.countAll({service_provider_sid, account_sid, name});
}
results = await Application.retrieveAll({
service_provider_sid, account_sid, name, page, page_size
});
const ret = results.map((a) => {
if (a.env_vars) {
a.env_vars = JSON.parse(decrypt(a.env_vars));
return a;
} else {
return a;
}
});
const body = isPaginationRequest ? {
total,
page: Number(page),
page_size: Number(page_size),
data: ret
} : ret;
res.status(200).json(body);
} catch (err) {
sysError(logger, res, err);
}
@@ -174,6 +216,9 @@ router.get('/:sid', async(req, res) => {
const results = await Application.retrieve(application_sid, service_provider_sid, account_sid);
if (results.length === 0) return res.status(404).end();
await validateRequest(req, results[0].account_sid);
if (results[0].env_vars) {
results[0].env_vars = JSON.parse(decrypt(results[0].env_vars));
}
return res.status(200).json(results[0]);
}
catch (err) {
@@ -228,6 +273,8 @@ router.put('/:sid', async(req, res) => {
try {
const sid = parseApplicationSid(req);
await validateUpdate(req, sid);
const service_provider_sid = req.user.hasServiceProviderAuth ? req.user.service_provider_sid : null;
const account_sid = req.user.hasAccountAuth ? req.user.account_sid : null;
// create webhooks if provided
const obj = Object.assign({}, req.body);
@@ -259,6 +306,21 @@ router.put('/:sid', async(req, res) => {
}
}
// validate env_vars data if required
if (obj['env_vars']) {
const applications = await Application.retrieve(sid, service_provider_sid, account_sid);
const call_hook_url = req.body.call_hook ?
(req.body.call_hook.url || req.body.call_hook) :
applications[0].call_hook.url;
const appenvschema = await fetchAppEnvSchema(logger, call_hook_url);
const errors = await validateAppEnvData(appenvschema, obj['env_vars']);
if (errors) {
throw new DbErrorBadRequest(errors);
} else {
obj['env_vars'] = encrypt(JSON.stringify(obj['env_vars']));
}
}
const rowsAffected = await Application.update(sid, obj);
if (rowsAffected === 0) {
return res.status(404).end();

View File

@@ -39,6 +39,7 @@ 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('/AppEnv', require('./appenv'));
//api.use('/Products', require('./products'));
api.use('/Prices', require('./prices'));
api.use('/StripeCustomerId', require('./stripe-customer-id'));

View File

@@ -29,6 +29,9 @@ router.post('/', async(req, res) => {
return res.sendStatus(403);
}
logger.info({r}, 'successfully retrieved user account');
if (r[0].provider !== 'local') {
return res.sendStatus(403);
}
const maxLoginAttempts = process.env.LOGIN_ATTEMPTS_MAX_RETRIES || 6;
const loginAttempsBlocked = await retrieveKey(`login:${r[0].user_sid}`) >= maxLoginAttempts;
@@ -71,9 +74,13 @@ router.post('/', async(req, res) => {
obj.service_provider_sid = r[0].service_provider_sid;
obj.service_provider_name = service_provider[0].name;
}
// if there is only one permission and it is VIEW_ONLY, then the user is view only
// this is to prevent write operations on the API
const is_view_only = permissions.length === 1 && permissions.includes('VIEW_ONLY');
const payload = {
scope: obj.scope,
permissions,
is_view_only,
...(obj.service_provider_sid && {
service_provider_sid: obj.service_provider_sid,
service_provider_name: obj.service_provider_name
@@ -83,7 +90,8 @@ router.post('/', async(req, res) => {
account_name: obj.account_name,
service_provider_name: obj.service_provider_name
}),
user_sid: obj.user_sid
user_sid: obj.user_sid,
name: username
};
const expiresIn = parseInt(process.env.JWT_EXPIRES_IN || 60) * 60;

View File

@@ -97,11 +97,35 @@ decorate(router, PhoneNumber, ['add', 'update', 'delete'], preconditions);
/* list */
router.get('/', async(req, res) => {
const logger = req.app.locals.logger;
const {service_provider_sid: query_service_provider_sid,
account_sid: query_account_sid, filter, page, page_size} = req.query;
const isPaginationRequest = page !== null && page !== undefined;
let service_provider_sid = null, account_sid = query_account_sid;
if (req.user.hasAccountAuth) {
account_sid = req.user.account_sid;
} else if (req.user.hasServiceProviderAuth) {
service_provider_sid = req.user.service_provider_sid;
} else {
// admin user can query all phone numbers
service_provider_sid = query_service_provider_sid;
account_sid = query_account_sid;
}
try {
const results = req.user.hasServiceProviderAuth ?
await PhoneNumber.retrieveAllForSP(req.user.service_provider_sid) :
await PhoneNumber.retrieveAll(req.user.hasAccountAuth ? req.user.account_sid : null);
res.status(200).json(results);
let total = 0;
if (isPaginationRequest) {
total = await PhoneNumber.countAll({service_provider_sid, account_sid, filter});
}
const results = await PhoneNumber.retrieveAllByCriteria({
service_provider_sid, account_sid, filter, page, page_size
});
const body = isPaginationRequest ? {
total,
page: Number(page),
page_size: Number(page_size),
data: results,
} : results;
res.status(200).json(body);
} catch (err) {
sysError(logger, res, err);
}

View File

@@ -149,13 +149,30 @@ router.get('/:sid/VoipCarriers', async(req, res) => {
try {
await validateRetrieve(req);
const service_provider_sid = parseServiceProviderSid(req);
const carriers = await VoipCarrier.retrieveAllForSP(service_provider_sid);
if (req.user.hasScope('account')) {
return res.status(200).json(carriers.filter((c) => c.account_sid === req.user.account_sid || !c.account_sid));
const {account_sid: query_account_sid, name, page, page_size} = req.query || {};
const isPaginationRequest = page !== null && page !== undefined;
const account_sid = req.user.hasAccountAuth ? req.user.account_sid : query_account_sid || null;
let carriers = [];
let total = 0;
if (isPaginationRequest) {
total = await VoipCarrier.countAll({service_provider_sid, account_sid, name});
}
carriers = await VoipCarrier.retrieveByCriteria({
service_provider_sid,
account_sid,
name,
page,
page_size,
});
res.status(200).json(carriers);
const body = isPaginationRequest ? {
total,
page: Number(page),
page_size: Number(page_size),
data: carriers,
} : carriers;
res.status(200).json(body);
} catch (err) {
sysError(logger, res, err);
}

View File

@@ -4,6 +4,7 @@ const {DbErrorBadRequest, DbErrorForbidden} = require('../../utils/errors');
//const {parseSipGatewaySid} = require('./utils');
const decorate = require('./decorate');
const sysError = require('../error');
const net = require('net');
const checkUserScope = async(req, voip_carrier_sid) => {
const {lookupCarrierBySid} = req.app.locals;
@@ -41,7 +42,7 @@ const checkUserScope = async(req, voip_carrier_sid) => {
const validate = async(req, sid) => {
const {lookupSipGatewayBySid} = req.app.locals;
const {netmask} = req.body;
const {netmask, ipv4, inbound, outbound} = req.body;
let voip_carrier_sid;
if (sid) {
@@ -59,6 +60,12 @@ const validate = async(req, sid) => {
throw new DbErrorBadRequest(
`netmask required to have value equal or greater than ${process.env.JAMBONZ_MIN_GATEWAY_NETMASK}`);
}
if (inbound && !net.isIPv4(ipv4)) {
throw new DbErrorBadRequest('Inbound gateway must be IPv4 address');
}
if (!inbound && outbound && (netmask && netmask != 32)) {
throw new DbErrorBadRequest('For outbound only gateway netmask can only be 32');
}
await checkUserScope(req, voip_carrier_sid);
};

View File

@@ -1,5 +1,4 @@
const router = require('express').Router();
const request = require('request');
const getProvider = require('../../utils/sms-provider');
const { v4: uuidv4 } = require('uuid');
const sysError = require('../error');
@@ -122,25 +121,19 @@ router.post('/:provider', async(req, res) => {
logger.info({payload, url: serviceUrl}, `sending incomingSms API request to FS at ${serviceUrl}`);
request({
url: serviceUrl,
const response = await fetch(serviceUrl, {
method: 'POST',
json: true,
body: payload,
},
async(err, response, body) => {
if (err) {
logger.error(err, `Error sending incomingSms POST to ${serviceUrl}`);
return res.sendStatus(500);
}
if (200 === response.statusCode) {
// success
logger.info({body}, 'sending response to provider for incomingSMS');
return doSendResponse(res, respondFn, body);
}
logger.error({statusCode: response.statusCode}, `Non-success response returned by incomingSms ${serviceUrl}`);
return res.sendStatus(500);
body: JSON.stringify(payload),
headers: {'Content-Type': 'application/json'},
});
if (!response.ok) {
logger.error({response}, `Error sending incomingSms POST to ${serviceUrl}`);
return res.sendStatus(500);
}
const body = await response.json();
logger.info({body}, 'sending response to provider for incomingSMS');
return doSendResponse(res, respondFn, body);
} catch (err) {
sysError(logger, res, err);
}

View File

@@ -130,6 +130,7 @@ const encryptCredential = (obj) => {
deepgram_stt_uri,
deepgram_stt_use_tls,
deepgram_tts_uri,
playht_tts_uri,
use_custom_tts,
custom_tts_endpoint,
custom_tts_endpoint_url,
@@ -214,7 +215,8 @@ const encryptCredential = (obj) => {
if (!deepgram_stt_uri || !deepgram_tts_uri) {
assert(api_key, 'invalid deepgram speech credential: api_key is required');
}
const deepgramData = JSON.stringify({api_key, deepgram_stt_uri, deepgram_stt_use_tls, deepgram_tts_uri});
const deepgramData = JSON.stringify({api_key, deepgram_stt_uri,
deepgram_stt_use_tls, deepgram_tts_uri, model_id});
return encrypt(deepgramData);
case 'ibm':
@@ -252,7 +254,7 @@ const encryptCredential = (obj) => {
assert(api_key, 'invalid playht speech credential: api_key is required');
assert(user_id, 'invalid playht speech credential: user_id is required');
assert(voice_engine, 'invalid voice_engine speech credential: voice_engine is required');
const playhtData = JSON.stringify({api_key, user_id, voice_engine, options});
const playhtData = JSON.stringify({api_key, user_id, voice_engine, playht_tts_uri, options});
return encrypt(playhtData);
case 'cartesia':
@@ -490,7 +492,9 @@ router.put('/:sid', async(req, res) => {
deepgram_stt_uri,
deepgram_stt_use_tls,
deepgram_tts_uri,
engine_version
playht_tts_uri,
engine_version,
speechmatics_stt_uri
} = req.body;
const newCred = {
@@ -519,7 +523,9 @@ router.put('/:sid', async(req, res) => {
deepgram_stt_uri,
deepgram_stt_use_tls,
deepgram_tts_uri,
engine_version
playht_tts_uri,
engine_version,
speechmatics_stt_uri
};
logger.info({o, newCred}, 'updating speech credential with this new credential');
obj.credential = encryptCredential(newCred);
@@ -819,7 +825,6 @@ router.get('/:sid/test', async(req, res) => {
SpeechCredential.ttsTestResult(sid, true);
} catch (err) {
let reason = err.message;
// if error is from bent, let get the body
try {
reason = await err.text();
} catch {}
@@ -835,7 +840,6 @@ router.get('/:sid/test', async(req, res) => {
SpeechCredential.ttsTestResult(sid, true);
} catch (err) {
let reason = err.message;
// if error is from bent, let get the body
try {
reason = await err.text();
} catch {}

View File

@@ -229,7 +229,7 @@ const updateQuantities = async(req, res) => {
const obj = {
quantity: product.quantity,
};
return Object.assign(obj, existingItem ? {id: existingItem.id} : {price_id: product.price_id});
return Object.assign(obj, existingItem ? {id: existingItem.id} : {price: product.price_id});
});
if (dry_run) {

View File

@@ -1,5 +1,7 @@
const router = require('express').Router();
const User = require('../../models/user');
const UserPermissions = require('../../models/user-permissions');
const Permissions = require('../../models/permissions');
const {DbErrorBadRequest, BadRequestError, DbErrorForbidden} = require('../../utils/errors');
const {generateHashedPassword, verifyPassword} = require('../../utils/password-utils');
const {promisePool} = require('../../db');
@@ -38,7 +40,8 @@ const validateRequest = async(user_sid, req) => {
email,
email_activation_code,
force_change,
is_active
is_active,
is_view_only
} = payload;
const [r] = await promisePool.query(retrieveSql, user_sid);
@@ -93,7 +96,8 @@ const validateRequest = async(user_sid, req) => {
if (email_activation_code && !email) {
throw new DbErrorBadRequest('email and email_activation_code both required');
}
if (!name && !new_password && !email && !initial_password && !force_change && !is_active)
if (!name && !new_password && !email && !initial_password && !force_change && !is_active &&
is_view_only === undefined)
throw new DbErrorBadRequest('no updates requested');
return user;
@@ -140,7 +144,35 @@ const ensureUserRetrievalIsAllowed = (req, user) => {
throw error;
}
};
async function updateViewOnlyUserPermission(is_view_only, user_sid) {
try {
const [viewOnlyPermission] = await Permissions.retrieveByName('VIEW_ONLY');
if (!viewOnlyPermission) {
throw new Error('VIEW_ONLY permission not found');
}
const existingPermissions = await UserPermissions.retrieveByUserIdPermissionSid(
user_sid,
viewOnlyPermission.permission_sid
);
if (is_view_only && existingPermissions.length === 0) {
await UserPermissions.make({
user_sid,
permission_sid: viewOnlyPermission.permission_sid,
});
} else if (!is_view_only && existingPermissions.length > 0) {
await UserPermissions.remove(existingPermissions[0].user_permissions_sid);
}
} catch (err) {
throw new Error(`Failed to update user permissions: ${err.message}`);
}
}
async function removeViewOnlyUserPermission(user_id) {
const [viewOnlyPermission] = await Permissions.retrieveByName('VIEW_ONLY');
if (viewOnlyPermission) {
await UserPermissions.remove(user_id, viewOnlyPermission.permission_sid);
}
}
router.get('/', async(req, res) => {
const logger = req.app.locals.logger;
@@ -308,7 +340,14 @@ router.get('/:user_sid', async(req, res) => {
}
ensureUserRetrievalIsAllowed(req, user);
const [viewOnlyPermission] = await Permissions.retrieveByName('VIEW_ONLY');
const existingPermissions = await UserPermissions.retrieveByUserId(
user_sid
);
logger.debug(`existingPermissions of ${user_sid}: ${JSON.stringify(existingPermissions)}`);
user.is_view_only = existingPermissions.length === 1 &&
existingPermissions[0].permission_sid === viewOnlyPermission.permission_sid;
logger.debug(`User ${user_sid} is view-only user: ${user.is_view_only}`);
// eslint-disable-next-line no-unused-vars
const { hashed_password, ...rest } = user;
return res.status(200).json(rest);
@@ -332,9 +371,9 @@ router.put('/:user_sid', async(req, res) => {
is_active,
force_change,
account_sid,
service_provider_sid
service_provider_sid,
is_view_only
} = req.body;
//if (req.user.user_sid && req.user.user_sid !== user_sid) return res.sendStatus(403);
if (!hasAdminAuth &&
@@ -427,6 +466,8 @@ router.put('/:user_sid', async(req, res) => {
//TODO: send email with activation code
}
}
// update user permissions
await updateViewOnlyUserPermission(is_view_only, user_sid);
res.sendStatus(204);
} catch (err) {
sysError(logger, res, err);
@@ -443,6 +484,8 @@ router.post('/', async(req, res) => {
};
const allUsers = await User.retrieveAll();
delete payload.initial_password;
const is_view_only = payload.is_view_only;
delete payload.is_view_only;
try {
if (req.body.initial_password) {
@@ -464,6 +507,7 @@ router.post('/', async(req, res) => {
if (req.user.hasAdminAuth) {
logger.debug({payload}, 'POST /users');
const uuid = await User.make(payload);
await updateViewOnlyUserPermission(is_view_only, uuid);
res.status(201).json({user_sid: uuid});
}
else if (req.user.hasAccountAuth) {
@@ -472,6 +516,7 @@ router.post('/', async(req, res) => {
...payload,
account_sid: req.user.account_sid,
});
await updateViewOnlyUserPermission(is_view_only, uuid);
res.status(201).json({user_sid: uuid});
}
else if (req.user.hasServiceProviderAuth) {
@@ -480,6 +525,7 @@ router.post('/', async(req, res) => {
...payload,
service_provider_sid: req.user.service_provider_sid,
});
await updateViewOnlyUserPermission(is_view_only, uuid);
res.status(201).json({user_sid: uuid});
}
} catch (err) {
@@ -497,6 +543,8 @@ router.delete('/:user_sid', async(req, res) => {
const user = allUsers.filter((user) => user.user_sid === user_sid);
ensureUserDeletionIsAllowed(req, activeAdminUsers, user);
logger.debug(`Removing view-only permission for user ${user_sid}`);
await removeViewOnlyUserPermission(user_sid);
await User.remove(user_sid);
/* invalidate the jwt of the deleted user */

View File

@@ -1,5 +1,6 @@
const { v4: uuid, validate } = require('uuid');
const bent = require('bent');
const URL = require('url').URL;
const isValidHostname = require('is-valid-hostname');
const Account = require('../../models/account');
const {promisePool} = require('../../db');
const {cancelSubscription, detachPaymentMethod} = require('../../utils/stripe-utils');
@@ -11,8 +12,6 @@ values (?, ?)`;
const replaceOldSubscriptionSql = `UPDATE account_subscriptions
SET effective_end_date = CURRENT_TIMESTAMP, change_reason = ?
WHERE account_subscription_sid = ?`;
//const request = require('request');
//require('request-debug')(request);
const setupFreeTrial = async(logger, account_sid, isReturningUser) => {
const sid = uuid();
@@ -370,35 +369,44 @@ const checkLimits = async(req, res, next) => {
};
const getSubspaceJWT = async(id, secret) => {
const postJwt = bent('https://id.subspace.com', 'POST', 'json', 200);
const jwt = await postJwt('/oauth/token',
{
const response = await fetch('https://id.subspace.com/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
client_id: id,
client_secret: secret,
audience: 'https://api.subspace.com/',
grant_type: 'client_credentials',
}
);
})
});
if (!response.ok) {
throw new Error(`Failed to get JWT: ${response.status} ${response.statusText}`);
}
const jwt = await response.json();
return jwt.access_token;
};
const enableSubspace = async(opts) => {
const {subspace_client_id, subspace_client_secret, destination} = opts;
const accessToken = await getSubspaceJWT(subspace_client_id, subspace_client_secret);
const postTeleport = bent('https://api.subspace.com', 'POST', 'json', 200);
const teleport = await postTeleport('/v1/sipteleport',
{
const response = await fetch('https://api.subspace.com/v1/sipteleport', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`
},
body: JSON.stringify({
name: 'Jambonz',
destination,
status: 'ENABLED'
},
{
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`
}
);
})
});
if (!response.ok) {
throw new Error(`Failed to enable teleport: ${response.status} ${response.statusText}`);
}
const teleport = await response.json();
return teleport;
};
@@ -406,13 +414,15 @@ const disableSubspace = async(opts) => {
const {subspace_client_id, subspace_client_secret, subspace_sip_teleport_id} = opts;
const accessToken = await getSubspaceJWT(subspace_client_id, subspace_client_secret);
const relativeUrl = `/v1/sipteleport/${subspace_sip_teleport_id}`;
const deleteTeleport = bent('https://api.subspace.com', 'DELETE', 'json', 200);
await deleteTeleport(relativeUrl, {},
{
const response = await fetch(`https://api.subspace.com${relativeUrl}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${accessToken}`
}
);
return;
});
if (!response.ok) {
throw new Error(`Failed to delete teleport: ${response.status} ${response.statusText}`);
}
};
const validatePasswordSettings = async(password) => {
@@ -456,6 +466,28 @@ function hasValue(data) {
}
}
const isInvalidUrl = async(s) => {
const protocols = ['https:', 'http:', 'ws:', 'wss:'];
try {
const url = new URL(s);
if (s.length != s.trim().length) {
return 'URL contains leading/trailing whitespace';
}
else if (!isValidHostname(url.hostname)) {
return `URL has invalid hostname ${url.hostname}`;
}
else if (!protocols.includes(url.protocol)) {
return `URL has missing or invalid protocol ${url.protocol}`;
}
else {
return false;
}
} catch (err) {
return 'URL is invalid';
}
};
module.exports = {
setupFreeTrial,
createTestCdrs,
@@ -478,4 +510,5 @@ module.exports = {
disableSubspace,
validatePasswordSettings,
hasValue,
isInvalidUrl
};

View File

@@ -73,16 +73,36 @@ decorate(router, VoipCarrier, ['add', 'update', 'delete'], preconditions);
/* list */
router.get('/', async(req, res) => {
const logger = req.app.locals.logger;
const {account_sid: query_account_sid, name, page, page_size} = req.query || {};
const isPaginationRequest = page !== null && page !== undefined;
let service_provider_sid = null, account_sid = query_account_sid;
if (req.user.hasAccountAuth) {
account_sid = req.user.account_sid;
} else if (req.user.hasServiceProviderAuth) {
service_provider_sid = req.user.service_provider_sid;
}
try {
const results = req.user.hasAdminAuth ?
await VoipCarrier.retrieveAll(req.user.hasAccountAuth ? req.user.account_sid : null) :
await VoipCarrier.retrieveAllForSP(req.user.service_provider_sid);
if (req.user.hasScope('account')) {
return res.status(200).json(results.filter((c) => c.account_sid === req.user.account_sid || !c.account_sid));
let total = 0;
if (isPaginationRequest) {
total = await VoipCarrier.countAll({service_provider_sid, account_sid, name});
}
res.status(200).json(results);
const carriers = await VoipCarrier.retrieveByCriteria({
service_provider_sid,
account_sid,
name,
page,
page_size,
});
const body = isPaginationRequest ? {
total,
page: Number(page),
page_size: Number(page_size),
data: carriers,
} : carriers;
res.status(200).json(body);
} catch (err) {
sysError(logger, res, err);
}

View File

@@ -0,0 +1,50 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"additionalProperties": false,
"minProperties": 1,
"patternProperties": {
"^[a-zA-Z0-9_]+$": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"string",
"number",
"boolean"
]
},
"required": {
"type": "boolean"
},
"default": {
"oneOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
}
]
},
"enum": {
"type": "array"
},
"obscure": {
"type": "boolean"
}
},
"required": [
"type",
"description"
]
}
}
}

78
lib/utils/appenv_utils.js Normal file
View File

@@ -0,0 +1,78 @@
const Ajv = require('ajv');
const assert = require('assert');
const ajv = new Ajv();
const schemaSchema = require('./appenv_schemaSchema.json');
const validateAppEnvSchema = (schema) => {
const validate = ajv.compile(schemaSchema);
return validate(schema);
};
//Currently this request is not signed with the webhook secret as it is outside an account
const fetchAppEnvSchema = async(logger, url) => {
// Translate WebSocket URLs to HTTP equivalents (case-insensitive)
let fetchUrl = url;
if (url.toLowerCase().startsWith('ws://')) {
fetchUrl = 'http://' + url.substring(5);
} else if (url.toLowerCase().startsWith('wss://')) {
fetchUrl = 'https://' + url.substring(6);
}
try {
const response = await fetch(fetchUrl, {
method: 'OPTIONS',
headers: {
Accept: 'application/json'
}
});
if (!response.ok) {
logger.info(`Failure to fetch app env schema ${response.status} ${response.statusText}`);
return false;
}
const schema = await response.json();
return schema;
}
catch (e) {
logger.info(`Failure to fetch app env schema ${e}`);
return false;
}
};
const validateAppEnvData = async(schema, data) => {
const schemaKeys = Object.keys(schema);
const dataKeys = Object.keys(data);
let errorMsg = false;
// Check for required keys
schemaKeys.forEach((k) => {
if (schema[k].required) {
if (!dataKeys.includes(k)) {
errorMsg = `Missing required value env_vars.${k}`;
console.log(errorMsg);
}
}
});
//Validate the values
dataKeys.forEach((k) => {
if (schemaKeys.includes(k)) {
try {
// Check value is correct type
assert(typeof data[k] == schema[k].type);
// if enum check value is valid
if (schema[k].enum) {
assert(schema[k].enum.includes(data[k]));
}
} catch (error) {
errorMsg = `Invalid value/type for env_vars.${k}`;
}
}
});
return errorMsg;
};
module.exports = {
validateAppEnvSchema,
fetchAppEnvSchema,
validateAppEnvData
};

View File

@@ -1,6 +1,5 @@
if (!process.env.JAMBONES_HOSTING) return;
const bent = require('bent');
const crypto = require('crypto');
const assert = require('assert');
const domains = new Map();
@@ -26,17 +25,20 @@ const createAuthHeaders = () => {
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');
const response = await fetch(`${process.env.DME_BASE_URL}/dns/managed`, {
method: 'GET',
headers
});
if (!response.ok) {
logger.error({response}, 'Error retrieving domains');
return;
}
const result = await response.json();
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}`);
}
};
@@ -80,21 +82,20 @@ const createDnsRecords = async(logger, domain, name, value, ttl = 3600) => {
];
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);
const response = await fetch(`${process.env.DME_BASE_URL}/dns/managed/${domainId}/records/createMulti`, {
method: 'POST',
headers,
body: JSON.stringify(records)
});
if (!response.ok) {
logger.error({response}, 'Error creating records');
return;
}
let body;
try {
body = await res.json();
} catch (err) {
const result = await response.json();
logger.debug({result}, 'createDnsRecords: created records');
if (201 === response.status) {
return result;
}
logger.error({headers: res.headers, body}, `Error creating records, status ${res.statusCode}`);
} catch (err) {
logger.error({err}, 'Error retrieving domains');
}
@@ -103,7 +104,6 @@ const createDnsRecords = async(logger, domain, name, value, ttl = 3600) => {
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);
@@ -112,7 +112,10 @@ const deleteDnsRecords = async(logger, domain, recIds) => {
}
const domainId = domains.get(domain);
const url = `/dns/managed/${domainId}/records?${recIds.map((r) => `ids=${r}`).join('&')}`;
await del(url);
await fetch(`${process.env.DME_BASE_URL}${url}`, {
method: 'DELETE',
headers
});
return true;
} catch (err) {
console.error(err);

View File

@@ -1,7 +1,6 @@
const formData = require('form-data');
const Mailgun = require('mailgun.js');
const mailgun = new Mailgun(formData);
const bent = require('bent');
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,}))$/;
@@ -19,8 +18,9 @@ const emailSimpleText = async(logger, to, subject, text) => {
};
const sendEmailByCustomVendor = async(logger, from, to, subject, text) => {
try {
const post = bent('POST', {
const response = await fetch(process.env.CUSTOM_EMAIL_VENDOR_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...((process.env.CUSTOM_EMAIL_VENDOR_USERNAME && process.env.CUSTOM_EMAIL_VENDOR_PASSWORD) &&
({
@@ -28,22 +28,22 @@ const sendEmailByCustomVendor = async(logger, from, to, subject, text) => {
`${process.env.CUSTOM_EMAIL_VENDOR_USERNAME}:${process.env.CUSTOM_EMAIL_VENDOR_PASSWORD}`
).toString('base64')}`
}))
});
const res = await post(process.env.CUSTOM_EMAIL_VENDOR_URL, {
},
body: JSON.stringify({
from,
to,
subject,
text
});
logger.debug({
res
}, 'sent email to custom vendor.');
} catch (err) {
logger.info({
err
}, 'Error sending email From Custom email vendor');
})
});
if (!response.ok) {
logger.error({response}, 'Error sending email to custom vendor');
return;
}
const res = await response.json();
logger.debug({
res
}, 'sent email to custom vendor.');
};
const sendEmailByMailgun = async(logger, from, to, subject, text) => {

View File

@@ -57,7 +57,7 @@ function isObscureKey(bucketCredentials) {
pattern = /^([A-Za-z0-9]{4,6}X+$)/;
return pattern.test(secret_access_key);
case 'azure':
pattern = /^https:[A-Za-z0-9\/.:?=&_-]+$/;
pattern = /^([A-Za-z0-9:]{4,6}X+$)/;
return pattern.test(connection_string);
case 'google': {

View File

@@ -27,11 +27,17 @@ class DbErrorForbidden extends DbError {
super(msg);
}
}
class UserPermissionError extends Error {
constructor(msg) {
super(msg);
}
}
module.exports = {
BadRequestError,
DbError,
DbErrorBadRequest,
DbErrorUnprocessableRequest,
DbErrorForbidden
DbErrorForbidden,
UserPermissionError
};

View File

@@ -1,15 +1,11 @@
const debug = require('debug')('jambonz:api-server');
const bent = require('bent');
const basicAuth = (apiKey) => {
const header = `Bearer ${apiKey}`;
return {Authorization: header};
};
const postJSON = bent(process.env.HOMER_BASE_URL || 'http://127.0.0.1', 'POST', 'json', 200, 201);
const postPcap = bent(process.env.HOMER_BASE_URL || 'http://127.0.0.1', 'POST', 200, {
'Content-Type': 'application/json',
'Accept': 'application/json, text/plain, */*',
});
const { Readable } = require('stream');
const SEVEN_DAYS_IN_MS = (1000 * 3600 * 24 * 7);
const HOMER_BASE_URL = process.env.HOMER_BASE_URL || 'http://127.0.0.1';
const getHomerApiKey = async(logger) => {
if (!process.env.HOMER_BASE_URL || !process.env.HOMER_USERNAME || !process.env.HOMER_PASSWORD) {
@@ -17,11 +13,21 @@ const getHomerApiKey = async(logger) => {
}
try {
const obj = await postJSON('/api/v3/auth', {
username: process.env.HOMER_USERNAME,
password: process.env.HOMER_PASSWORD
const response = await fetch(`${HOMER_BASE_URL}/api/v3/auth`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: process.env.HOMER_USERNAME,
password: process.env.HOMER_PASSWORD
})
});
debug(obj);
if (!response.ok) {
logger.error({response}, 'Error retrieving apikey');
return;
}
const obj = await response.json();
logger.debug({obj}, `getHomerApiKey for user ${process.env.HOMER_USERNAME}`);
return obj.token;
} catch (err) {
@@ -36,28 +42,40 @@ const getHomerSipTrace = async(logger, apiKey, callId) => {
}
try {
const now = Date.now();
const obj = await postJSON('/api/v3/call/transaction', {
param: {
transaction: {
call: true,
registration: true,
rest: false
},
orlogic: true,
search: {
'1_call': {
callid: [callId]
},
'1_registration': {
callid: [callId]
}
},
const response = await fetch(`${HOMER_BASE_URL}/api/v3/call/transaction`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...basicAuth(apiKey)
},
timestamp: {
from: now - SEVEN_DAYS_IN_MS,
to: now
}
}, basicAuth(apiKey));
body: JSON.stringify({
param: {
transaction: {
call: true,
registration: true,
rest: false
},
orlogic: true,
search: {
'1_call': {
callid: [callId]
},
'1_registration': {
callid: [callId]
}
},
},
timestamp: {
from: now - SEVEN_DAYS_IN_MS,
to: now
}
})
});
if (!response.ok) {
logger.error({response}, 'Error retrieving messages');
return;
}
const obj = await response.json();
return obj;
} catch (err) {
logger.info({err}, `getHomerSipTrace: Error retrieving messages for callid ${callId}`);
@@ -70,34 +88,45 @@ const getHomerPcap = async(logger, apiKey, callIds, method) => {
}
try {
const now = Date.now();
const stream = await postPcap('/api/v3/export/call/messages/pcap', {
param: {
transaction: {
call: method === 'invite',
registration: method === 'register',
rest: false
},
orlogic: true,
search: {
...(method === 'invite' && {
'1_call': {
callid: callIds
}
})
,
...(method === 'register' && {
'1_registration': {
callid: callIds
}
})
},
const response = await fetch(`${HOMER_BASE_URL}/api/v3/export/call/messages/pcap`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...basicAuth(apiKey)
},
timestamp: {
from: now - SEVEN_DAYS_IN_MS,
to: now
}
}, basicAuth(apiKey));
return stream;
body: JSON.stringify({
param: {
transaction: {
call: method === 'invite',
registration: method === 'register',
rest: false
},
orlogic: true,
search: {
...(method === 'invite' && {
'1_call': {
callid: callIds
}
})
,
...(method === 'register' && {
'1_registration': {
callid: callIds
}
})
},
},
timestamp: {
from: now - SEVEN_DAYS_IN_MS,
to: now
}
})
});
if (!response.ok) {
logger.error({response}, 'Error retrieving messages');
return;
}
return Readable.fromWeb(response.body);
} catch (err) {
logger.info({err}, `getHomerPcap: Error retrieving messages for callid ${callIds}`);
}

View File

@@ -1,5 +1,4 @@
const bent = require('bent');
const getJSON = bent(process.env.JAEGER_BASE_URL || 'http://127.0.0.1', 'GET', 'json', 200);
const JAEGER_BASE_URL = process.env.JAEGER_BASE_URL || 'http://127.0.0.1';
const getJaegerTrace = async(logger, traceId) => {
if (!process.env.JAEGER_BASE_URL) {
@@ -7,7 +6,12 @@ const getJaegerTrace = async(logger, traceId) => {
return null;
}
try {
return await getJSON(`/api/v3/traces/${traceId}`);
const response = await fetch(`${JAEGER_BASE_URL}/api/v3/traces/${traceId}`);
if (!response.ok) {
logger.error({response}, 'Error retrieving spans');
return;
}
return await response.json();
} catch (err) {
const url = `${process.env.JAEGER_BASE_URL}/api/traces/${traceId}`;
logger.error({err, traceId}, `getJaegerTrace: Error retrieving spans from ${url}`);

View File

@@ -1,7 +1,4 @@
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');
@@ -10,13 +7,26 @@ const doGithubAuth = async(logger, payload) => {
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
const response = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({
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 (!response.ok) {
logger.error({response}, 'Error retrieving access_token from github');
throw new DbErrorForbidden(await response.text());
}
const obj = await response.json();
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');
@@ -25,17 +35,31 @@ const doGithubAuth = async(logger, payload) => {
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 userResponse = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${obj.access_token}`,
Accept: 'application/json',
'User-Agent': 'jambonz 1.0'
}
});
if (!userResponse.ok) {
logger.error({userResponse}, 'Error retrieving user details from github');
throw new DbErrorForbidden(await userResponse.text());
}
const userDetails = await userResponse.json();
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 emailsResponse = await fetch('https://api.github.com/user/emails', {
headers: {
Authorization: `Bearer ${obj.access_token}`,
Accept: 'application/json',
'User-Agent': 'jambonz 1.0'
}
});
if (!emailsResponse.ok) {
logger.error({emailsResponse}, 'Error retrieving emails from github');
throw new DbErrorForbidden(await emailsResponse.text());
}
const emails = await emailsResponse.json();
const primary = emails.find((e) => e.primary);
if (primary) Object.assign(userDetails, {
email: primary.email,
@@ -55,14 +79,26 @@ const doGoogleAuth = async(logger, payload) => {
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'
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({
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 (!response.ok) {
logger.error({response}, 'Error retrieving access_token from google');
throw new DbErrorForbidden(await response.text());
}
const obj = await response.json();
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');
@@ -71,12 +107,18 @@ const doGoogleAuth = async(logger, payload) => {
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'
const userDetailsResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: {
Authorization: `Bearer ${obj.access_token}`,
Accept: 'application/json',
'User-Agent': 'jambonz 1.0'
}
});
if (!userDetailsResponse.ok) {
logger.error({userDetailsResponse}, 'Error retrieving user details from google');
throw new DbErrorForbidden(await userDetailsResponse.text());
}
const userDetails = await userDetailsResponse.json();
logger.info({userDetails}, 'retrieved user details from google');
return userDetails;
} catch (err) {

View File

@@ -1,10 +1,57 @@
module.exports = [
{ name: 'Arabic, Gulf', value: 'ar-AE' },
{ name: 'Arabic, Modern Standard', value: 'ar-SA' },
{ name: 'Afrikaans', value: 'af-ZA' },
{ name: 'Basque', value: 'eu-ES' },
{ name: 'Catalan', value: 'ca-ES' },
{ name: 'Chinese, Simplified', value: 'zh-CN' },
{ name: 'Chinese, Traditional', value: 'zh-TW' },
{ name: 'Chinese (Cantonese), Hong-Kong', value: 'zh-HK' },
{ name: 'Croatian', value: 'hr-HR' },
{ name: 'Czech', value: 'cs-CZ' },
{ name: 'Danish', value: 'da-DK' },
{ name: 'Dutch', value: 'nl-NL' },
{ name: 'Australian English', value: 'en-AU' },
{ name: 'British English', value: 'en-GB' },
{ name: 'US English', value: 'en-US' },
{ name: 'Indian English', value: 'en-IN' },
{ name: 'Irish English', value: 'en-IE' },
{ name: 'New Zealand English', value: 'en-NZ' },
{ name: 'Scottish English', value: 'en-AB' },
{ name: 'South African English', value: 'en-ZA' },
{ name: 'Welsh English', value: 'en-WL' },
{ name: 'Farsi', value: 'fa-IR' },
{ name: 'Finnish', value: 'fi-FI' },
{ name: 'French', value: 'fr-FR' },
{ name: 'Canadian French', value: 'fr-CA' },
{ name: 'Galician', value: 'gl-ES' },
{ name: 'German', value: 'de-DE' },
{ name: 'Swiss German', value: 'de-CH' },
{ name: 'Greek', value: 'el-GR' },
{ name: 'Hindi', value: 'hi-IN' },
{ name: 'Hebrew', value: 'he-IL' },
{ name: 'Italian', value: 'it-IT' },
{ name: 'Indian Hindi', value: 'hi-IN' },
{ name: 'Indonesian', value: 'id-ID' },
{ name: 'Japanese', value: 'ja-JP' },
{ name: 'Korean', value: 'ko-KR' },
{ name: 'Latvian', value: 'lv-LV' },
{ name: 'Malay', value: 'ms-MY' },
{ name: 'Norwegian Bokmål', value: 'no-NO' },
{ name: 'Polish', value: 'pl-PL' },
{ name: 'Portuguese', value: 'pt-PT' },
{ name: 'Brazilian Portuguese', value: 'pt-BR' },
{ name: 'Romanian', value: 'ro-RO' },
{ name: 'Russian', value: 'ru-RU' },
{ name: 'Serbian', value: 'sr-RS' },
{ name: 'Slovak', value: 'sk-SK' },
{ name: 'Somali', value: 'so-SO' },
{ name: 'Spanish', value: 'es-ES' },
{ name: 'US Spanish', value: 'es-US' },
{ name: 'Swedish', value: 'sv-SE' },
{ name: 'Tagalog/Filipino', value: 'tl-PH' },
{ name: 'Thai', value: 'th-TH' },
{ name: 'Ukrainian', value: 'uk-UA' },
{ name: 'Vietnamese', value: 'vi-VN' },
{ name: 'Zulu', value: 'zu-ZA' }
];

View File

@@ -0,0 +1,52 @@
module.exports = [
// Nova-3
{ name: 'Nova-3', value: 'nova-3' },
{ name: 'Nova-3 General', value: 'nova-3-general' },
{ name: 'Nova-3 Medical', value: 'nova-3-medical' },
// Nova-2
{ name: 'Nova-2', value: 'nova-2' },
{ name: 'Nova-2 General', value: 'nova-2-general' },
{ name: 'Nova-2 Meeting', value: 'nova-2-meeting' },
{ name: 'Nova-2 Phonecall', value: 'nova-2-phonecall' },
{ name: 'Nova-2 Finance', value: 'nova-2-finance' },
{ name: 'Nova-2 Conversational AI', value: 'nova-2-conversationalai' },
{ name: 'Nova-2 Voicemail', value: 'nova-2-voicemail' },
{ name: 'Nova-2 Video', value: 'nova-2-video' },
{ name: 'Nova-2 Medical', value: 'nova-2-medical' },
{ name: 'Nova-2 Drivethru', value: 'nova-2-drivethru' },
{ name: 'Nova-2 Automotive', value: 'nova-2-automotive' },
{ name: 'Nova-2 ATC', value: 'nova-2-atc' },
// Nova (legacy)
{ name: 'Nova', value: 'nova' },
{ name: 'Nova General', value: 'nova-general' },
{ name: 'Nova Phonecall', value: 'nova-phonecall' },
{ name: 'Nova Medical', value: 'nova-medical' },
// Enhanced (legacy)
{ name: 'Enhanced', value: 'enhanced' },
{ name: 'Enhanced General', value: 'enhanced-general' },
{ name: 'Enhanced Meeting', value: 'enhanced-meeting' },
{ name: 'Enhanced Phonecall', value: 'enhanced-phonecall' },
{ name: 'Enhanced Finance', value: 'enhanced-finance' },
// Base (legacy)
{ name: 'Base', value: 'base' },
{ name: 'Base General', value: 'base-general' },
{ name: 'Base Meeting', value: 'base-meeting' },
{ name: 'Base Phonecall', value: 'base-phonecall' },
{ name: 'Base Finance', value: 'base-finance' },
{ name: 'Base Conversational AI', value: 'base-conversationalai' },
{ name: 'Base Voicemail', value: 'base-voicemail' },
{ name: 'Base Video', value: 'base-video' },
// Whisper
{ name: 'Whisper Tiny', value: 'whisper-tiny' },
{ name: 'Whisper Base', value: 'whisper-base' },
{ name: 'Whisper Small', value: 'whisper-small' },
{ name: 'Whisper Medium', value: 'whisper-medium' },
{ name: 'Whisper Large', value: 'whisper-large' },
{ name: 'Whisper', value: 'whisper' },
];

View File

@@ -1,68 +1,320 @@
module.exports = [
{
locale: 'en-ph',
localeName: 'English (PH)',
name: 'Amalthea English (PH) Female Aura-2',
value: 'aura-2-amalthea-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Andromeda English (US) Female Aura-2',
value: 'aura-2-andromeda-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Apollo English (US) Male Aura-2',
value: 'aura-2-apollo-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Arcas English (US) Male Aura-2',
value: 'aura-2-arcas-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Aries English (US) Male Aura-2',
value: 'aura-2-aries-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Asteria English (US) Female Aura-2',
value: 'aura-2-asteria-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Athena English (US) Female Aura-2',
value: 'aura-2-athena-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Atlas English (US) Male Aura-2',
value: 'aura-2-atlas-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Aurora English (US) Female Aura-2',
value: 'aura-2-aurora-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Callista English (US) Female Aura-2',
value: 'aura-2-callista-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Cora English (US) Female Aura-2',
value: 'aura-2-cora-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Cordelia English (US) Female Aura-2',
value: 'aura-2-cordelia-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Delia English (US) Female Aura-2',
value: 'aura-2-delia-en'
},
{
locale: 'en-gb',
localeName: 'English (GB)',
name: 'Draco English (GB) Male Aura-2',
value: 'aura-2-draco-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Electra English (US) Female Aura-2',
value: 'aura-2-electra-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Harmonia English (US) Female Aura-2',
value: 'aura-2-harmonia-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Helena English (US) Female Aura-2',
value: 'aura-2-helena-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Hera English (US) Female Aura-2',
value: 'aura-2-hera-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Hermes English (US) Male Aura-2',
value: 'aura-2-hermes-en'
},
{
locale: 'en-au',
localeName: 'English (AU)',
name: 'Hyperion English (AU) Male Aura-2',
value: 'aura-2-hyperion-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Iris English (US) Female Aura-2',
value: 'aura-2-iris-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Janus English (US) Female Aura-2',
value: 'aura-2-janus-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Juno English (US) Female Aura-2',
value: 'aura-2-juno-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Jupiter English (US) Male Aura-2',
value: 'aura-2-jupiter-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Luna English (US) Female Aura-2',
value: 'aura-2-luna-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Mars English (US) Male Aura-2',
value: 'aura-2-mars-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Minerva English (US) Female Aura-2',
value: 'aura-2-minerva-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Neptune English (US) Male Aura-2',
value: 'aura-2-neptune-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Odysseus English (US) Male Aura-2',
value: 'aura-2-odysseus-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Ophelia English (US) Female Aura-2',
value: 'aura-2-ophelia-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Orion English (US) Male Aura-2',
value: 'aura-2-orion-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Orpheus English (US) Male Aura-2',
value: 'aura-2-orpheus-en'
},
{
locale: 'en-gb',
localeName: 'English (GB)',
name: 'Pandora English (GB) Female Aura-2',
value: 'aura-2-pandora-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Phoebe English (US) Female Aura-2',
value: 'aura-2-phoebe-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Pluto English (US) Male Aura-2',
value: 'aura-2-pluto-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Saturn English (US) Male Aura-2',
value: 'aura-2-saturn-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Selene English (US) Female Aura-2',
value: 'aura-2-selene-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Thalia English (US) Female Aura-2',
value: 'aura-2-thalia-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Theia English (US) Female Aura-2',
value: 'aura-2-theia-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Vesta English (US) Female Aura-2',
value: 'aura-2-vesta-en'
},
{
locale: 'en-us',
localeName: 'English (US)',
name: 'Zeus English (US) Male Aura-2',
value: 'aura-2-zeus-en'
},
{
locale: 'en-US',
localeName: 'English (US)',
name: 'Asteria English (US) Female',
name: 'Asteria English (US) Female Aura-1',
value: 'aura-asteria-en'
},
{
locale: 'en-US',
localeName: 'English (US)',
name: 'Luna English (US) Female',
name: 'Luna English (US) Female Aura-1',
value: 'aura-luna-en'
},
{
locale: 'en-US',
localeName: 'English (US)',
name: 'Stella English (US) Female',
name: 'Stella English (US) Female Aura-1',
value: 'aura-stella-en'
},
{
locale: 'en-GB',
localeName: 'English (UK)',
name: 'Stella English (UK) Female',
name: 'Stella English (UK) Female Aura-1',
value: 'aura-athena-en'
},
{
locale: 'en-US',
localeName: 'English (US)',
name: 'Hera English (US) Female',
name: 'Hera English (US) Female Aura-1',
value: 'aura-hera-en'
},
{
locale: 'en-US',
localeName: 'English (US)',
name: 'Orion English (US) Male',
name: 'Orion English (US) Male Aura-1',
value: 'aura-orion-en'
},
{
locale: 'en-US',
localeName: 'English (US)',
name: 'Arcas English (US) Male',
name: 'Arcas English (US) Male Aura-1',
value: 'aura-arcas-en'
},
{
locale: 'en-US',
localeName: 'English (US)',
name: 'Perseus English (US) Male',
name: 'Perseus English (US) Male Aura-1',
value: 'aura-perseus-en'
},
{
locale: 'en-IE',
localeName: 'English (Ireland)',
name: 'Angus English (Ireland) Male',
name: 'Angus English (Ireland) Male Aura-1',
value: 'aura-angus-en'
},
{
locale: 'en-US',
localeName: 'English (US)',
name: 'Orpheus English (US) Male',
name: 'Orpheus English (US) Male Aura-1',
value: 'aura-orpheus-en'
},
{
locale: 'en-gb',
localeName: 'English (US)',
name: 'Helios English (GB) Male Aura-1',
value: 'aura-helios-en'
},
{
locale: 'en-US',
localeName: 'English (US)',
name: 'Zeus English (US) Male',
name: 'Zeus English (US) Male Aura-1',
value: 'aura-zeus-en'
},
];

View File

@@ -3,5 +3,6 @@ module.exports = [
{ name: 'PlayHT2.0-turbo', value: 'PlayHT2.0-turbo' },
{ name: 'PlayHT2.0', value: 'PlayHT2.0' },
{ name: 'PlayHT1.0', value: 'PlayHT1.0' },
{ name: 'Dialog 1.0', value: 'PlayDialog'}
];

View File

@@ -1,4 +1,5 @@
module.exports = [
{ name: 'Arcana', value: 'arcana' },
{ name: 'Mist', value: 'mist' },
{ name: 'Mistv2', value: 'mistv2' },
{ name: 'V1', value: 'v1' },

View File

@@ -1,5 +1,6 @@
module.exports = [
{ name: 'TTS-1', value: 'tts-1' },
{ name: 'TTS-1-HD', value: 'tts-1-hd' },
{ name: 'GPT-4o mini TTS', value: 'gpt-4o-mini-tts' },
];

View File

@@ -1,16 +1,66 @@
module.exports = [
{
value: 'en-US',
name: 'English',
voices: [
{
value: 'English-US.Female-1',
name: 'Female',
},
{
value: 'English-US.Male-1',
name: 'Male',
},
],
},
];
module.exports = (() => {
const voices = [
{ value: 'Magpie-Multilingual.EN-US.Female.Female-1', name: 'Female Magpie' },
{ value: 'Magpie-Multilingual.EN-US.Female.Calm', name: 'Female Calm' },
{ value: 'Magpie-Multilingual.EN-US.Female.Fearful', name: 'Female Fearful' },
{ value: 'Magpie-Multilingual.EN-US.Female.Happy', name: 'Female Happy' },
{ value: 'Magpie-Multilingual.EN-US.Female.Angry', name: 'Female Angry' },
{ value: 'Magpie-Multilingual.EN-US.Female.Neutral', name: 'Female Neutral' },
{ value: 'Magpie-Multilingual.EN-US.Male.Calm', name: 'Male Calm' },
{ value: 'Magpie-Multilingual.EN-US.Male.Fearful', name: 'Male Fearful' },
{ value: 'Magpie-Multilingual.EN-US.Male.Happy', name: 'Male Happy' },
{ value: 'Magpie-Multilingual.EN-US.Male.Neutral', name: 'Male Neutral' },
{ value: 'Magpie-Multilingual.EN-US.Male.Angry', name: 'Male Angry' },
{ value: 'Magpie-Multilingual.EN-US.Male.Disgusted', name: 'Male Disgusted' },
{ value: 'Magpie-Multilingual.EN-US.Male.Male-1', name: 'Male Magpie' },
{ value: 'Magpie-Multilingual.FR-FR.Male.Male-1', name: 'Male Magpie' },
{ value: 'Magpie-Multilingual.FR-FR.Female.Female-1', name: 'Female Magpie' },
{ value: 'Magpie-Multilingual.FR-FR.Female.Angry', name: 'Female Angry' },
{ value: 'Magpie-Multilingual.FR-FR.Female.Calm', name: 'Female Calm' },
{ value: 'Magpie-Multilingual.FR-FR.Female.Disgust', name: 'Female Disgust' },
{ value: 'Magpie-Multilingual.FR-FR.Female.Sad', name: 'Female Sad' },
{ value: 'Magpie-Multilingual.FR-FR.Female.Happy', name: 'Female Happy' },
{ value: 'Magpie-Multilingual.FR-FR.Female.Fearful', name: 'Female Fearful' },
{ value: 'Magpie-Multilingual.FR-FR.Female.Neutral', name: 'Female Neutral' },
{ value: 'Magpie-Multilingual.FR-FR.Male.Neutral', name: 'Male Neutral' },
{ value: 'Magpie-Multilingual.FR-FR.Male.Angry', name: 'Male Angry' },
{ value: 'Magpie-Multilingual.FR-FR.Male.Calm', name: 'Male Calm' },
{ value: 'Magpie-Multilingual.FR-FR.Male.Sad', name: 'Male Sad' },
{ value: 'Magpie-Multilingual.ES-US.Male.Male-1', name: 'Male Magpie' },
{ value: 'Magpie-Multilingual.ES-US.Female.Female-1', name: 'Female Magpie' },
{ value: 'Magpie-Multilingual.ES-US.Female.Neutral', name: 'Female Neutral' },
{ value: 'Magpie-Multilingual.ES-US.Male.Neutral', name: 'Male Neutral' },
{ value: 'Magpie-Multilingual.ES-US.Male.Angry', name: 'Male Angry' },
{ value: 'Magpie-Multilingual.ES-US.Female.Angry', name: 'Female Angry' },
{ value: 'Magpie-Multilingual.ES-US.Female.Happy', name: 'Female Happy' },
{ value: 'Magpie-Multilingual.ES-US.Male.Happy', name: 'Male Happy' },
{ value: 'Magpie-Multilingual.ES-US.Female.Calm', name: 'Female Calm' },
{ value: 'Magpie-Multilingual.ES-US.Male.Calm', name: 'Male Calm' },
{ value: 'Magpie-Multilingual.ES-US.Female.Pleasant_Surprise', name: 'Female Pleasant Surprise' },
{ value: 'Magpie-Multilingual.ES-US.Male.Pleasant_Surprise', name: 'Male Pleasant Surprise' },
{ value: 'Magpie-Multilingual.ES-US.Female.Sad', name: 'Female Sad' },
{ value: 'Magpie-Multilingual.ES-US.Male.Sad', name: 'Male Sad' },
{ value: 'Magpie-Multilingual.ES-US.Male.Disgust', name: 'Male Disgust' }
];
return [
{
value: 'en-US',
name: 'English',
voices: [
{ value: 'English-US.Female-1', name: 'Female' },
{ value: 'English-US.Male-1', name: 'Male' },
...voices.filter((voice) => voice.value.includes('EN-US'))]
},
{
value: 'fr-FR',
name: 'French',
voices: voices.filter((voice) => voice.value.includes('FR-FR'))
},
{
value: 'es-US',
name: 'Spanish',
voices: voices.filter((voice) => voice.value.includes('ES-US'))
}
];
})();

View File

@@ -3,7 +3,6 @@ const { TranscribeClient, ListVocabulariesCommand } = require('@aws-sdk/client-t
const { Deepgram } = require('@deepgram/sdk');
const sdk = require('microsoft-cognitiveservices-speech-sdk');
const { SpeechClient } = require('@soniox/soniox-node');
const bent = require('bent');
const fs = require('fs');
const { AssemblyAI } = require('assemblyai');
const {decrypt, obscureKey} = require('./encrypt-decrypt');
@@ -49,6 +48,12 @@ const SttOpenaiLanguagesVoices = require('./speech-data/stt-openai');
const SttModelOpenai = require('./speech-data/stt-model-openai');
const sttModelDeepgram = require('./speech-data/stt-model-deepgram');
function capitalizeFirst(str) {
if (!str) return str;
return str.charAt(0).toUpperCase() + str.slice(1);
}
const testSonioxStt = async(logger, credentials) => {
@@ -288,44 +293,46 @@ const testMicrosoftTts = async(logger, synthAudio, credentials) => {
const testWellSaidTts = async(logger, credentials) => {
const {api_key} = credentials;
try {
const post = bent('https://api.wellsaidlabs.com', 'POST', 'buffer', {
const response = await fetch('https://api.wellsaidlabs.com/v1/tts/stream', {
method: 'POST',
headers: {
'X-Api-Key': api_key,
'Accept': 'audio/mpeg',
'Content-Type': 'application/json'
});
const mp3 = await post('/v1/tts/stream', {
},
body: JSON.stringify({
text: 'Hello, world',
speaker_id: '3'
});
return mp3;
} catch (err) {
logger.info({err}, 'testWellSaidTts returned error');
throw err;
})
});
if (!response.ok) {
throw new Error('failed to synthesize speech');
}
return response.body;
};
const testElevenlabs = async(logger, credentials) => {
const {api_key, model_id} = credentials;
try {
const post = bent('https://api.elevenlabs.io', 'POST', 'buffer', {
const response = await fetch('https://api.elevenlabs.io/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM', {
method: 'POST',
headers: {
'xi-api-key': api_key,
'Accept': 'audio/mpeg',
'Content-Type': 'application/json'
});
const mp3 = await post('/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM', {
},
body: JSON.stringify({
text: 'Hello',
model_id,
voice_settings: {
stability: 0.5,
similarity_boost: 0.5
}
});
return mp3;
} catch (err) {
logger.info({err}, 'synthEvenlabs returned error');
throw err;
})
});
if (!response.ok) {
throw new Error('failed to synthesize speech');
}
return response.body;
};
const testPlayHT = async(logger, synthAudio, credentials) => {
@@ -362,14 +369,14 @@ const testRimelabs = async(logger, synthAudio, credentials) => {
{
vendor: 'rimelabs',
credentials,
language: 'en-US',
language: 'eng',
voice: 'amber',
text: 'Hi there and welcome to jambones!',
renderForCaching: true
}
);
} catch (err) {
logger.info({err}, 'synth Playht returned error');
logger.info({err}, 'synth rimelabs returned error');
throw err;
}
};
@@ -387,7 +394,7 @@ const testWhisper = async(logger, synthAudio, credentials) => {
}
);
} catch (err) {
logger.info({err}, 'synthEvenlabs returned error');
logger.info({err}, 'synth whisper returned error');
throw err;
}
};
@@ -466,18 +473,18 @@ const testVerbioTts = async(logger, synthAudio, credentials) => {
};
const testVerbioStt = async(logger, getVerbioAccessToken, credentials) => {
const token = await getVerbioAccessToken(credentials);
try {
const post = bent('https://us.rest.speechcenter.verbio.com', 'POST', 'json', {
const response = await fetch('https://us.rest.speechcenter.verbio.com/api/v1/recognize?language=en-US&version=V1', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token.access_token}`,
'User-Agent': 'jambonz',
'Content-Type': 'audio/wav'
});
const json = await post('/api/v1/recognize?language=en-US&version=V1',
fs.readFileSync(`${__dirname}/../../data/test_audio.wav`));
logger.debug({json}, 'successfully speech to text from verbio');
} catch (err) {
logger.info({err}, 'testWellSaidTts returned error');
throw err;
},
body: fs.readFileSync(`${__dirname}/../../data/test_audio.wav`)
});
if (!response.ok) {
logger.error({Error: await response.text()}, 'Error transcribing speech');
throw new Error('failed to transcribe speech');
}
};
@@ -546,16 +553,17 @@ const testAssemblyStt = async(logger, credentials) => {
const testVoxistStt = async(logger, credentials) => {
const {api_key} = credentials;
try {
const get = bent('https://api-asr.voxist.com', 'GET', 'json', {
const response = await fetch('https://api-asr.voxist.com/clients', {
headers: {
'Accept': 'application/json',
'x-lvl-key': api_key
});
await get('/clients');
} catch (err) {
logger.info({err}, 'failed to get clients from Voxist');
throw err;
}
});
if (!response.ok) {
logger.error({response}, 'Error retrieving clients');
throw new Error('failed to get clients');
}
return response.json();
};
const getSpeechCredential = (credential, logger) => {
@@ -634,6 +642,7 @@ function decryptCredential(obj, credential, logger, isObscureKey = true) {
obj.deepgram_stt_uri = o.deepgram_stt_uri;
obj.deepgram_stt_use_tls = o.deepgram_stt_use_tls;
obj.deepgram_tts_uri = o.deepgram_tts_uri;
obj.model_id = o.model_id;
}
else if ('ibm' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
@@ -665,6 +674,7 @@ function decryptCredential(obj, credential, logger, isObscureKey = true) {
obj.api_key = isObscureKey ? obscureKey(o.api_key) : o.api_key;
obj.user_id = o.user_id;
obj.voice_engine = o.voice_engine;
obj.playht_tts_uri = o.playht_tts_uri;
obj.options = o.options;
} else if ('cartesia' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
@@ -812,17 +822,19 @@ async function getLanguagesVoicesForAws(credential, getTtsVoices, logger) {
async function getLanguagesVoicesForMicrosoft(credential, getTtsVoices, logger) {
if (credential) {
try {
const get = bent('https://westus.tts.speech.microsoft.com', 'GET', 'json', {
'Ocp-Apim-Subscription-Key' : credential.api_key
});
const voices = await get('/cognitiveservices/voices/list');
const tts = parseMicrosoftLanguagesVoices(voices);
return tranform(tts, SttMicrosoftLanguagesVoices);
} catch (err) {
logger.info('Error while fetching Microsoft languages, voices, return predefined values', err);
const {region, api_key} = credential;
const response = await fetch(`https://${region}.tts.speech.microsoft.com/cognitiveservices/voices/list`, {
headers: {
'Ocp-Apim-Subscription-Key': api_key
}
});
if (!response.ok) {
logger.error({response}, 'Error fetching Microsoft voices');
throw new Error('failed to list voices');
}
const voices = await response.json();
const tts = parseMicrosoftLanguagesVoices(voices);
return tranform(tts, SttMicrosoftLanguagesVoices);
}
return tranform(TtsMicrosoftLanguagesVoices, SttMicrosoftLanguagesVoices);
}
@@ -847,8 +859,47 @@ async function getLanguagesVoicesForNuane(credential, getTtsVoices, logger) {
return tranform(TtsNuanceLanguagesVoices, SttNuanceLanguagesVoices);
}
async function getLanguagesVoicesForDeepgram(credential) {
return tranform(TtsLanguagesDeepgram, SttDeepgramLanguagesVoices, TtsModelDeepgram);
async function getLanguagesVoicesForDeepgram(credential, getTtsVoices, logger) {
if (credential) {
const {model_id, api_key, deepgram_stt_uri, deepgram_tts_uri} = credential;
// currently just fetching STT and TTS models from Deepgram cloud
if (!deepgram_stt_uri && !deepgram_tts_uri) {
const response = await fetch('https://api.deepgram.com/v1/models', {
headers: {
'Authorization': `Token ${api_key}`
}
});
if (!response.ok) {
logger.error({response}, 'Error fetching Deepgram voices');
throw new Error('failed to list voices');
}
const {stt, tts} = await response.json();
let sttLangs = SttDeepgramLanguagesVoices;
const sttModels = Array.from(
new Map(
stt.map((m) => [m.canonical_name, { name: capitalizeFirst(m.canonical_name), value: m.canonical_name }])
).values()
).sort((a, b) => a.name.localeCompare(b.name));
const ttsModels = Array.from(
new Map(
tts.map((m) => [m.canonical_name, { name: capitalizeFirst(m.canonical_name), value: m.canonical_name }])
).values()
).sort((a, b) => a.name.localeCompare(b.name));
// if model_id is not provided, return all models, all voices, all languages
if (!model_id) {
return tranform(TtsLanguagesDeepgram, sttLangs, ttsModels, sttModels);
}
const selectedSttModel = stt.find((m) => m.canonical_name === model_id);
const selectedSttLangs = selectedSttModel ? selectedSttModel.languages : [];
sttLangs = SttDeepgramLanguagesVoices.filter((l) => {
return selectedSttLangs.includes(l.value);
});
return tranform(TtsLanguagesDeepgram, sttLangs, ttsModels, sttModels);
}
}
return tranform(TtsLanguagesDeepgram, SttDeepgramLanguagesVoices,
TtsModelDeepgram, sttModelDeepgram.sort((a, b) => a.name.localeCompare(b.name)));
}
async function getLanguagesVoicesForIbm(credential, getTtsVoices, logger) {
@@ -885,14 +936,27 @@ async function getLanguagesVoicesForSpeechmatics(credential) {
async function getLanguagesVoicesForElevenlabs(credential) {
if (credential) {
const get = bent('https://api.elevenlabs.io', 'GET', 'json', {
'xi-api-key' : credential.api_key
const headers = {
'xi-api-key': credential.api_key
};
const getModelPromise = fetch('https://api.elevenlabs.io/v1/models', {
headers
});
const getVoicePromise = fetch('https://api.elevenlabs.io/v1/voices', {
headers
});
const [langResp, voiceResp] = await Promise.all([getModelPromise, getVoicePromise]);
const [langResp, voiceResp] = await Promise.all([get('/v1/models'), get('/v1/voices')]);
if (!langResp.ok || !voiceResp.ok) {
throw new Error('failed to list voices');
}
const model = langResp.find((m) => m.model_id === credential.model_id);
const models = langResp.map((m) => {
const langs = await langResp.json();
const voicesR = await voiceResp.json();
const model = langs.find((m) => m.model_id === credential.model_id);
const models = langs.map((m) => {
return {
value: m.model_id,
name: m.name
@@ -908,7 +972,7 @@ async function getLanguagesVoicesForElevenlabs(credential) {
if (languages && languages.length > 0) {
// using if condition to avoid \n character in name
const voices = voiceResp ? voiceResp.voices.map((v) => {
const voices = voicesR ? voicesR.voices.map((v) => {
let name = `${v.name}${v.category !== 'premade' ? ` (${v.category.trim()})` : ''} - (`;
if (v.labels.accent) name += `${v.labels.accent}, `;
if (v.labels.description) name += `${v.labels.description}, `;
@@ -946,18 +1010,28 @@ const concat = (a) => {
const fetchLayHTVoices = async(credential) => {
if (credential) {
const get = bent('https://api.play.ht', 'GET', 'json', {
'AUTHORIZATION' : credential.api_key,
const headers = {
'AUTHORIZATION': credential.api_key,
'X-USER-ID': credential.user_id,
'Accept': 'application/json'
};
const response = await fetch('https://api.play.ht/api/v2/voices', {
headers
});
const voices = await get('/api/v2/voices');
if (!response.ok) {
throw new Error('failed to list voices');
}
const voices = await response.json();
let clone_voices = [];
try {
// try if the account has permission to cloned voice
//otherwise ignore this.
clone_voices = await get('/api/v2/cloned-voices');
const clone_voices_Response = await fetch('https://api.play.ht/api/v2/cloned-voices', {
headers
});
if (clone_voices_Response.ok) {
clone_voices = await clone_voices_Response.json();
}
} catch {}
return [clone_voices, voices];
}
@@ -1032,17 +1106,22 @@ async function getLanguagesVoicesForPlayHT(credential) {
async function getLanguagesVoicesForRimelabs(credential) {
const model_id = credential ? credential.model_id : null;
const get = bent('https://users.rime.ai', 'GET', 'json', {
'Accept': 'application/json'
const response = await fetch('https://users.rime.ai//data/voices/all-v2.json', {
headers: {
'Accept': 'application/json'
}
});
const voices = await get('/data/voices/all-v2.json');
if (!response.ok) {
throw new Error('failed to list models');
}
const voices = await response.json();
const modelVoices = model_id ? voices[model_id] :
Object.keys(voices).length > 0 ? voices[Object.keys(voices)[0]] : [];
const ttsVoices = Object.entries(modelVoices).map(([key, voices]) => ({
value: key,
name: key.charAt(0).toUpperCase() + key.slice(1),
name: capitalizeFirst(key),
voices: voices.map((v) => ({
name: v.charAt(0).toUpperCase() + v.slice(1),
name: capitalizeFirst(v),
value: v
}))
}));
@@ -1238,14 +1317,18 @@ function parseVerbioLanguagesVoices(data) {
const fetchCartesiaVoices = async(credential) => {
if (credential) {
const get = bent('https://api.cartesia.ai', 'GET', 'json', {
'X-API-Key' : credential.api_key,
'Cartesia-Version': '2024-06-10',
'Accept': 'application/json'
const response = await fetch('https://api.cartesia.ai/voices', {
headers: {
'X-API-Key': credential.api_key,
'Cartesia-Version': '2024-06-10',
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error('failed to list voices');
}
const voices = await get('/voices');
return voices;
return await response.json();
}
};

View File

@@ -6,7 +6,6 @@ assert.ok(process.env.STRIPE_API_KEY || process.env.NODE_ENV === 'test',
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');
const qs = require('qs');
const toBase64 = (str) => Buffer.from(str || '', 'utf8').toString('base64');
@@ -14,10 +13,47 @@ 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 STRIPE_BASE_URL = process.env.STRIPE_BASE_URL || 'http://127.0.0.1';
const getJSON = async(path) => {
const response = await fetch(`${STRIPE_BASE_URL}${path}`, {
headers: {
'Content-Type': 'application/json',
...basicAuth()
}
});
if (!response.ok) {
throw new Error(`Error retrieving ${path} from stripe: ${response.status}`);
}
return await response.json();
};
const postForm = async(path, body) => {
const response = await fetch(`${STRIPE_BASE_URL}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...basicAuth()
},
body
});
if (!response.ok) {
throw new Error(`Error posting to ${path} from stripe: ${response.status}`);
}
return await response.text();
};
const deleteJSON = async(path) => {
const response = await fetch(`${STRIPE_BASE_URL}${path}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
...basicAuth()
}
});
if (!response.ok) {
throw new Error(`Error deleting ${path} from stripe: ${response.status}`);
}
return await response.json();
};
//const debug = require('debug')('jambonz:api-server');
const listProducts = async(logger) => await getJSON('/products?active=true');

8529
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "jambonz-api-server",
"version": "0.9.3",
"version": "0.9.4",
"description": "",
"main": "app.js",
"scripts": {
@@ -27,17 +27,17 @@
"@deepgram/sdk": "^1.21.0",
"@google-cloud/speech": "^6.5.0",
"@google-cloud/storage": "^7.9.0",
"@jambonz/db-helpers": "^0.9.3",
"@jambonz/db-helpers": "^0.9.12",
"@jambonz/lamejs": "^1.2.2",
"@jambonz/mw-registrar": "^0.2.7",
"@jambonz/realtimedb-helpers": "^0.8.13",
"@jambonz/speech-utils": "^0.2.3",
"@jambonz/speech-utils": "^0.2.10",
"@jambonz/time-series": "^0.2.8",
"@jambonz/verb-specifications": "^0.0.72",
"@jambonz/verb-specifications": "^0.0.104",
"@soniox/soniox-node": "^1.2.2",
"ajv": "^8.17.1",
"argon2": "^0.40.1",
"assemblyai": "^4.3.4",
"bent": "^7.3.12",
"cors": "^2.8.5",
"debug": "^4.3.4",
"express": "^4.19.2",
@@ -45,6 +45,7 @@
"form-data": "^4.0.0",
"helmet": "^7.1.0",
"ibm-watson": "^9.0.1",
"is-valid-hostname": "^1.0.2",
"jsonwebtoken": "^9.0.2",
"mailgun.js": "^10.2.1",
"microsoft-cognitiveservices-speech-sdk": "1.36.0",
@@ -69,8 +70,6 @@
"eslint-plugin-promise": "^6.1.1",
"husky": "9.0.11",
"nyc": "^15.1.0",
"request": "^2.88.2",
"request-promise-native": "^1.0.9",
"tape": "^5.7.5"
}
}

View File

@@ -1,11 +1,8 @@
const test = require('tape') ;
const ADMIN_TOKEN = '38700987-c7a4-4685-a5bb-af378f9734de';
const authAdmin = {bearer: ADMIN_TOKEN};
const SP_TOKEN = '38700987-c7a4-4685-a5bb-af378f9734ds';
const authSP = {bearer: ADMIN_TOKEN};
const ACC_TOKEN = '38700987-c7a4-4685-a5bb-af378f9734da';
const authAcc = {bearer: ADMIN_TOKEN};
const request = require('request-promise-native').defaults({
const { createClient } = require('./http-client');
const request = createClient({
baseUrl: 'http://127.0.0.1:3000/v1'
});
const {

View File

@@ -1,7 +1,8 @@
const test = require('tape') ;
const ADMIN_TOKEN = '38700987-c7a4-4685-a5bb-af378f9734de';
const authAdmin = {bearer: ADMIN_TOKEN};
const request = require('request-promise-native').defaults({
const { createClient } = require('./http-client');
const request = createClient({
baseUrl: 'http://127.0.0.1:3000/v1'
});
const {createVoipCarrier, createServiceProvider,

View File

@@ -1,11 +1,13 @@
const test = require('tape') ;
const ADMIN_TOKEN = '38700987-c7a4-4685-a5bb-af378f9734de';
const authAdmin = {bearer: ADMIN_TOKEN};
const request = require('request-promise-native').defaults({
const { createClient } = require('./http-client');
const request = createClient({
baseUrl: 'http://127.0.0.1:3000/v1'
});
const {createVoipCarrier, createServiceProvider, createPhoneNumber, createAccount, deleteObjectBySid} = require('./utils');
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});

View File

@@ -1,6 +1,7 @@
const test = require('tape');
const jwt = require('jsonwebtoken');
const request = require('request-promise-native').defaults({
const { createClient } = require('./http-client');
const request = createClient({
baseUrl: 'http://127.0.0.1:3000/v1'
});
const consoleLogger = { debug: console.log, info: console.log, error: console.error }
@@ -47,8 +48,7 @@ test('Create Call Success With Synthesizer in Payload', async (t) => {
});
// THEN
t.ok(result.statusCode === 201, 'successfully created Call without Synthesizer && application_sid');
const fs_request = await getLastRequestFromFeatureServer('15083778299_createCall');
const obj = JSON.parse(fs_request);
const obj = await getLastRequestFromFeatureServer('15083778299_createCall');
t.ok(obj.body.speech_synthesis_vendor == 'google', 'speech synthesizer successfully added')
t.ok(obj.body.speech_recognizer_vendor == 'google', 'speech recognizer successfully added')
});
@@ -82,7 +82,7 @@ test('Create Call Success Without Synthesizer in Payload', async (t) => {
}
}
}).then(data => { t.ok(false, 'Create Call should not be success') })
.catch(error => { t.ok(error.response.statusCode === 400, 'Call failed for no synthesizer') });
.catch(error => { t.ok(error.statusCode === 400, 'Call failed for no synthesizer') });
});
test("Create call with application sid and app_json", async(t) => {
@@ -150,7 +150,6 @@ result = await request.post(`/Accounts/${account_sid}/Calls`, {
});
// THEN
t.ok(result.statusCode === 201, 'successfully created Call without Synthesizer && application_sid');
const fs_request = await getLastRequestFromFeatureServer('15083778299_createCall');
const obj = JSON.parse(fs_request);
const obj = await getLastRequestFromFeatureServer('15083778299_createCall');
t.ok(obj.body.app_json == app_json, 'app_json successfully added')
});

View File

@@ -1,7 +1,8 @@
const test = require('tape') ;
const { createClient } = require('./http-client');
const ADMIN_TOKEN = '38700987-c7a4-4685-a5bb-af378f9734de';
const authAdmin = {bearer: ADMIN_TOKEN};
const request = require('request-promise-native').defaults({
const request = createClient({
baseUrl: 'http://127.0.0.1:3000/v1'
});

View File

@@ -32,3 +32,11 @@ test('add predefined carriers', (t) => {
t.end();
});
});
test('prepare permissions', (t) => {
exec(`mysql -h 127.0.0.1 -u root --protocol=tcp --port=3360 -D jambones_test < ${__dirname}/../db/prepare-permissions-test.sql`, (err, stdout, stderr) => {
if (err) return t.end(err);
t.pass('permissions prepared');
t.end();
});
});

View File

@@ -1,7 +1,5 @@
const test = require('tape');
const {emailSimpleText} = require('../lib/utils/email-utils');
const bent = require('bent');
const getJSON = bent('json')
const logger = {
debug: () =>{},
info: () => {}
@@ -15,7 +13,7 @@ test('email-test', async(t) => {
await emailSimpleText(logger, 'test@gmail.com', 'subject', 'body text');
const obj = await getJSON(`http://127.0.0.1:3101/lastRequest/custom_email_vendor`);
const obj = await (await fetch(`http://127.0.0.1:3101/lastRequest/custom_email_vendor`)).json();
t.ok(obj.headers['Content-Type'] == 'application/json');
t.ok(obj.headers.Authorization == 'Basic VVNFUk5BTUU6UEFTU1dPUkQ=');
t.ok(obj.body.from == 'jambonz Support <support@jambonz.org>');

View File

@@ -1,5 +1,6 @@
const test = require('tape');
const request = require("request-promise-native").defaults({
const { createClient } = require('./http-client');
const request = createClient({
baseUrl: "http://127.0.0.1:3000/v1",
});

172
test/http-client.js Normal file
View File

@@ -0,0 +1,172 @@
/**
* Fetch-based HTTP client that mimics the request-promise-native API
*/
class HttpClient {
constructor(defaults = {}) {
this.defaults = defaults;
this.baseUrl = defaults.baseUrl || '';
}
/**
* Make an HTTP GET request
*/
async get(url, options = {}) {
return this._makeRequest(url, 'GET', options);
}
/**
* Make an HTTP POST request
*/
async post(url, options = {}) {
return this._makeRequest(url, 'POST', options);
}
/**
* Make an HTTP PUT request
*/
async put(url, options = {}) {
return this._makeRequest(url, 'PUT', options);
}
/**
* Make an HTTP DELETE request
*/
async delete(url, options = {}) {
return this._makeRequest(url, 'DELETE', options);
}
/**
* Private method to handle all HTTP requests
*/
async _makeRequest(url, method, options = {}) {
const {
auth,
body,
json = true, // Changed default to true since most API calls expect JSON
qs = {},
simple = true,
resolveWithFullResponse = false
} = options;
// Build full URL with query parameters
const fullUrl = this._buildUrl(url, qs);
// Set up headers
const headers = {};
if (auth?.bearer) {
headers['Authorization'] = `Bearer ${auth.bearer}`;
}
// Set JSON headers for all requests when json is true
if (json) {
headers['Accept'] = 'application/json';
// Only set Content-Type when sending data
if (['POST', 'PUT', 'PATCH'].includes(method) && body) {
headers['Content-Type'] = 'application/json';
}
}
// Build request options
const fetchOptions = {
method,
headers
};
// Add request body if needed
if (body && ['POST', 'PUT', 'PATCH'].includes(method)) {
fetchOptions.body = json ? JSON.stringify(body) : body;
}
try {
// Make the request
const response = await fetch(fullUrl, fetchOptions);
// Clone the response before consuming it
// This allows us to use the body in error handling if needed
const clonedResponse = response.clone();
// Parse response body based on content type - only once
let responseBody = null;
if (response.status !== 204) { // No content
if (json) {
try {
responseBody = await response.json();
} catch (e) {
// If can't parse JSON, get text
responseBody = await clonedResponse.text();
}
} else {
responseBody = await response.text();
}
}
// Handle errors if simple mode is enabled
if (simple && !response.ok) {
const error = new Error(`Request failed with status code ${response.status}`);
error.statusCode = response.status;
error.body = responseBody; // Include the already parsed body
throw error;
}
// Return full response object or just body based on options
if (resolveWithFullResponse) {
return {
statusCode: response.status,
body: responseBody,
headers: Object.fromEntries(response.headers.entries())
};
}
return responseBody;
} catch (error) {
if (!simple) {
// If simple=false, return error response instead of throwing
return {
statusCode: error.statusCode || 500,
body: error.body || { message: error.message }
};
}
throw error;
}
}
/**
* Build URL with query parameters
*/
_buildUrl(url, qs) {
// Start with base URL
let fullUrl = this.baseUrl + url;
// Add query parameters
if (Object.keys(qs).length > 0) {
const params = new URLSearchParams();
Object.entries(qs).forEach(([key, value]) => {
params.append(key, value);
});
fullUrl += `?${params.toString()}`;
}
return fullUrl;
}
}
/**
* Create a client with default options
*/
function createClient(defaults = {}) {
const client = new HttpClient(defaults);
// Return the methods directly for API compatibility
return {
get: (url, options) => client.get(url, options),
post: (url, options) => client.post(url, options),
put: (url, options) => client.put(url, options),
delete: (url, options) => client.delete(url, options),
defaults: client.defaults
};
}
module.exports = {
createClient
};

View File

@@ -13,6 +13,7 @@ require('./ms-teams');
require('./speech-credentials');
require('./recent-calls');
require('./users');
require('./users-view-only');
require('./login');
require('./webapp_tests');
// require('./homer');

View File

@@ -1,7 +1,8 @@
const test = require('tape') ;
const ADMIN_TOKEN = '38700987-c7a4-4685-a5bb-af378f9734de';
const authAdmin = {bearer: ADMIN_TOKEN};
const request = require('request-promise-native').defaults({
const { createClient } = require('./http-client');
const request = createClient({
baseUrl: 'http://127.0.0.1:3000/v1'
});

View File

@@ -1,7 +1,8 @@
const test = require('tape') ;
const ADMIN_TOKEN = '38700987-c7a4-4685-a5bb-af378f9734de';
const authAdmin = {bearer: ADMIN_TOKEN};
const request = require('request-promise-native').defaults({
const { createClient } = require('./http-client');
const request = createClient({
baseUrl: 'http://127.0.0.1:3000/v1'
});

View File

@@ -1,7 +1,8 @@
const test = require('tape') ;
const { createClient } = require('./http-client');
const ADMIN_TOKEN = '38700987-c7a4-4685-a5bb-af378f9734de';
const authAdmin = {bearer: ADMIN_TOKEN};
const request = require('request-promise-native').defaults({
const request = createClient({
baseUrl: 'http://127.0.0.1:3000/v1'
});

View File

@@ -1,12 +1,14 @@
const test = require('tape') ;
const jwt = require('jsonwebtoken');
const request = require('request-promise-native').defaults({
const { createClient } = require('./http-client');
const request = createClient({
baseUrl: 'http://127.0.0.1:3000/v1'
});
const exec = require('child_process').exec ;
const {generateHashedPassword} = require('../lib/utils/password-utils');
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});
@@ -52,7 +54,7 @@ test('login tests', async(t) => {
password: 'adm',
}
}).catch(error => {
t.ok(error.response.statusCode === 403, `Maximum login attempts reached. Please try again in ${attempTime} seconds.`)
t.ok(error.statusCode === 403, `Maximum login attempts reached. Please try again in ${attempTime} seconds.`)
});
} else if (index < maxAttempts) {
attemptResult = await request.post('/login', {
@@ -62,7 +64,10 @@ test('login tests', async(t) => {
username: 'admin',
password: 'adm',
}
}).catch(error => t.ok(error.response.statusCode === 403));
}).catch(error => {
console.log(JSON.stringify(error));
t.ok(error.statusCode === 403);
});
} else {
attemptResult = await request.post('/login', {
resolveWithFullResponse: true,
@@ -71,7 +76,7 @@ test('login tests', async(t) => {
username: 'admin',
password: 'adm',
}
}).catch(error => t.ok(error.response.statusCode === 403, 'Maximum login attempts reached. Please try again later or reset your password.'));
}).catch(error => t.ok(error.statusCode === 403, 'Maximum login attempts reached. Please try again later or reset your password.'));
}
}

View File

@@ -1,7 +1,8 @@
const test = require('tape') ;
const ADMIN_TOKEN = '38700987-c7a4-4685-a5bb-af378f9734de';
const authAdmin = {bearer: ADMIN_TOKEN};
const request = require('request-promise-native').defaults({
const { createClient } = require('./http-client');
const request = createClient({
baseUrl: 'http://127.0.0.1:3000/v1'
});
const {createServiceProvider, createAccount, deleteObjectBySid} = require('./utils');

View File

@@ -1,9 +1,6 @@
const bent = require('bent');
const getJSON = bent('GET', 200);
const request = require('request');
const test = async() => {
request.get('https://api.github.com/user', {
fetch('https://api.github.com/user', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.GH_CODE}`,
Accept: 'application/json',

View File

@@ -1,7 +1,8 @@
const test = require('tape') ;
const { createClient } = require('./http-client');
const ADMIN_TOKEN = '38700987-c7a4-4685-a5bb-af378f9734de';
const authAdmin = {bearer: ADMIN_TOKEN};
const request = require('request-promise-native').defaults({
const request = createClient({
baseUrl: 'http://127.0.0.1:3000/v1'
});

View File

@@ -1,7 +1,8 @@
const test = require('tape') ;
const ADMIN_TOKEN = '38700987-c7a4-4685-a5bb-af378f9734de';
const authAdmin = {bearer: ADMIN_TOKEN};
const request = require('request-promise-native').defaults({
const { createClient } = require('./http-client');
const request = createClient({
baseUrl: 'http://127.0.0.1:3000/v1'
});
const {createVoipCarrier, deleteObjectBySid} = require('./utils');

View File

@@ -3,7 +3,8 @@ const fs = require('fs');
const jwt = require('jsonwebtoken');
const ADMIN_TOKEN = '38700987-c7a4-4685-a5bb-af378f9734de';
const authAdmin = {bearer: ADMIN_TOKEN};
const request = require('request-promise-native').defaults({
const { createClient } = require('./http-client');
const request = createClient({
baseUrl: 'http://127.0.0.1:3000/v1'
});
const consoleLogger = {debug: console.log, info: console.log, error: console.error}

View File

@@ -1,7 +1,8 @@
const test = require('tape') ;
const ADMIN_TOKEN = '38700987-c7a4-4685-a5bb-af378f9734de';
const authAdmin = {bearer: ADMIN_TOKEN};
const request = require('request-promise-native').defaults({
const { createClient } = require('./http-client');
const request = createClient({
baseUrl: 'http://127.0.0.1:3000/v1'
});
const {createServiceProvider, deleteObjectBySid} = require('./utils');

View File

@@ -1,11 +1,13 @@
const test = require('tape') ;
const ADMIN_TOKEN = '38700987-c7a4-4685-a5bb-af378f9734de';
const authAdmin = {bearer: ADMIN_TOKEN};
const request = require('request-promise-native').defaults({
const { createClient } = require('./http-client');
const request = createClient({
baseUrl: 'http://127.0.0.1:3000/v1'
});
const {deleteObjectBySid} = require('./utils');
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});

View File

@@ -1,7 +1,8 @@
const test = require('tape') ;
const ADMIN_TOKEN = '38700987-c7a4-4685-a5bb-af378f9734de';
const authAdmin = {bearer: ADMIN_TOKEN};
const request = require('request-promise-native').defaults({
const { createClient } = require('./http-client');
const request = createClient({
baseUrl: 'http://127.0.0.1:3000/v1'
});
const {createVoipCarrier, deleteObjectBySid} = require('./utils');

View File

@@ -1,11 +1,13 @@
const test = require('tape') ;
const ADMIN_TOKEN = '38700987-c7a4-4685-a5bb-af378f9734de';
const authAdmin = {bearer: ADMIN_TOKEN};
const request = require('request-promise-native').defaults({
const { createClient } = require('./http-client');
const request = createClient({
baseUrl: 'http://127.0.0.1:3000/v1'
});
const {createVoipCarrier, deleteObjectBySid} = require('./utils');
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});

View File

@@ -3,12 +3,14 @@ const fs = require('fs');
const jwt = require('jsonwebtoken');
const ADMIN_TOKEN = '38700987-c7a4-4685-a5bb-af378f9734de';
const authAdmin = {bearer: ADMIN_TOKEN};
const request = require('request-promise-native').defaults({
const { createClient } = require('./http-client');
const request = createClient({
baseUrl: 'http://127.0.0.1:3000/v1'
});
const {createServiceProvider, createAccount, deleteObjectBySid} = require('./utils');
const { noopLogger } = require('@jambonz/realtimedb-helpers/lib/utils');
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});

View File

@@ -1,8 +1,9 @@
const test = require('tape') ;
const jwt = require('jsonwebtoken');
const { createClient } = require('./http-client');
const ADMIN_TOKEN = '38700987-c7a4-4685-a5bb-af378f9734de';
const authAdmin = {bearer: ADMIN_TOKEN};
const request = require('request-promise-native').defaults({
const request = createClient({
baseUrl: 'http://127.0.0.1:3000/v1'
});

View File

@@ -2,7 +2,8 @@ const test = require('tape') ;
const jwt = require('jsonwebtoken');
const ADMIN_TOKEN = '38700987-c7a4-4685-a5bb-af378f9734de';
const authAdmin = {bearer: ADMIN_TOKEN};
const request = require('request-promise-native').defaults({
const { createClient } = require('./http-client');
const request = createClient({
baseUrl: 'http://127.0.0.1:3000/v1'
});
const crypto = require('crypto');

298
test/users-view-only.js Normal file
View File

@@ -0,0 +1,298 @@
const test = require('tape') ;
const jwt = require('jsonwebtoken');
const { createClient } = require('./http-client');
const request = createClient({
baseUrl: 'http://127.0.0.1:3000/v1'
});
const exec = require('child_process').exec ;
const {generateHashedPassword} = require('../lib/utils/password-utils');
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});
test('add an admin user', (t) => {
exec(`${__dirname}/../db/reset_admin_password.js`, (err, stdout, stderr) => {
console.log(stderr);
console.log(stdout);
if (err) return t.end(err);
t.pass('successfully added admin user');
t.end();
});
});
test('prepare permissions', (t) => {
exec(`mysql -h 127.0.0.1 -u root --protocol=tcp --port=3360 -D jambones_test < ${__dirname}/../db/prepare-permissions-test.sql`, (err, stdout, stderr) => {
if (err) return t.end(err);
t.pass('permissions prepared');
t.end();
});
});
test('view-only user tests', async(t) => {
const app = require('../app');
const password = 'abcde12345-';
try {
let result;
/* login as admin to get a jwt */
result = await request.post('/login', {
resolveWithFullResponse: true,
json: true,
body: {
username: 'admin',
password: 'admin',
}
});
t.ok(result.statusCode === 200 && result.body.token, 'successfully logged in as admin');
const authAdmin = {bearer: result.body.token};
const decodedJwt = jwt.verify(result.body.token, process.env.JWT_SECRET);
/* add admin user */
result = await request.post(`/Users`, {
resolveWithFullResponse: true,
json: true,
auth: authAdmin,
body: {
name: 'admin2',
email: 'admin2@jambonz.com',
is_active: true,
force_change: true,
initial_password: password,
}
});
t.ok(result.statusCode === 201 && result.body.user_sid, 'admin user created');
const admin_user_sid = result.body.user_sid;
/* add a service provider */
result = await request.post('/ServiceProviders', {
resolveWithFullResponse: true,
auth: authAdmin,
json: true,
body: {
name: 'sp1',
}
});
t.ok(result.statusCode === 201, 'successfully created service provider');
const sp_sid = result.body.sid;
/* add service_provider read only user */
result = await request.post(`/Users`, {
resolveWithFullResponse: true,
json: true,
auth: authAdmin,
body: {
name: 'service_provider',
email: 'sp@jambonz.com',
is_active: true,
force_change: true,
initial_password: password,
service_provider_sid: sp_sid,
is_view_only: true
}
});
t.ok(result.statusCode === 201 && result.body.user_sid, 'service_provider scope view-only user created');
// login as service_provider read only user
result = await request.post('/login', {
resolveWithFullResponse: true,
json: true,
body: {
username: 'service_provider',
password: password,
}
});
t.ok(result.statusCode === 200 && result.body.token, 'successfully logged in as service provider view-only user');
const spToken = {bearer: result.body.token};
const spDecodedJwt = jwt.verify(result.body.token, process.env.JWT_SECRET);
try {
result = await request.post('/Accounts', {
resolveWithFullResponse: true,
auth: spToken,
json: true,
body: {
name: 'sample_account',
service_provider_sid: sp_sid,
registration_hook: {
url: 'http://example.com/reg',
method: 'get'
},
webhook_secret: 'foobar'
}
})
} catch(err) {
t.ok(err.statusCode === 403, 'As a view-only user, you cannot create an account');
}
result = await request.post('/Accounts', {
resolveWithFullResponse: true,
auth: authAdmin,
json: true,
body: {
name: 'sample_account',
service_provider_sid: sp_sid,
registration_hook: {
url: 'http://example.com/reg',
method: 'get'
},
webhook_secret: 'foobar'
}
})
t.ok(result.statusCode === 201, 'successfully created account using admin token');
const account_sid = result.body.sid;
/* add account scope view-only user */
result = await request.post(`/Users`, {
resolveWithFullResponse: true,
json: true,
auth: authAdmin,
body: {
name: 'account',
email: 'account@jambonz.com',
is_active: true,
force_change: true,
initial_password: password,
service_provider_sid: sp_sid,
account_sid: account_sid,
is_view_only: true
}
});
t.ok(result.statusCode === 201 && result.body.user_sid, 'account scope user created');
const account_user_sid = result.body.user_sid;
// login as account read only user
result = await request.post('/login', {
resolveWithFullResponse: true,
json: true,
body: {
username: 'account',
password: password,
}
});
t.ok(result.statusCode === 200 && result.body.token, 'successfully logged in as account view-only user');
let userToken = {bearer: result.body.token};
/* add an application which should fail as the logged in user is a view-only user */
try {
result = await request.post('/Applications', {
resolveWithFullResponse: true,
auth: userToken,
json: true,
body: {
name: 'daveh',
account_sid,
call_hook: {
url: 'http://example.com'
},
call_status_hook: {
url: 'http://example.com/status',
method: 'POST'
},
messaging_hook: {
url: 'http://example.com/sms'
},
app_json : '[\
{\
"verb": "play",\
"url": "https://example.com/example.mp3",\
"timeoutSecs": 10,\
"seekOffset": 8000,\
"actionHook": "/play/action"\
}\
]',
use_for_fallback_speech: 1,
fallback_speech_synthesis_vendor: 'google',
fallback_speech_synthesis_language: 'en-US',
fallback_speech_synthesis_voice: 'man',
fallback_speech_synthesis_label: 'label1',
fallback_speech_recognizer_vendor: 'google',
fallback_speech_recognizer_language: 'en-US',
fallback_speech_recognizer_label: 'label1'
}
});
} catch(err) {
t.ok(err.statusCode === 403, 'As a view-only user, you cannot create an application');
}
// change user as read/write user and create an application - it should succeed
result = await request.put(`/Users/${account_user_sid}`, {
resolveWithFullResponse: true,
json: true,
auth: authAdmin,
body: {
is_view_only: false
}
});
t.ok(result.statusCode === 204, 'successfully updated user to read/write permissions');
// login as account read only user
result = await request.post('/login', {
resolveWithFullResponse: true,
json: true,
body: {
username: 'account',
password: password,
}
});
t.ok(result.statusCode === 200 && result.body.token, 'successfully logged in as account read-write user');
userToken = {bearer: result.body.token};
/* add an application which should succeed as the logged in user is a read-write user */
result = await request.post('/Applications', {
resolveWithFullResponse: true,
auth: userToken,
json: true,
body: {
name: 'daveh',
account_sid,
call_hook: {
url: 'http://example.com'
},
call_status_hook: {
url: 'http://example.com/status',
method: 'POST'
},
messaging_hook: {
url: 'http://example.com/sms'
},
app_json : '[\
{\
"verb": "play",\
"url": "https://example.com/example.mp3",\
"timeoutSecs": 10,\
"seekOffset": 8000,\
"actionHook": "/play/action"\
}\
]',
use_for_fallback_speech: 1,
fallback_speech_synthesis_vendor: 'google',
fallback_speech_synthesis_language: 'en-US',
fallback_speech_synthesis_voice: 'man',
fallback_speech_synthesis_label: 'label1',
fallback_speech_recognizer_vendor: 'google',
fallback_speech_recognizer_language: 'en-US',
fallback_speech_recognizer_label: 'label1'
}
});
t.ok(result.statusCode === 201, 'successfully created an application');
// change user back to view-only and query the application - it should succeed
result = await request.put(`/Users/${account_user_sid}`, {
resolveWithFullResponse: true,
json: true,
auth: authAdmin,
body: {
is_view_only: true
}
});
t.ok(result.statusCode === 204, 'successfully updated user permission to view-only');
// login as account read only user
result = await request.post('/login', {
resolveWithFullResponse: true,
json: true,
body: {
username: 'account',
password: password,
}
});
t.ok(result.statusCode === 200 && result.body.token, 'successfully logged in as account view-only user');
userToken = {bearer: result.body.token};
result = await request.get('/Applications', {
auth: userToken,
json: true,
});
//console.log(`result: ${JSON.stringify(result)}`);
t.ok(result.length === 1 , 'successfully queried all applications with view-only user');
} catch (err) {
console.error(err);
t.end(err);
}
});

View File

@@ -1,10 +1,11 @@
const test = require('tape') ;
const jwt = require('jsonwebtoken');
const request = require('request-promise-native').defaults({
const { createClient } = require('./http-client');
const request = createClient({
baseUrl: 'http://127.0.0.1:3000/v1'
});
const exec = require('child_process').exec ;
const {generateHashedPassword} = require('../lib/utils/password-utils');
process.on('unhandledRejection', (reason, p) => {
@@ -20,7 +21,13 @@ test('add an admin user', (t) => {
t.end();
});
});
test('prepare permissions', (t) => {
exec(`mysql -h 127.0.0.1 -u root --protocol=tcp --port=3360 -D jambones_test < ${__dirname}/../db/prepare-permissions-test.sql`, (err, stdout, stderr) => {
if (err) return t.end(err);
t.pass('permissions prepared');
t.end();
});
});
test('user tests', async(t) => {
const app = require('../app');
const password = 'abcde12345-';

View File

@@ -1,6 +1,7 @@
const { v4: uuid } = require('uuid');
const fs = require('fs');
const request_fs_mock = require('request-promise-native').defaults({
const { createClient } = require('./http-client');
const request_fs_mock = createClient({
baseUrl: 'http://127.0.0.1:3100'
});

View File

@@ -1,11 +1,13 @@
const test = require('tape') ;
const ADMIN_TOKEN = '38700987-c7a4-4685-a5bb-af378f9734de';
const authAdmin = {bearer: ADMIN_TOKEN};
const request = require('request-promise-native').defaults({
const { createClient } = require('./http-client');
const request = createClient({
baseUrl: 'http://127.0.0.1:3000/v1'
});
const {createServiceProvider, createAccount, createApplication, deleteObjectBySid} = require('./utils');
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});

View File

@@ -1,7 +1,8 @@
const test = require('tape') ;
const exec = require('child_process').exec ;
const Account = require('../lib/models/account');
const request = require('request-promise-native').defaults({
const { createClient } = require('./http-client');
const request = createClient({
baseUrl: 'http://127.0.0.1:3000/v1'
});
const theOneAndOnlyServiceProviderSid = '2708b1b3-2736-40ea-b502-c53d8396247f';
@@ -24,7 +25,13 @@ test('seeding database for webapp tests', (t) => {
t.end();
});
});
test('prepare permissions', (t) => {
exec(`mysql -h 127.0.0.1 -u root --protocol=tcp --port=3360 -D jambones_test < ${__dirname}/../db/prepare-permissions-test.sql`, (err, stdout, stderr) => {
if (err) return t.end(err);
t.pass('permissions prepared');
t.end();
});
});
test('webapp tests', async(t) => {
const app = require('../app');
let sid;