Compare commits

...

30 Commits

Author SHA1 Message Date
Hoan Luu Huu
bb705fe808 feat: custom email vendor (#130)
* feat: custom email vendor

* feat: custom email vendor

* feat: custom email vendor

* feat: custom email vendor

---------

Co-authored-by: Quan HL <quanluuhoang8@gmail.com>
2023-03-29 12:48:53 -04:00
Guilherme Rauen
789a0ba3ff Fix SQL Injection Vulnerabilities (#134)
* avoid sql injections

* linter

* fix test using random sid

* add some test cases

* remove tests that don't use the new validation

* add test

* linter

* fix tests

* add test

---------

Co-authored-by: Guilherme Rauen <g.rauen@cognigy.com>
2023-03-29 12:36:51 -04:00
EgleH
27cb7c471a Add passwordSettings validation (#136)
* add password Settings validation

* fix test failing because of pass validation

---------

Co-authored-by: eglehelms <e.helms@cognigy.com>
2023-03-29 08:54:05 -04:00
Dave Horton
39260f0b47 bump version 2023-03-28 14:14:44 -04:00
Anton Voylenko
75a2b42d65 update README (#135) 2023-03-27 14:17:03 -04:00
Anton Voylenko
518a9163fb add ENCRYPTION_SECRET variable (#132) 2023-03-25 15:34:09 -04:00
Hoan Luu Huu
5fb4bd7bd1 feat: add nuance on-premise (#131)
* feat: add nuance on-premise

* feat: update fetch nuance credential

* fix: update

* fix nuance tts test against on-prem, refactor aws/google tts testing to use speech-utils package

---------

Co-authored-by: Quan HL <quanluuhoang8@gmail.com>
Co-authored-by: Dave Horton <daveh@beachdognet.com>
2023-03-25 11:20:44 -04:00
Dave Horton
409ad68123 fix tests for AWS speech 2023-03-24 08:59:16 -04:00
Dave Horton
17afb7102a update speech-utils with fix for aws tts 2023-03-24 08:19:18 -04:00
Anton Voylenko
6e7cb9b332 update README (#129) 2023-03-23 15:44:45 -04:00
Anton Voylenko
34f83e323c update swagger yaml (#127) 2023-03-23 09:08:38 -04:00
Anton Voylenko
00af458cb3 Migrate to argon2 from argon2-ffi (#126) 2023-03-22 16:15:01 -04:00
Dave Horton
389017a5c4 update to latest speech-utils 2023-03-20 15:37:35 -04:00
Dave Horton
c4cc6c51ee eliminate parsing of jwt to support either jwt or api key (#124)
* eliminate parsing of jwt to support either jwt or api key

* fixes for preventing non-authorized changes to users

* update to AWS v3 api
2023-03-14 18:54:56 -04:00
Dave Horton
aea7388ba0 refactor of speech-utils (#123) 2023-03-14 10:01:05 -04:00
Dave Horton
3d86292a90 prevent updates to users that would move them to a different account … (#122)
* prevent updates to users that would move them to a different account or service provider, or make them admin users

* bugfix: when updating account as admin user, verify the account sid

* validate account sid when SP user tries to update
2023-03-08 11:38:49 -05:00
Dave Horton
08962fe7ba bugfix: get of speech credential was not returning soniox api_key 2023-03-03 13:49:36 -05:00
Dave Horton
e573f6ab06 change property names for custom speech 2023-03-02 15:28:53 -05:00
Dave Horton
4934e2a1ca add support for custom speech api (#121) 2023-03-02 14:13:41 -05:00
EgleH
cc384995ea add support for soniox speech (#120)
Co-authored-by: eglehelms <e.helms@cognigy.com>
2023-02-26 11:31:39 -05:00
Dave Horton
d4506fb8fa bump version 2023-02-24 10:05:14 -05:00
Dave Horton
042a2c37dc update dockerfile 2023-02-23 08:21:30 -05:00
EgleH
6da1903dee Update node to node:18.14.0-alpine3.16 (#117) 2023-02-21 07:54:26 -05:00
dependabot[bot]
10009d903e Bump undici from 5.11.0 to 5.19.1 (#115)
Bumps [undici](https://github.com/nodejs/undici) from 5.11.0 to 5.19.1.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v5.11.0...v5.19.1)

---
updated-dependencies:
- dependency-name: undici
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-16 17:33:12 -05:00
Snyk bot
69a72c5e43 fix: upgrade aws-sdk from 2.1238.0 to 2.1302.0 (#110)
Snyk has created this PR to upgrade aws-sdk from 2.1238.0 to 2.1302.0.

See this package in npm:
https://www.npmjs.com/package/aws-sdk

See this project in Snyk:
https://app.snyk.io/org/davehorton/project/b7e09765-19b2-4aa5-90ba-10432e250041?utm_source=github&utm_medium=referral&page=upgrade-pr
2023-02-16 13:31:57 -05:00
Dave Horton
f7f3881d70 update to @jambonz/verb-specifications (#109) 2023-02-15 10:15:06 -05:00
Hoan Luu Huu
4d48c6946c feat: start using verb-specifications (#107)
* feat: start using verb-specifications

* fix: verb specification v2

* fix vulnerabilities

---------

Co-authored-by: Quan HL <quanluuhoang8@gmail.com>
Co-authored-by: Dave Horton <daveh@beachdognet.com>
2023-02-14 17:24:19 -05:00
Snyk bot
5b48fc8a07 fix: Dockerfile to reduce vulnerabilities (#106)
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-ALPINE316-OPENSSL-3314623
- https://snyk.io/vuln/SNYK-ALPINE316-OPENSSL-3314624
- https://snyk.io/vuln/SNYK-ALPINE316-OPENSSL-3314624
- https://snyk.io/vuln/SNYK-ALPINE316-OPENSSL-3314641
- https://snyk.io/vuln/SNYK-ALPINE316-OPENSSL-3314643
2023-02-12 22:50:13 -05:00
Hoan Luu Huu
f46be95551 feat: nvidia speech credential (#105)
* feat: nvidia speech credential

* fix: riva_server_uri

* fix: riva_server_uri

---------

Co-authored-by: Quan HL <quanluuhoang8@gmail.com>
2023-02-10 08:31:49 -05:00
EgleH
d4f2be3dc1 Add check for users to delete function (#104)
* add check for users to delete function

* fix typo

* fix active admin check

---------

Co-authored-by: eglehelms <e.helms@cognigy.com>
2023-02-08 07:16:29 -05:00
36 changed files with 4050 additions and 1704 deletions

View File

@@ -1,4 +1,4 @@
FROM --platform=linux/amd64 node:18.12.1-alpine3.16 as base
FROM --platform=linux/amd64 node:18.14.1-alpine3.16 as base
RUN apk --update --no-cache add --virtual .builds-deps build-base python3
@@ -20,4 +20,4 @@ ARG NODE_ENV
ENV NODE_ENV $NODE_ENV
CMD [ "node", "app.js" ]
CMD [ "node", "app.js" ]

View File

@@ -1,4 +1,4 @@
FROM --platform=linux/amd64 node:18.9.0-alpine3.16 as base
FROM --platform=linux/amd64 node:18.14.1-alpine3.16 as base
RUN apk --update --no-cache add --virtual .builds-deps build-base python3

View File

@@ -1,29 +1,44 @@
# jambonz-api-server ![Build Status](https://github.com/jambonz/jambonz-api-server/workflows/CI/badge.svg)
Jambones REST API server.
Jambones REST API server of the jambones platform.
## Configuration
This process requires the following environment variables to be set.
Configuration is provided via environment variables:
```
JAMBONES_MYSQL_HOST
JAMBONES_MYSQL_USER
JAMBONES_MYSQL_PASSWORD
JAMBONES_MYSQL_DATABASE
JAMBONES_MYSQL_CONNECTION_LIMIT # defaults to 10
JAMBONES_REDIS_HOST
JAMBONES_REDIS_PORT
JAMBONES_LOGLEVEL # defaults to info
JAMBONES_API_VERSION # defaults to v1
HTTP_PORT # defaults to 3000
```
| variable | meaning | required?|
|----------|----------|---------|
|JWT_SECRET| secret for signing JWT token |yes|
|JWT_EXPIRES_IN| expiration time for JWT token(in minutes) |no|
|ENCRYPTION_SECRET| secret for credential encryption(JWT_SECRET is deprecated) |yes|
|HTTP_PORT| tcp port to listen on for API requests from jambonz-api-server |no|
|JAMBONES_LOGLEVEL| log level for application, 'info' or 'debug' |no|
|JAMBONES_MYSQL_HOST| mysql host |yes|
|JAMBONES_MYSQL_USER| mysql username |yes|
|JAMBONES_MYSQL_PASSWORD| mysql password |yes|
|JAMBONES_MYSQL_DATABASE| mysql data |yes|
|JAMBONES_MYSQL_PORT| mysql port |no|
|JAMBONES_MYSQL_CONNECTION_LIMIT| mysql connection limit |no|
|JAMBONES_REDIS_HOST| redis host |yes|
|JAMBONES_REDIS_PORT| redis port |no|
|RATE_LIMIT_WINDOWS_MINS| rate limit window |no|
|RATE_LIMIT_MAX_PER_WINDOW| number of requests per window |no|
|JAMBONES_TRUST_PROXY| trust proxies, must be a number |no|
|JAMBONES_API_VERSION| api version |no|
|JAMBONES_TIME_SERIES_HOST| influxdb host |yes|
|JAMBONES_CLUSTER_ID| cluster id |no|
|HOMER_BASE_URL| HOMER URL |no|
|HOMER_USERNAME| HOMER username |no|
|HOMER_PASSWORD| HOMER password |no|
|K8S| service running as kubernetes service |no|
|K8S_FEATURE_SERVER_SERVICE_NAME| feature server name(required for K8S) |no|
|K8S_FEATURE_SERVER_SERVICE_PORT| feature server port(required for K8S) |no|
#### Database dependency
A mysql database is used to store long-lived objects such as Accounts, Applications, etc. To create the database schema, use or review the scripts in the 'db' folder, particularly:
- [create_db.sql](db/create_db.sql), which creates the database and associated user (you may want to edit the username and password),
- [jambones-sql.sql](db/jambones-sql.sql), which creates the schema,
- [create-admin-token.sql](db/create-admin-token.sql), which creates an admin-level auth token that can be used for testing/exercising the API.
- [seed-production-database-open-source.sql](db/seed-production-database-open-source.sql), which seeds the database with initial dataset(accounts, permissions, api keys, applications etc).
- [create-admin-user.sql](db/create-admin-user.sql), which creates admin user with password set to "admin". The password will be forced to change after the first login.
> Note: due to the dependency on the npmjs [mysql](https://www.npmjs.com/package/mysql) package, the mysql database must be configured to use sql [native authentication](https://medium.com/@crmcmullen/how-to-run-mysql-8-0-with-native-password-authentication-502de5bac661).

12
app.js
View File

@@ -21,6 +21,9 @@ assert.ok(process.env.JAMBONES_MYSQL_HOST &&
process.env.JAMBONES_MYSQL_DATABASE, 'missing JAMBONES_MYSQL_XXX env vars');
assert.ok(process.env.JAMBONES_REDIS_HOST, 'missing JAMBONES_REDIS_HOST env var');
assert.ok(process.env.JAMBONES_TIME_SERIES_HOST, 'missing JAMBONES_TIME_SERIES_HOST env var');
assert.ok(process.env.ENCRYPTION_SECRET || process.env.JWT_SECRET, 'missing ENCRYPTION_SECRET env var');
assert.ok(process.env.JWT_SECRET, 'missing JWT_SECRET env var');
const {
queryCdrs,
queryCdrsSP,
@@ -41,9 +44,14 @@ const {
addKey,
retrieveKey,
deleteKey,
getTtsVoices
} = require('@jambonz/realtimedb-helpers')({
host: process.env.JAMBONES_REDIS_HOST || 'localhost',
host: process.env.JAMBONES_REDIS_HOST,
port: process.env.JAMBONES_REDIS_PORT || 6379
}, logger);
const {
getTtsVoices
} = require('@jambonz/speech-utils')({
host: process.env.JAMBONES_REDIS_HOST,
port: process.env.JAMBONES_REDIS_PORT || 6379
}, logger);
const {

10
db/create-admin-user.sql Normal file
View File

@@ -0,0 +1,10 @@
/* hashed password is "admin" */
insert into users (user_sid, name, email, hashed_password, force_change, provider, email_validated)
values ('12c80508-edf9-4b22-8d09-55abd02648eb', 'admin', 'joe@foo.bar', '$argon2i$v=19$m=65536,t=3,p=4$c2FsdHNhbHRzYWx0c2FsdA$x5OO6gXFXS25oqUU2JvbYqrSgRxBujNUJBq6xv9EgjM', 1, 'local', 1);
insert into user_permissions (user_permissions_sid, user_sid, permission_sid)
values ('8919e0dc-4d69-4de5-be56-a121598d9093', '12c80508-edf9-4b22-8d09-55abd02648eb', 'ffbc342a-546a-11ed-bdc3-0242ac120002');
insert into user_permissions (user_permissions_sid, user_sid, permission_sid)
values ('d6fdf064-0a65-4b17-8b10-5500e956a159', '12c80508-edf9-4b22-8d09-55abd02648eb', 'ffbc3a10-546a-11ed-bdc3-0242ac120002');
insert into user_permissions (user_permissions_sid, user_sid, permission_sid)
values ('f68185dd-0486-4767-a77d-a0b84c1b236e' ,'12c80508-edf9-4b22-8d09-55abd02648eb', 'ffbc3c5e-546a-11ed-bdc3-0242ac120002');

View File

@@ -99,7 +99,7 @@ const checkApiTokens = (logger, token, done) => {
hasServiceProviderAuth: scope === 'service_provider',
hasAccountAuth: scope === 'account'
};
logger.info(user, `successfully validated with scope ${scope}`);
logger.debug({user}, `successfully validated with scope ${scope}`);
return done(null, user, {scope});
});
});

View File

@@ -107,7 +107,7 @@ class Model extends Emitter {
if (pk.name in obj) throw new DbErrorBadRequest(`primary key ${pk.name} is immutable`);
getMysqlConnection((err, conn) => {
if (err) return reject(err);
conn.query(`UPDATE ${this.table} SET ? WHERE ${pk.name} = '${sid}'`, obj, (err, results, fields) => {
conn.query(`UPDATE ${this.table} SET ? WHERE ${pk.name} = ?`, [obj, sid], (err, results, fields) => {
conn.release();
if (err) return reject(err);
resolve(results.affectedRows);

View File

@@ -46,17 +46,34 @@ const stripPort = (hostport) => {
};
const validateUpdateForCarrier = async(req) => {
const account_sid = parseAccountSid(req);
if (req.user.hasScope('admin')) return ;
if (req.user.hasScope('account')) {
if (account_sid === req.user.account_sid) return ;
throw new DbErrorForbidden('insufficient permissions to update account');
}
if (req.user.hasScope('service_provider')) {
const [r] = await promisePool.execute(
'SELECT service_provider_sid from accounts WHERE account_sid = ?', [account_sid]);
if (r.length === 1 && r[0].service_provider_sid === req.user.service_provider_sid) return;
throw new DbErrorForbidden('insufficient permissions to update account');
try {
const account_sid = parseAccountSid(req);
if (req.user.hasScope('admin')) {
return;
}
if (req.user.hasScope('account')) {
if (account_sid === req.user.account_sid) {
return;
}
throw new DbErrorForbidden('insufficient permissions to update account');
}
if (req.user.hasScope('service_provider')) {
const [r] = await promisePool.execute(
'SELECT service_provider_sid from accounts WHERE account_sid = ?', [account_sid]
);
if (r.length === 1 && r[0].service_provider_sid === req.user.service_provider_sid) {
return;
}
throw new DbErrorForbidden('insufficient permissions to update account');
}
} catch (error) {
throw error;
}
};
@@ -344,10 +361,20 @@ async function validateUpdate(req, sid) {
if (req.user.service_provider_sid && !req.user.hasScope('admin')) {
const result = await Account.retrieve(sid);
if (!result || result.length === 0) {
throw new DbErrorBadRequest(`account not found for sid ${sid}`);
}
if (result[0].service_provider_sid !== req.user.service_provider_sid) {
throw new DbErrorUnprocessableRequest('cannot update account from different service provider');
}
}
if (req.user.hasScope('admin')) {
/* check to be sure that the account_sid exists */
const result = await Account.retrieve(sid);
if (!result || result.length === 0) {
throw new DbErrorBadRequest(`account not found for sid ${sid}`);
}
}
if (req.body.service_provider_sid) throw new DbErrorBadRequest('service_provider_sid may not be modified');
}
async function validateDelete(req, sid) {
@@ -775,11 +802,11 @@ router.put('/:sid/Calls/:callSid', async(req, res) => {
* create a new Message
*/
router.post('/:sid/Messages', async(req, res) => {
const account_sid = parseAccountSid(req);
const setName = `${(process.env.JAMBONES_CLUSTER_ID || 'default')}:active-fs`;
const {retrieveSet, logger} = req.app.locals;
try {
const account_sid = parseAccountSid(req);
const setName = `${(process.env.JAMBONES_CLUSTER_ID || 'default')}:active-fs`;
const serviceUrl = await getFsUrl(logger, retrieveSet, setName);
if (!serviceUrl) res.json({msg: 'no available feature servers at this time'}).status(480);
await validateCreateMessage(logger, account_sid, req);

View File

@@ -24,13 +24,13 @@ router.post('/:sid', async(req, res) => {
let service_provider_sid;
const {account_sid} = req.user;
if (!account_sid) {
service_provider_sid = parseServiceProviderSid(req);
} else {
service_provider_sid = req.user.service_provider_sid;
}
try {
if (!account_sid) {
service_provider_sid = parseServiceProviderSid(req);
} else {
service_provider_sid = req.user.service_provider_sid;
}
const [template] = await PredefinedCarrier.retrieve(sid);
logger.debug({template}, `Retrieved template carrier for sid ${sid}`);
if (!template) return res.sendStatus(404);

View File

@@ -1,17 +1,13 @@
const router = require('express').Router();
const sysError = require('../error');
const {DbErrorBadRequest} = require('../../utils/errors');
const { parseServiceProviderSid } = require('./utils');
const parseAccountSid = (url) => {
const arr = /Accounts\/([^\/]*)/.exec(url);
if (arr) return arr[1];
};
const parseServiceProviderSid = (url) => {
const arr = /ServiceProviders\/([^\/]*)/.exec(url);
if (arr) return arr[1];
};
router.get('/', async(req, res) => {
const {logger, queryAlerts, queryAlertsSP} = req.app.locals;
try {

View File

@@ -6,7 +6,7 @@ const Webhook = require('../../models/webhook');
const {promisePool} = require('../../db');
const decorate = require('./decorate');
const sysError = require('../error');
const jambonzAppJsonValidator = require('../../utils/jambonz-app-json-validation');
const { validate } = require('@jambonz/verb-specifications');
const preconditions = {
'add': validateAdd,
'update': validateUpdate
@@ -80,7 +80,11 @@ router.post('/', async(req, res) => {
// validate app json if required
if (obj['app_json']) {
const app_json = JSON.parse(obj['app_json']);
jambonzAppJsonValidator(logger, app_json);
try {
validate(logger, app_json);
} catch (err) {
throw new DbErrorBadRequest(err);
}
}
const uuid = await Application.make(obj);
@@ -189,7 +193,11 @@ router.put('/:sid', async(req, res) => {
// validate app json if required
if (obj['app_json']) {
const app_json = JSON.parse(obj['app_json']);
jambonzAppJsonValidator(logger, app_json);
try {
validate(logger, app_json);
} catch (err) {
throw new DbErrorBadRequest(err);
}
}
const rowsAffected = await Application.update(sid, obj);

View File

@@ -1,6 +1,10 @@
const {DbErrorBadRequest, DbErrorUnprocessableRequest} = require('../../utils/errors');
const { BadRequestError, DbErrorBadRequest, DbErrorUnprocessableRequest } = require('../../utils/errors');
function sysError(logger, res, err) {
if (err instanceof BadRequestError) {
logger.info(err, err.message);
return res.status(400).json({msg: 'Bad request'});
}
if (err instanceof DbErrorBadRequest) {
logger.info(err, 'invalid client request');
return res.status(400).json({msg: err.message});

View File

@@ -28,16 +28,18 @@ router.post('/', async(req, res) => {
category,
quantity
} = req.body;
const account_sid = parseAccountSid(req);
let service_provider_sid;
if (!account_sid) {
if (!req.user.hasServiceProviderAuth && !req.user.hasAdminAuth) {
logger.error('POST /SpeechCredentials invalid credentials');
return res.sendStatus(403);
}
service_provider_sid = parseServiceProviderSid(req);
}
try {
let service_provider_sid;
const account_sid = parseAccountSid(req);
if (!account_sid) {
if (!req.user.hasServiceProviderAuth && !req.user.hasAdminAuth) {
logger.error('POST /SpeechCredentials invalid credentials');
return res.sendStatus(403);
}
service_provider_sid = parseServiceProviderSid(req);
}
let uuid;
if (account_sid) {
const existing = (await AccountLimits.retrieve(account_sid) || [])
@@ -80,10 +82,11 @@ router.post('/', async(req, res) => {
*/
router.get('/', async(req, res) => {
let service_provider_sid;
const account_sid = parseAccountSid(req);
if (!account_sid) service_provider_sid = parseServiceProviderSid(req);
const logger = req.app.locals.logger;
try {
const account_sid = parseAccountSid(req);
if (!account_sid) service_provider_sid = parseServiceProviderSid(req);
const limits = account_sid ?
await AccountLimits.retrieve(account_sid) :
await ServiceProviderLimits.retrieve(service_provider_sid);
@@ -99,10 +102,11 @@ router.get('/', async(req, res) => {
router.delete('/', async(req, res) => {
const logger = req.app.locals.logger;
const account_sid = parseAccountSid(req);
const {category} = req.query;
const service_provider_sid = parseServiceProviderSid(req);
try {
const account_sid = parseAccountSid(req);
const {category} = req.query;
const service_provider_sid = parseServiceProviderSid(req);
if (account_sid) {
if (category) {
await promisePool.execute(sqlDeleteAccountLimitsByCategory, [account_sid, category]);

View File

@@ -15,6 +15,9 @@ const validate = (obj) => {
router.post('/', async(req, res) => {
const logger = req.app.locals.logger;
try {
if (!req.user.hasAdminAuth) {
return res.sendStatus(403);
}
validate(req.body);
const [existing] = (await PasswordSettings.retrieve() || []);
if (existing) {

View File

@@ -12,7 +12,7 @@ const {hasServiceProviderPermissions, parseServiceProviderSid} = require('./util
const sysError = require('../error');
const decorate = require('./decorate');
const preconditions = {
'delete': noActiveAccounts
'delete': noActiveAccountsOrUsers
};
const sqlDeleteSipGateways = `DELETE from sip_gateways
WHERE voip_carrier_sid IN (
@@ -35,36 +35,64 @@ function validateAdd(req) {
}
async function validateRetrieve(req) {
const service_provider_sid = parseServiceProviderSid(req);
if (req.user.hasScope('admin')) return ;
if (req.user.hasScope('service_provider')) {
if (service_provider_sid === req.user.service_provider_sid) return ;
try {
const service_provider_sid = parseServiceProviderSid(req);
if (req.user.hasScope('admin')) {
return;
}
if (req.user.hasScope('service_provider')) {
if (service_provider_sid === req.user.service_provider_sid) return ;
}
if (req.user.hasScope('account')) {
/* allow account users to retrieve service provider data from parent SP */
const sid = req.user.account_sid;
const [r] = await promisePool.execute('SELECT service_provider_sid from accounts WHERE account_sid = ?', [sid]);
if (r.length === 1 && r[0].service_provider_sid === req.user.service_provider_sid) return;
}
throw new DbErrorForbidden('insufficient permissions to update service provider');
} catch (error) {
throw error;
}
if (req.user.hasScope('account')) {
/* allow account users to retrieve service provider data from parent SP */
const sid = req.user.account_sid;
const [r] = await promisePool.execute('SELECT service_provider_sid from accounts WHERE account_sid = ?', [sid]);
if (r.length === 1 && r[0].service_provider_sid === req.user.service_provider_sid) return;
}
throw new DbErrorForbidden('insufficient permissions to update service provider');
}
function validateUpdate(req) {
if (req.user.hasScope('admin')) return ;
if (req.user.hasScope('service_provider')) {
try {
const service_provider_sid = parseServiceProviderSid(req);
if (service_provider_sid === req.user.service_provider_sid) return ;
if (req.user.hasScope('admin')) {
return;
}
if (req.user.hasScope('service_provider')) {
if (service_provider_sid === req.user.service_provider_sid) return;
}
throw new DbErrorForbidden('insufficient permissions to update service provider');
} catch (error) {
console.log('Passing forward the error received');
throw error;
}
throw new DbErrorForbidden('insufficient permissions to update service provider');
}
/* can not delete a service provider if it has any active accounts */
async function noActiveAccounts(req, sid) {
async function noActiveAccountsOrUsers(req, sid) {
if (!req.user.hasAdminAuth) {
throw new DbErrorForbidden('only admin users can delete a service provider');
}
const activeAccounts = await ServiceProvider.getForeignKeyReferences('accounts.service_provider_sid', sid);
const activeUsers = await ServiceProvider.getForeignKeyReferences('users.service_provider_sid', sid);
if (activeAccounts > 0 && activeUsers > 0) throw new DbErrorUnprocessableRequest(
'cannot delete service provider with active accounts or users'
);
if (activeAccounts > 0) throw new DbErrorUnprocessableRequest('cannot delete service provider with active accounts');
if (activeUsers > 0) throw new DbErrorUnprocessableRequest(
'cannot delete service provider with active service provider level users'
);
/* ok we can delete -- no active accounts. remove carriers and speech credentials */
await promisePool.execute('DELETE from speech_credentials WHERE service_provider_sid = ?', [sid]);
@@ -183,7 +211,6 @@ router.post('/', async(req, res) => {
const logger = req.app.locals.logger;
try {
validateAdd(req);
// create webhooks if provided
const obj = Object.assign({}, req.body);
for (const prop of ['registration_hook']) {
@@ -204,7 +231,6 @@ router.post('/', async(req, res) => {
/* list */
router.get('/', async(req, res) => {
const logger = req.app.locals.logger;
try {
const results = await ServiceProvider.retrieveAll();
logger.debug({results, user: req.user}, 'ServiceProvider.retrieveAll');
@@ -234,10 +260,10 @@ router.get('/:sid', async(req, res) => {
/* update */
router.put('/:sid', async(req, res) => {
const sid = req.params.sid;
const logger = req.app.locals.logger;
try {
validateUpdate(req);
const sid = req.params.sid;
// create webhooks if provided
const obj = Object.assign({}, req.body);
@@ -247,15 +273,14 @@ router.put('/:sid', async(req, res) => {
const sid = obj[prop]['webhook_sid'];
delete obj[prop]['webhook_sid'];
await Webhook.update(sid, obj[prop]);
}
else {
} else {
const sid = await Webhook.make(obj[prop]);
obj[`${prop}_sid`] = sid;
}
}
else {
} else {
obj[`${prop}_sid`] = null;
}
delete obj[prop];
}
@@ -263,6 +288,7 @@ router.put('/:sid', async(req, res) => {
if (rowsAffected === 0) {
return res.status(404).end();
}
res.status(204).end();
} catch (err) {
sysError(logger, res, err);

View File

@@ -17,6 +17,7 @@ const {
testNuanceStt,
testNuanceTts,
testDeepgramStt,
testSonioxStt,
testIbmTts,
testIbmStt
} = require('../../utils/speech-utils');
@@ -43,6 +44,8 @@ const encryptCredential = (obj) => {
region,
client_id,
secret,
nuance_tts_uri,
nuance_stt_uri,
use_custom_tts,
custom_tts_endpoint,
use_custom_stt,
@@ -51,7 +54,11 @@ const encryptCredential = (obj) => {
tts_region,
stt_api_key,
stt_region,
instance_id
riva_server_uri,
instance_id,
custom_stt_url,
custom_tts_url,
auth_token = ''
} = obj;
switch (vendor) {
@@ -92,9 +99,10 @@ const encryptCredential = (obj) => {
return encrypt(wsData);
case 'nuance':
assert(client_id, 'invalid nuance speech credential: client_id is required');
assert(secret, 'invalid nuance speech credential: secret is required');
const nuanceData = JSON.stringify({client_id, secret});
const checked = (client_id && secret) || (nuance_tts_uri || nuance_stt_uri);
assert(checked, 'invalid nuance speech credential: either entered client id and\
secret or entered a nuance_tts_uri or nuance_stt_uri');
const nuanceData = JSON.stringify({client_id, secret, nuance_tts_uri, nuance_stt_uri});
return encrypt(nuanceData);
case 'deepgram':
@@ -106,28 +114,45 @@ const encryptCredential = (obj) => {
const ibmData = JSON.stringify({tts_api_key, tts_region, stt_api_key, stt_region, instance_id});
return encrypt(ibmData);
case 'nvidia':
assert(riva_server_uri, 'invalid riva server uri: riva_server_uri is required');
const nvidiaData = JSON.stringify({ riva_server_uri });
return encrypt(nvidiaData);
case 'soniox':
assert(api_key, 'invalid soniox speech credential: api_key is required');
const sonioxData = JSON.stringify({api_key});
return encrypt(sonioxData);
default:
assert(false, `invalid or missing vendor: ${vendor}`);
if (vendor.startsWith('custom:')) {
const customData = JSON.stringify({auth_token, custom_stt_url, custom_tts_url});
return encrypt(customData);
}
else assert(false, `invalid or missing vendor: ${vendor}`);
}
};
router.post('/', async(req, res) => {
const logger = req.app.locals.logger;
const {
use_for_stt,
use_for_tts,
vendor,
} = req.body;
const account_sid = req.user.account_sid || req.body.account_sid;
const service_provider_sid = req.user.service_provider_sid ||
req.body.service_provider_sid || parseServiceProviderSid(req);
if (!account_sid) {
if (!req.user.hasServiceProviderAuth && !req.user.hasAdminAuth) {
logger.error('POST /SpeechCredentials invalid credentials');
return res.sendStatus(403);
}
}
try {
const {
use_for_stt,
use_for_tts,
vendor,
} = req.body;
const account_sid = req.user.account_sid || req.body.account_sid;
const service_provider_sid = req.user.service_provider_sid ||
req.body.service_provider_sid || parseServiceProviderSid(req);
if (!account_sid) {
if (!req.user.hasServiceProviderAuth && !req.user.hasAdminAuth) {
logger.error('POST /SpeechCredentials invalid credentials');
return res.sendStatus(403);
}
}
const encrypted_credential = encryptCredential(req.body);
const uuid = await SpeechCredential.make({
account_sid,
@@ -147,10 +172,11 @@ router.post('/', async(req, res) => {
* retrieve all speech credentials for an account
*/
router.get('/', async(req, res) => {
const account_sid = parseAccountSid(req) || req.user.account_sid;
const service_provider_sid = parseServiceProviderSid(req) || req.user.service_provider_sid;
const logger = req.app.locals.logger;
try {
const account_sid = parseAccountSid(req) || req.user.account_sid;
const service_provider_sid = parseServiceProviderSid(req) || req.user.service_provider_sid;
const credsAccount = account_sid ? await SpeechCredential.retrieveAll(account_sid) : [];
const credsSP = service_provider_sid ?
await SpeechCredential.retrieveAllForSP(service_provider_sid) :
@@ -197,7 +223,7 @@ router.get('/', async(req, res) => {
else if ('nuance' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.client_id = o.client_id;
obj.secret = obscureKey(o.secret);
obj.secret = o.secret ? obscureKey(o.secret) : null;
}
else if ('deepgram' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
@@ -210,6 +236,19 @@ router.get('/', async(req, res) => {
obj.stt_api_key = obscureKey(o.stt_api_key);
obj.stt_region = o.stt_region;
obj.instance_id = o.instance_id;
} else if ('nvidia' == obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.riva_server_uri = o.riva_server_uri;
}
else if ('soniox' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.api_key = obscureKey(o.api_key);
}
else if (obj.vendor.startsWith('custom:')) {
const o = JSON.parse(decrypt(credential));
obj.auth_token = obscureKey(o.auth_token);
obj.custom_stt_url = o.custom_stt_url;
obj.custom_tts_url = o.custom_tts_url;
}
return obj;
}));
@@ -259,7 +298,9 @@ router.get('/:sid', async(req, res) => {
else if ('nuance' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.client_id = o.client_id;
obj.secret = obscureKey(o.secret);
obj.secret = o.secret ? obscureKey(o.secret) : null;
obj.nuance_tts_uri = o.nuance_tts_uri;
obj.nuance_stt_uri = o.nuance_stt_uri;
}
else if ('deepgram' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
@@ -272,6 +313,19 @@ router.get('/:sid', async(req, res) => {
obj.stt_api_key = obscureKey(o.stt_api_key);
obj.stt_region = o.stt_region;
obj.instance_id = o.instance_id;
} else if ('nvidia' == obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.riva_server_uri = o.riva_server_uri;
}
else if ('soniox' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.api_key = obscureKey(o.api_key);
}
else if (obj.vendor.startsWith('custom:')) {
const o = JSON.parse(decrypt(credential));
obj.auth_token = obscureKey(o.auth_token);
obj.custom_stt_url = o.custom_stt_url;
obj.custom_tts_url = o.custom_tts_url;
}
res.status(200).json(obj);
} catch (err) {
@@ -302,7 +356,8 @@ router.put('/:sid', async(req, res) => {
const sid = req.params.sid;
const logger = req.app.locals.logger;
try {
const {use_for_tts, use_for_stt, region, aws_region, stt_region, tts_region} = req.body;
const {use_for_tts, use_for_stt, region, aws_region, stt_region, tts_region,
riva_server_uri, nuance_tts_uri, nuance_stt_uri} = req.body;
if (typeof use_for_tts === 'undefined' && typeof use_for_stt === 'undefined') {
throw new DbErrorUnprocessableRequest('use_for_tts and use_for_stt are the only updateable fields');
}
@@ -337,7 +392,10 @@ router.put('/:sid', async(req, res) => {
use_custom_stt,
custom_stt_endpoint,
stt_region,
tts_region
tts_region,
riva_server_uri,
nuance_stt_uri,
nuance_tts_uri
};
logger.info({o, newCred}, 'updating speech credential with this new credential');
obj.credential = encryptCredential(newCred);
@@ -389,7 +447,8 @@ router.get('/:sid/test', async(req, res) => {
if (cred.use_for_tts) {
try {
await testGoogleTts(logger, credential);
const {getTtsVoices} = req.app.locals;
await testGoogleTts(logger, getTtsVoices, credential);
results.tts.status = 'ok';
SpeechCredential.ttsTestResult(sid, true);
} catch (err) {
@@ -411,8 +470,9 @@ router.get('/:sid/test', async(req, res) => {
}
else if (cred.vendor === 'aws') {
if (cred.use_for_tts) {
const {getTtsVoices} = req.app.locals;
try {
await testAwsTts(logger, {
await testAwsTts(logger, getTtsVoices, {
accessKeyId: credential.access_key_id,
secretAccessKey: credential.secret_access_key,
region: credential.aws_region || process.env.AWS_REGION
@@ -494,13 +554,16 @@ router.get('/:sid/test', async(req, res) => {
const {
client_id,
secret
secret,
nuance_tts_uri,
nuance_stt_uri
} = credential;
if (cred.use_for_tts) {
try {
await testNuanceTts(logger, getTtsVoices, {
client_id,
secret
secret,
nuance_tts_uri
});
results.tts.status = 'ok';
SpeechCredential.ttsTestResult(sid, true);
@@ -515,7 +578,7 @@ router.get('/:sid/test', async(req, res) => {
}
if (cred.use_for_stt) {
try {
await testNuanceStt(logger, {client_id, secret});
await testNuanceStt(logger, {client_id, secret, nuance_stt_uri});
results.stt.status = 'ok';
SpeechCredential.sttTestResult(sid, true);
} catch (err) {
@@ -570,8 +633,22 @@ router.get('/:sid/test', async(req, res) => {
}
}
}
else if (cred.vendor === 'soniox') {
const {api_key} = credential;
if (cred.use_for_stt) {
try {
await testSonioxStt(logger, {api_key});
results.stt.status = 'ok';
SpeechCredential.sttTestResult(sid, true);
} catch (err) {
results.stt = {status: 'fail', reason: err.message};
SpeechCredential.sttTestResult(sid, false);
}
}
}
res.status(200).json(results);
} catch (err) {
sysError(logger, res, err);
}

View File

@@ -1,10 +1,10 @@
const router = require('express').Router();
const User = require('../../models/user');
const jwt = require('jsonwebtoken');
const request = require('request');
const {DbErrorBadRequest} = require('../../utils/errors');
const {generateHashedPassword, verifyPassword} = require('../../utils/password-utils');
const {promisePool} = require('../../db');
const {validatePasswordSettings} = require('./utils');
const {decrypt} = require('../../utils/encrypt-decrypt');
const sysError = require('../error');
const retrieveMyDetails = `SELECT *
@@ -28,7 +28,8 @@ AND account_subscriptions.pending=0`;
const updateSql = 'UPDATE users set hashed_password = ?, force_change = false WHERE user_sid = ?';
const retrieveStaticIps = 'SELECT * FROM account_static_ips WHERE account_sid = ?';
const validateRequest = async(user_sid, payload) => {
const validateRequest = async(user_sid, req) => {
const payload = req.body;
const {
old_password,
new_password,
@@ -37,15 +38,53 @@ const validateRequest = async(user_sid, payload) => {
email,
email_activation_code,
force_change,
is_active} = payload;
is_active
} = payload;
const [r] = await promisePool.query(retrieveSql, user_sid);
if (r.length === 0) return null;
if (r.length === 0) {
throw new DbErrorBadRequest('Invalid request: user_sid does not exist');
}
const user = r[0];
/* it is not allowed for anyone to promote a user to a higher level of authority */
if (null === payload.account_sid || null === payload.service_provider_sid) {
throw new DbErrorBadRequest('Invalid request: user may not be promoted');
}
if (req.user.hasAccountAuth) {
/* account user may not change modify account_sid or service_provider_sid */
if ('account_sid' in payload && payload.account_sid !== user.account_sid) {
throw new DbErrorBadRequest('Invalid request: user may not be promoted or moved to another account');
}
if ('service_provider_sid' in payload && payload.service_provider_sid !== user.service_provider_sid) {
throw new DbErrorBadRequest('Invalid request: user may not be promoted or moved to another service provider');
}
}
if (req.user.hasServiceProviderAuth) {
if ('service_provider_sid' in payload && payload.service_provider_sid !== user.service_provider_sid) {
throw new DbErrorBadRequest('Invalid request: user may not be promoted or moved to another service provider');
}
}
if ('account_sid' in payload) {
const [r] = await promisePool.query('SELECT * FROM accounts WHERE account_sid = ?', payload.account_sid);
if (r.length === 0) throw new DbErrorBadRequest('Invalid request: account_sid does not exist');
const {service_provider_sid} = r[0];
if (service_provider_sid !== user.service_provider_sid) {
throw new DbErrorBadRequest('Invalid request: user may not be moved to another service provider');
}
}
if (initial_password) {
await validatePasswordSettings(initial_password);
}
if ((old_password && !new_password) || (new_password && !old_password)) {
throw new DbErrorBadRequest('new_password and old_password both required');
}
if (new_password) {
await validatePasswordSettings(new_password);
}
if (new_password && name) throw new DbErrorBadRequest('can not change name and password simultaneously');
if (new_password && user.provider !== 'local') {
throw new DbErrorBadRequest('can not change password when using oauth2');
@@ -62,23 +101,18 @@ const validateRequest = async(user_sid, payload) => {
router.get('/', async(req, res) => {
const logger = req.app.locals.logger;
const token = req.user.jwt;
const decodedJwt = jwt.verify(token, process.env.JWT_SECRET);
let usersList;
try {
let results;
if (decodedJwt.scope === 'admin') {
if (req.user.hasAdminAuth) {
results = await User.retrieveAll();
}
else if (decodedJwt.scope === 'account') {
results = await User.retrieveAllForAccount(decodedJwt.account_sid, true);
else if (req.user.hasAccountAuth) {
results = await User.retrieveAllForAccount(req.user.account_sid, true);
}
else if (decodedJwt.scope === 'service_provider') {
results = await User.retrieveAllForServiceProvider(decodedJwt.service_provider_sid, true);
}
else {
throw new DbErrorBadRequest(`invalid scope: ${decodedJwt.scope}`);
else if (req.user.hasServiceProviderAuth) {
results = await User.retrieveAllForServiceProvider(req.user.service_provider_sid, true);
}
if (results.length === 0) throw new Error('failure retrieving users list');
@@ -222,8 +256,6 @@ router.get('/me', async(req, res) => {
router.get('/:user_sid', async(req, res) => {
const logger = req.app.locals.logger;
const token = req.user.jwt;
const decodedJwt = jwt.verify(token, process.env.JWT_SECRET);
const {user_sid} = req.params;
try {
@@ -232,9 +264,9 @@ router.get('/:user_sid', async(req, res) => {
const {hashed_password, ...rest} = user;
if (!user) throw new Error('failure retrieving user');
if (decodedJwt.scope === 'admin' ||
decodedJwt.scope === 'account' && decodedJwt.account_sid === user.account_sid ||
decodedJwt.scope === 'service_provider' && decodedJwt.service_provider_sid === user.service_provider_sid) {
if (req.user.hasAdminAuth ||
req.user.hasAccountAuth && req.user.account_sid === user.account_sid ||
req.user.hasServiceProviderAuth && req.user.service_provider_sid === user.service_provider_sid) {
res.status(200).json(rest);
} else {
res.sendStatus(403);
@@ -249,8 +281,7 @@ router.put('/:user_sid', async(req, res) => {
const logger = req.app.locals.logger;
const {user_sid} = req.params;
const user = await User.retrieve(user_sid);
const token = req.user.jwt;
const decodedJwt = jwt.verify(token, process.env.JWT_SECRET);
const {hasAccountAuth, hasServiceProviderAuth, hasAdminAuth} = req.user;
const {
old_password,
new_password,
@@ -266,15 +297,15 @@ router.put('/:user_sid', async(req, res) => {
//if (req.user.user_sid && req.user.user_sid !== user_sid) return res.sendStatus(403);
if (decodedJwt.scope !== 'admin' &&
!(decodedJwt.scope === 'account' && decodedJwt.account_sid === user[0].account_sid) &&
!(decodedJwt.scope === 'service_provider' && decodedJwt.service_provider_sid === user[0].service_provider_sid) &&
if (!hasAdminAuth &&
!(hasAccountAuth && req.user.account_sid === user[0].account_sid) &&
!(hasServiceProviderAuth && req.user.service_provider_sid === user[0].service_provider_sid) &&
(req.user.user_sid && req.user.user_sid !== user_sid)) {
return res.sendStatus(403);
}
try {
const user = await validateRequest(user_sid, req.body);
const user = await validateRequest(user_sid, req);
if (!user) return res.sendStatus(404);
if (new_password) {
@@ -367,11 +398,12 @@ router.post('/', async(req, res) => {
hashed_password: passwordHash,
};
const allUsers = await User.retrieveAll();
const token = req.user.jwt;
const decodedJwt = jwt.verify(token, process.env.JWT_SECRET);
delete payload.initial_password;
try {
if (req.body.initial_password) {
await validatePasswordSettings(req.body.initial_password);
}
const email = allUsers.find((e) => e.email === payload.email);
const name = allUsers.find((e) => e.name === payload.name);
@@ -385,30 +417,27 @@ router.post('/', async(req, res) => {
return res.status(422).json({msg: 'user with this email already exists'});
}
if (decodedJwt.scope === 'admin') {
if (req.user.hasAdminAuth) {
logger.debug({payload}, 'POST /users');
const uuid = await User.make(payload);
res.status(201).json({user_sid: uuid});
}
else if (decodedJwt.scope === 'account') {
else if (req.user.hasAccountAuth) {
logger.debug({payload}, 'POST /users');
const uuid = await User.make({
...payload,
account_sid: decodedJwt.account_sid,
account_sid: req.user.account_sid,
});
res.status(201).json({user_sid: uuid});
}
else if (decodedJwt.scope === 'service_provider') {
else if (req.user.hasServiceProviderAuth) {
logger.debug({payload}, 'POST /users');
const uuid = await User.make({
...payload,
service_provider_sid: decodedJwt.service_provider_sid,
service_provider_sid: req.user.service_provider_sid,
});
res.status(201).json({user_sid: uuid});
}
else {
throw new DbErrorBadRequest(`invalid scope: ${decodedJwt.scope}`);
}
} catch (err) {
sysError(logger, res, err);
}
@@ -417,23 +446,21 @@ router.post('/', async(req, res) => {
router.delete('/:user_sid', async(req, res) => {
const logger = req.app.locals.logger;
const {user_sid} = req.params;
const token = req.user.jwt;
const decodedJwt = jwt.verify(token, process.env.JWT_SECRET);
const allUsers = await User.retrieveAll();
const activeAdminUsers = allUsers.filter((e) => !e.account_sid && !e.service_provider_sid && e.is_active);
const user = await User.retrieve(user_sid);
try {
if (decodedJwt.scope === 'admin' && activeAdminUsers.length === 1) {
if (req.user.hasAdminAuth && activeAdminUsers.length === 1) {
throw new Error('cannot delete this admin user - there are no other active admin users');
}
if (decodedJwt.scope === 'admin' ||
(decodedJwt.scope === 'account' && decodedJwt.account_sid === user[0].account_sid) ||
(decodedJwt.scope === 'service_provider' && decodedJwt.service_provider_sid === user[0].service_provider_sid)) {
if (req.user.hasAdminAuth ||
(req.user.hasAccountAuth && req.user.account_sid === user[0].account_sid) ||
(req.user.hasServiceProviderAuth && req.user.service_provider_sid === user[0].service_provider_sid)) {
await User.remove(user_sid);
//logout user after self-delete
if (decodedJwt.user_sid === user_sid) {
if (req.user.user_sid === user_sid) {
request({
url:'http://localhost:3000/v1/logout',
method: 'POST',
@@ -447,12 +474,11 @@ router.delete('/:user_sid', async(req, res) => {
}
return res.sendStatus(204);
} else {
throw new DbErrorBadRequest(`invalid scope: ${decodedJwt.scope}`);
throw new DbErrorBadRequest('invalid request');
}
} catch (err) {
sysError(logger, res, err);
}
});
module.exports = router;

View File

@@ -1,9 +1,10 @@
const { v4: uuid } = require('uuid');
const { v4: uuid, validate } = require('uuid');
const bent = require('bent');
const Account = require('../../models/account');
const {promisePool} = require('../../db');
const {cancelSubscription, detachPaymentMethod} = require('../../utils/stripe-utils');
const freePlans = require('../../utils/free_plans');
const { BadRequestError, DbErrorBadRequest } = require('../../utils/errors');
const insertAccountSubscriptionSql = `INSERT INTO account_subscriptions
(account_subscription_sid, account_sid)
values (?, ?)`;
@@ -139,37 +140,76 @@ const createTestAlerts = async(writeAlerts, AlertType, account_sid) => {
const parseServiceProviderSid = (req) => {
const arr = /ServiceProviders\/([^\/]*)/.exec(req.originalUrl);
if (arr) return arr[1];
if (arr) {
const sid = arr[1];
const sid_validation = validate(sid);
if (!sid_validation) {
throw new BadRequestError('invalid service_provider_sid format');
}
return arr[1];
}
};
const parseAccountSid = (req) => {
const arr = /Accounts\/([^\/]*)/.exec(req.originalUrl);
if (arr) return arr[1];
if (arr) {
const sid = arr[1];
const sid_validation = validate(sid);
if (!sid_validation) {
throw new BadRequestError('invalid account_sid format');
}
return arr[1];
}
};
const hasAccountPermissions = (req, res, next) => {
if (req.user.hasScope('admin')) return next();
if (req.user.hasScope('service_provider')) return next();
if (req.user.hasScope('account')) {
const account_sid = parseAccountSid(req);
if (account_sid === req.user.account_sid) return next();
try {
if (req.user.hasScope('admin')) {
return next();
}
if (req.user.hasScope('service_provider')) {
return next();
}
if (req.user.hasScope('account')) {
const account_sid = parseAccountSid(req);
if (account_sid === req.user.account_sid) {
return next();
}
}
res.status(403).json({
status: 'fail',
message: 'insufficient privileges'
});
} catch (error) {
throw error;
}
res.status(403).json({
status: 'fail',
message: 'insufficient privileges'
});
};
const hasServiceProviderPermissions = (req, res, next) => {
if (req.user.hasScope('admin')) return next();
if (req.user.hasScope('service_provider')) {
const service_provider_sid = parseServiceProviderSid(req);
if (service_provider_sid === req.user.service_provider_sid) return next();
try {
if (req.user.hasScope('admin')) {
return next();
}
if (req.user.hasScope('service_provider')) {
const service_provider_sid = parseServiceProviderSid(req);
if (service_provider_sid === req.user.service_provider_sid) {
return next();
}
}
res.status(403).json({
status: 'fail',
message: 'insufficient privileges'
});
} catch (error) {
throw error;
}
res.status(403).json({
status: 'fail',
message: 'insufficient privileges'
});
};
const checkLimits = async(req, res, next) => {
@@ -274,6 +314,31 @@ const disableSubspace = async(opts) => {
return;
};
const validatePasswordSettings = async(password) => {
const sql = 'SELECT * from password_settings';
const [rows] = await promisePool.execute(sql);
const specialChars = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]+/;
const numbers = /[0-9]+/;
if (rows.length === 0) {
if (password.length < 8 || password.length > 20) {
throw new DbErrorBadRequest('password length must be between 8 and 20');
}
} else {
if (rows[0].min_password_length && password.length < rows[0].min_password_length) {
throw new DbErrorBadRequest(`password must be at least ${rows[0].min_password_length} characters long`);
}
if (rows[0].require_digit === 1 && !numbers.test(password)) {
throw new DbErrorBadRequest('password must contain at least one digit');
}
if (rows[0].require_special_character === 1 && !specialChars.test(password)) {
throw new DbErrorBadRequest('password must contain at least one special character');
}
}
return;
};
module.exports = {
setupFreeTrial,
createTestCdrs,
@@ -284,5 +349,6 @@ module.exports = {
hasServiceProviderPermissions,
checkLimits,
enableSubspace,
disableSubspace
disableSubspace,
validatePasswordSettings
};

View File

@@ -1,6 +1,15 @@
const {DbErrorBadRequest, DbErrorUnprocessableRequest, DbErrorForbidden} = require('../utils/errors');
const {
BadRequestError,
DbErrorBadRequest,
DbErrorUnprocessableRequest,
DbErrorForbidden
} = require('../utils/errors');
function sysError(logger, res, err) {
if (err instanceof BadRequestError) {
logger.info(err, err.message);
return res.status(400).json({msg: 'Bad request'});
}
if (err instanceof DbErrorBadRequest) {
logger.info(err, 'invalid client request');
return res.status(400).json({msg: err.message});

View File

@@ -10,7 +10,34 @@ info:
version: 1.0.0
servers:
- url: /v1
description: development server
description: jambonz API server
tags:
- name: Authentication
description: Authentication operations
- name: Accounts
description: Accounts operations
- name: Users
description: Users operations
- name: Applications
description: Applications operations
- name: Phone Numbers
description: Phone Numbers operations
- name: Api Keys
description: Api Keys operations
- name: Service Providers
description: Service Providers operations
- name: SBCs
description: SBCs operations
- name: Voip Carriers
description: Voip Carriers operations
- name: Sip Gateways
description: Sip Gateways operations
- name: Smpp Gateways
description: Smpp Gateways operations
- name: Webhooks
description: Webhooks operations
- name: Microsoft Teams Tenants
description: Microsoft Teams Tenants operations
paths:
/BetaInviteCodes:
post:
@@ -79,6 +106,8 @@ paths:
type: string
format: uuid
post:
tags:
- Accounts
summary: add a VoiPCarrier to an account based on PredefinedCarrier template
operationId: createVoipCarrierFromTemplate
responses:
@@ -96,6 +125,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
/Sbcs:
post:
tags:
- SBCs
summary: add an SBC address
operationId: createSbc
requestBody:
@@ -134,7 +165,9 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
summary: retrieve public IP addresses of the jambonz Sbcs
tags:
- SBCs
summary: retrieve public IP addresses of the jambonz SBCs
operationId: listSbcs
parameters:
- in: query
@@ -171,7 +204,9 @@ paths:
schema:
type: string
delete:
summary: delete sbc address
tags:
- SBCs
summary: delete SBC address
operationId: deleteSbcAddress
responses:
200:
@@ -240,6 +275,8 @@ paths:
/ApiKeys:
post:
tags:
- Api Keys
summary: create an api key
operationId: createApikey
requestBody:
@@ -277,7 +314,7 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/GeneralError'
/Apikeys/{ApiKeySid}:
/ApiKeys/{ApiKeySid}:
parameters:
- name: ApiKeySid
in: path
@@ -285,6 +322,8 @@ paths:
schema:
type: string
delete:
tags:
- Api Keys
summary: delete api key
operationId: deleteApiKey
responses:
@@ -294,6 +333,8 @@ paths:
description: api key or account not found
/signin:
post:
tags:
- Authentication
summary: sign in using email and password
operationId: loginUser
requestBody:
@@ -335,6 +376,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
/logout:
post:
tags:
- Authentication
summary: log out and deactivate jwt
operationId: logoutUser
responses:
@@ -348,6 +391,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
/forgot-password:
post:
tags:
- Authentication
summary: send link to reset password
operationId: forgotPassword
requestBody:
@@ -377,6 +422,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
/change-password:
post:
tags:
- Authentication
summary: changePassword
operationId: changePassword
requestBody:
@@ -408,6 +455,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
/register:
post:
tags:
- Authentication
summary: create a new user and account
operationId: registerUser
requestBody:
@@ -516,7 +565,9 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
/Users:
get:
get:
tags:
- Users
summary: list all users
operationId: listUsers
responses:
@@ -547,6 +598,8 @@ paths:
schema:
type: string
get:
tags:
- Users
summary: retrieve user information
operationId: getUser
requestBody:
@@ -583,6 +636,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
put:
tags:
- Users
summary: update user information
operationId: updateUser
requestBody:
@@ -603,8 +658,6 @@ paths:
new_password:
type: string
description: new password
name:
type: string
is_active:
type: boolean
force_change:
@@ -629,6 +682,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
post:
tags:
- Users
summary: create a new user
operationId: createUser
requestBody:
@@ -649,8 +704,6 @@ paths:
type: string
permissions:
type: array
force_change:
type: boolean
old_password:
type: string
description: existing password, which is to be replaced
@@ -670,6 +723,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
delete:
tags:
- Users
summary: delete a user
operationId: deleteUser
responses:
@@ -687,6 +742,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
/Users/me:
get:
tags:
- Users
summary: retrieve details about logged-in user and associated account
operationId: getMyDetails
responses:
@@ -852,6 +909,8 @@ paths:
/ActivationCode:
post:
tags:
- Authentication
summary: send an activation code to the user
operationId: sendActivationCode
requestBody:
@@ -897,6 +956,8 @@ paths:
schema:
type: string
put:
tags:
- Authentication
summary: validate an activation code
operationId: validateActivationCode
requestBody:
@@ -976,6 +1037,8 @@ paths:
schema:
type: string
get:
tags:
- Webhooks
summary: retrieve webhook
operationId: getWebhook
responses:
@@ -996,6 +1059,8 @@ paths:
/VoipCarriers:
post:
tags:
- Voip Carriers
summary: create voip carrier
operationId: createVoipCarrier
requestBody:
@@ -1083,6 +1148,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Voip Carriers
summary: list voip carriers
operationId: listVoipCarriers
responses:
@@ -1110,6 +1177,8 @@ paths:
schema:
type: string
delete:
tags:
- Voip Carriers
summary: delete a voip carrier
operationId: deleteVoipCarrier
responses:
@@ -1132,6 +1201,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Voip Carriers
summary: retrieve voip carrier
operationId: getVoipCarrier
responses:
@@ -1150,6 +1221,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
put:
tags:
- Voip Carriers
summary: update voip carrier
operationId: updateVoipCarrier
parameters:
@@ -1189,6 +1262,8 @@ paths:
/SipGateways:
post:
tags:
- Sip Gateways
summary: create sip gateway
operationId: createSipGateway
requestBody:
@@ -1240,6 +1315,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Sip Gateways
summary: list sip gateways
operationId: listSipGateways
parameters:
@@ -1274,6 +1351,8 @@ paths:
schema:
type: string
delete:
tags:
- Sip Gateways
summary: delete a sip gateway
operationId: deleteSipGateway
responses:
@@ -1288,6 +1367,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Sip Gateways
summary: retrieve sip gateway
operationId: getSipGateway
responses:
@@ -1306,6 +1387,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
put:
tags:
- Sip Gateways
summary: update sip gateway
operationId: updateSipGateway
requestBody:
@@ -1332,6 +1415,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
/SmppGateways:
post:
tags:
- Smpp Gateways
summary: create smpp gateway
operationId: createSmppGateway
requestBody:
@@ -1387,6 +1472,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Smpp Gateways
summary: list smpp gateways
operationId: listSmppGateways
responses:
@@ -1414,6 +1501,8 @@ paths:
schema:
type: string
delete:
tags:
- Smpp Gateways
summary: delete a smpp gateway
operationId: deleteSmppGateway
responses:
@@ -1428,6 +1517,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Smpp Gateways
summary: retrieve smpp gateway
operationId: getSmppGateway
responses:
@@ -1446,7 +1537,9 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
put:
summary: update sip gateway
tags:
- Smpp Gateways
summary: update smpp gateway
operationId: updateSmppGateway
requestBody:
content:
@@ -1472,6 +1565,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
/PhoneNumbers:
post:
tags:
- Phone Numbers
summary: provision a phone number into inventory from a Voip Carrier
operationId: provisionPhoneNumber
requestBody:
@@ -1527,6 +1622,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Phone Numbers
summary: list phone numbers
operationId: listProvisionedPhoneNumbers
responses:
@@ -1554,6 +1651,8 @@ paths:
schema:
type: string
delete:
tags:
- Phone Numbers
summary: delete a phone number
operationId: deletePhoneNumber
responses:
@@ -1576,6 +1675,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Phone Numbers
summary: retrieve phone number
operationId: getPhoneNumber
responses:
@@ -1594,6 +1695,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
put:
tags:
- Phone Numbers
summary: update phone number
operationId: updatePhoneNumber
requestBody:
@@ -1625,6 +1728,8 @@ paths:
/ServiceProviders:
post:
tags:
- Service Providers
summary: create service provider
operationId: createServiceProvider
requestBody:
@@ -1667,6 +1772,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Service Providers
summary: list service providers
operationId: listServiceProviders
responses:
@@ -1695,6 +1802,8 @@ paths:
schema:
type: string
delete:
tags:
- Service Providers
summary: delete a service provider
operationId: deleteServiceProvider
responses:
@@ -1716,6 +1825,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Service Providers
summary: retrieve service provider
operationId: getServiceProvider
responses:
@@ -1733,8 +1844,10 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/GeneralError'
put:
tags:
- Service Providers
summary: update service provider
operationId: updateServiceProvider
requestBody:
@@ -1769,6 +1882,8 @@ paths:
type: string
format: uuid
get:
tags:
- Service Providers
summary: get all accounts for a service provider
operationId: getServiceProviderAccounts
responses:
@@ -1794,6 +1909,8 @@ paths:
type: string
format: uuid
get:
tags:
- Service Providers
summary: get all carriers for a service provider
operationId: getServiceProviderCarriers
responses:
@@ -1810,6 +1927,8 @@ paths:
404:
description: service provider not found
post:
tags:
- Service Providers
summary: create a carrier
operationId: createCarrierForServiceProvider
requestBody:
@@ -1841,6 +1960,8 @@ paths:
type: string
format: uuid
post:
tags:
- Service Providers
summary: add a VoiPCarrier to a service provider based on PredefinedCarrier template
operationId: createVoipCarrierFromTemplate
responses:
@@ -1866,6 +1987,8 @@ paths:
type: string
format: uuid
post:
tags:
- Service Providers
summary: create a speech credential for a service provider
operationId: addSpeechCredentialForSeerviceProvider
requestBody:
@@ -1897,6 +2020,8 @@ paths:
type: string
format: uuid
get:
tags:
- Service Providers
summary: get a specific speech credential
operationId: getSpeechCredential
responses:
@@ -1909,6 +2034,8 @@ paths:
404:
description: credential not found
put:
tags:
- Service Providers
summary: update a speech credential
operationId: updateSpeechCredential
requestBody:
@@ -1928,6 +2055,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
delete:
tags:
- Service Providers
summary: delete a speech credential
operationId: deleteSpeechCredential
responses:
@@ -1937,6 +2066,8 @@ paths:
description: credential not found
/ServiceProviders/{ServiceProviderSid}/SpeechCredentials/{SpeechCredentialSid}/test:
get:
tags:
- Service Providers
summary: test a speech credential
operationId: testSpeechCredential
parameters:
@@ -1991,6 +2122,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
/ServiceProviders/{ServiceProviderSid}/Limits:
post:
tags:
- Service Providers
summary: create a limit for a service provider
operationId: addLimitForServiceProvider
parameters:
@@ -2021,6 +2154,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Service Providers
summary: retrieve call capacity and other limits from the service provider
operationId: getServiceProviderLimits
parameters:
@@ -2049,6 +2184,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
/Accounts/{AccountSid}/Limits:
post:
tags:
- Accounts
summary: create a limit for an account
operationId: addLimitForAccount
parameters:
@@ -2079,6 +2216,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Accounts
summary: retrieve call capacity and other limits from the account
operationId: getAccountLimits
parameters:
@@ -2107,6 +2246,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
/MicrosoftTeamsTenants:
post:
tags:
- Microsoft Teams Tenants
summary: provision a customer tenant for MS Teams
operationId: createMsTeamsTenant
requestBody:
@@ -2154,6 +2295,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Microsoft Teams Tenants
summary: list MS Teams tenants
operationId: listMsTeamsTenants
responses:
@@ -2180,6 +2323,8 @@ paths:
type: string
format: uuid
delete:
tags:
- Microsoft Teams Tenants
summary: delete an MS Teams tenant
operationId: deleteTenant
responses:
@@ -2194,6 +2339,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Microsoft Teams Tenants
summary: retrieve an MS Teams tenant
operationId: getTenant
responses:
@@ -2213,7 +2360,9 @@ paths:
$ref: '#/components/schemas/GeneralError'
put:
summary: update tenant
tags:
- Microsoft Teams Tenants
summary: update an MS Teams tenant
operationId: putTenant
requestBody:
content:
@@ -2241,6 +2390,8 @@ paths:
/Accounts:
post:
tags:
- Accounts
summary: create an account
operationId: createAccount
requestBody:
@@ -2408,6 +2559,8 @@ paths:
address should be blacklisted by the platform (0 means forever).
get:
tags:
- Accounts
summary: list accounts
operationId: listAccounts
responses:
@@ -2435,6 +2588,8 @@ paths:
type: string
format: uuid
delete:
tags:
- Accounts
summary: delete an account
operationId: deleteAccount
responses:
@@ -2455,6 +2610,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Accounts
summary: retrieve account
operationId: getAccount
responses:
@@ -2474,6 +2631,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
put:
tags:
- Accounts
summary: update account
operationId: updateAccount
requestBody:
@@ -2512,6 +2671,8 @@ paths:
schema:
type: boolean
get:
tags:
- Accounts
summary: get webhook signing secret, regenerating if requested
operationId: getWebhookSecret
responses:
@@ -2542,6 +2703,8 @@ paths:
type: string
format: uuid
get:
tags:
- Accounts
summary: get all api keys for an account
operationId: getAccountApiKeys
responses:
@@ -2576,6 +2739,8 @@ paths:
schema:
type: string
post:
tags:
- Accounts
summary: add or change the sip realm
operationId: createSipRealm
responses:
@@ -2604,6 +2769,8 @@ paths:
format: uuid
post:
tags:
- Accounts
summary: add a speech credential
operationId: createSpeechCredential
requestBody:
@@ -2631,6 +2798,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Accounts
summary: retrieve all speech credentials for an account
operationId: listSpeechCredentials
responses:
@@ -2659,6 +2828,8 @@ paths:
type: string
format: uuid
get:
tags:
- Accounts
summary: get a specific speech credential
operationId: getSpeechCredential
responses:
@@ -2671,6 +2842,8 @@ paths:
404:
description: credential not found
put:
tags:
- Accounts
summary: update a speech credential
operationId: updateSpeechCredential
requestBody:
@@ -2690,6 +2863,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
delete:
tags:
- Accounts
summary: delete a speech credential
operationId: deleteSpeechCredential
responses:
@@ -2699,6 +2874,8 @@ paths:
description: credential not found
/Accounts/{AccountSid}/SpeechCredentials/{SpeechCredentialSid}/test:
get:
tags:
- Accounts
summary: test a speech credential
operationId: testSpeechCredential
parameters:
@@ -2812,6 +2989,8 @@ paths:
- inbound
- outbound
get:
tags:
- Accounts
summary: retrieve recent calls for an account
operationId: listRecentCalls
responses:
@@ -2908,6 +3087,8 @@ paths:
schema:
type: string
get:
tags:
- Accounts
summary: retrieve sip trace detail for a call
operationId: getRecentCallTrace
responses:
@@ -2933,6 +3114,8 @@ paths:
schema:
type: string
get:
tags:
- Service Providers
summary: retrieve pcap for a call
operationId: getRecentCallTrace
responses:
@@ -3005,6 +3188,8 @@ paths:
- inbound
- outbound
get:
tags:
- Service Providers
summary: retrieve recent calls for an account
operationId: listRecentCalls
responses:
@@ -3104,6 +3289,8 @@ paths:
schema:
type: string
get:
tags:
- Service Providers
summary: retrieve sip trace detail for a call
operationId: getRecentCallTrace
responses:
@@ -3129,6 +3316,8 @@ paths:
schema:
type: string
get:
tags:
- Accounts
summary: retrieve pcap for a call
operationId: getRecentCallTrace
responses:
@@ -3201,6 +3390,8 @@ paths:
- device-limit
- api-limit
get:
tags:
- Service Providers
summary: retrieve alerts for a service provider
operationId: listAlerts
responses:
@@ -3311,6 +3502,8 @@ paths:
- device-limit
- api-limit
get:
tags:
- Accounts
summary: retrieve alerts for an account
operationId: listAlerts
responses:
@@ -3359,6 +3552,8 @@ paths:
description: account not found
/Applications:
post:
tags:
- Applications
summary: create application
operationId: createApplication
requestBody:
@@ -3424,6 +3619,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Applications
summary: list applications
operationId: listApplications
responses:
@@ -3452,6 +3649,8 @@ paths:
schema:
type: string
delete:
tags:
- Applications
summary: delete an application
operationId: deleteApplication
responses:
@@ -3472,6 +3671,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Applications
summary: retrieve an application
responses:
200:
@@ -3489,6 +3690,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
put:
tags:
- Applications
summary: update application
operationId: updateApplication
requestBody:
@@ -3517,6 +3720,8 @@ paths:
/Accounts/{AccountSid}/Calls:
post:
tags:
- Accounts
summary: create a call
operationId: createCall
parameters:
@@ -3578,6 +3783,8 @@ paths:
400:
description: bad request
get:
tags:
- Accounts
summary: list calls
operationId: listCalls
parameters:
@@ -3618,6 +3825,8 @@ paths:
schema:
type: string
delete:
tags:
- Accounts
summary: delete a call
operationId: deleteCall
responses:
@@ -3638,6 +3847,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Accounts
summary: retrieve a call
operationId: getCall
responses:
@@ -3656,6 +3867,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
post:
tags:
- Accounts
summary: update a call
operationId: updateCall
requestBody:
@@ -3741,6 +3954,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
/Accounts/{AccountSid}/Messages:
post:
tags:
- Accounts
summary: create an outgoing SMS message
operationId: createMessage
parameters:
@@ -4262,6 +4477,8 @@ components:
type: boolean
stt_tested_ok:
type: boolean
riva_server_uri:
type: string
SpeechCredentialUpdate:
properties:
use_for_tts:

View File

@@ -1,6 +1,7 @@
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,}))$/;
@@ -8,6 +9,44 @@ const validateEmail = (email) => {
};
const emailSimpleText = async(logger, to, subject, text) => {
const from = 'jambonz Support <support@jambonz.org>';
if (process.env.CUSTOM_EMAIL_VENDOR_URL) {
await sendEmailByCustomVendor(logger, from, to, subject, text);
} else {
await sendEmailByMailgun(logger, from, to, subject, text);
}
};
const sendEmailByCustomVendor = async(logger, from, to, subject, text) => {
try {
const post = bent('POST', {
'Content-Type': 'application/json',
...((process.env.CUSTOM_EMAIL_VENDOR_USERNAME && process.env.CUSTOM_EMAIL_VENDOR_PASSWORD) &&
({
'Authorization':`Basic ${Buffer.from(
`${process.env.CUSTOM_EMAIL_VENDOR_USERNAME}:${process.env.CUSTOM_EMAIL_VENDOR_PASSWORD}`
).toString('base64')}`
}))
});
const res = await post(process.env.CUSTOM_EMAIL_VENDOR_URL, {
from,
to,
subject,
text
});
logger.debug({
res
}, 'sent email to custom vendor.');
} catch (err) {
logger.info({
err
}, 'Error sending email From Custom email vendor');
}
};
const sendEmailByMailgun = async(logger, from, to, subject, text) => {
const mg = mailgun.client({
username: 'api',
key: process.env.MAILGUN_API_KEY
@@ -17,14 +56,18 @@ const emailSimpleText = async(logger, to, subject, text) => {
try {
const res = await mg.messages.create(process.env.MAILGUN_DOMAIN, {
from: 'jambonz Support <support@jambonz.org>',
from,
to,
subject,
text
});
logger.debug({res}, 'sent email');
logger.debug({
res
}, 'sent email');
} catch (err) {
logger.info({err}, 'Error sending email');
logger.info({
err
}, 'Error sending email From mailgun');
}
};

View File

@@ -2,9 +2,9 @@ const crypto = require('crypto');
const algorithm = process.env.LEGACY_CRYPTO ? 'aes-256-ctr' : 'aes-256-cbc';
const iv = crypto.randomBytes(16);
const secretKey = crypto.createHash('sha256')
.update(String(process.env.JWT_SECRET))
.update(process.env.ENCRYPTION_SECRET || process.env.JWT_SECRET)
.digest('base64')
.substr(0, 32);
.substring(0, 32);
const encrypt = (text) => {
const cipher = crypto.createCipheriv(algorithm, secretKey, iv);

View File

@@ -1,3 +1,9 @@
class BadRequestError extends Error {
constructor(msg) {
super(msg);
}
}
class DbError extends Error {
constructor(msg) {
super(msg);
@@ -23,6 +29,7 @@ class DbErrorForbidden extends DbError {
}
module.exports = {
BadRequestError,
DbError,
DbErrorBadRequest,
DbErrorUnprocessableRequest,

View File

@@ -1,112 +0,0 @@
const {DbErrorBadRequest} = require('../utils/errors');
const assert = require('assert');
const _specData = require('./specs');
const specs = new Map();
for (const key in _specData) { specs.set(key, _specData[key]); }
function normalizeJambones(logger, obj) {
if (!Array.isArray(obj)) throw new DbErrorBadRequest('malformed jambonz payload: must be array');
const document = [];
for (const tdata of obj) {
if (typeof tdata !== 'object') throw new DbErrorBadRequest('malformed jambonz payload: must be array of objects');
if ('verb' in tdata) {
// {verb: 'say', text: 'foo..bar'..}
const name = tdata.verb;
const o = {};
Object.keys(tdata)
.filter((k) => k !== 'verb')
.forEach((k) => o[k] = tdata[k]);
const o2 = {};
o2[name] = o;
document.push(o2);
}
else if (Object.keys(tdata).length === 1) {
// {'say': {..}}
document.push(tdata);
}
else {
logger.info(tdata, 'malformed jambonz payload: missing verb property');
throw new DbErrorBadRequest('malformed jambonz payload: missing verb property');
}
}
logger.debug({ document }, `normalizeJambones: returning document with ${document.length} tasks`);
return document;
}
function validate(logger, obj) {
normalizeJambones(logger, obj).map((tdata) => {
const keys = Object.keys(tdata);
const name = keys[0];
const data = tdata[name];
validateVerb(name, data, logger);
});
}
function validateVerb(name, data, logger) {
logger.debug(`validating ${name} with data ${JSON.stringify(data)}`);
// validate the instruction is supported
if (!specs.has(name)) throw new DbErrorBadRequest(`invalid instruction: ${name}`);
// check type of each element and make sure required elements are present
const specData = specs.get(name);
let required = specData.required || [];
for (const dKey in data) {
if (dKey in specData.properties) {
const dVal = data[dKey];
const dSpec = specData.properties[dKey];
logger.debug(`Task:validate validating property ${dKey} with value ${JSON.stringify(dVal)}`);
if (typeof dSpec === 'string' && dSpec === 'array') {
if (!Array.isArray(dVal)) throw new DbErrorBadRequest(`${name}: property ${dKey} is not an array`);
}
else if (typeof dSpec === 'string' && dSpec.includes('|')) {
const types = dSpec.split('|').map((t) => t.trim());
if (!types.includes(typeof dVal) && !(types.includes('array') && Array.isArray(dVal))) {
throw new DbErrorBadRequest(`${name}: property ${dKey} has invalid data type, must be one of ${types}`);
}
}
else if (typeof dSpec === 'string' && ['number', 'string', 'object', 'boolean'].includes(dSpec)) {
// simple types
if (typeof dVal !== specData.properties[dKey]) {
throw new DbErrorBadRequest(`${name}: property ${dKey} has invalid data type`);
}
}
else if (Array.isArray(dSpec) && dSpec[0].startsWith('#')) {
const name = dSpec[0].slice(1);
for (const item of dVal) {
validate(name, item);
}
}
else if (typeof dSpec === 'object') {
// complex types
const type = dSpec.type;
assert.ok(['number', 'string', 'object', 'boolean'].includes(type),
`invalid or missing type in spec ${JSON.stringify(dSpec)}`);
if (type === 'string' && dSpec.enum) {
assert.ok(Array.isArray(dSpec.enum), `enum must be an array ${JSON.stringify(dSpec.enum)}`);
if (!dSpec.enum.includes(dVal)) {
throw new DbErrorBadRequest(`invalid value ${dVal} must be one of ${dSpec.enum}`);
}
}
}
else if (typeof dSpec === 'string' && dSpec.startsWith('#')) {
// reference to another datatype (i.e. nested type)
const name = dSpec.slice(1);
//const obj = {};
//obj[name] = dVal;
validate(name, dVal);
}
else {
assert.ok(0, `invalid spec ${JSON.stringify(dSpec)}`);
}
required = required.filter((item) => item !== dKey);
}
else if (dKey === '_') {
/* no op: allow arbitrary info to be carried here, used by conference e.g in transfer */
}
else throw new DbErrorBadRequest(`${name}: unknown property ${dKey}`);
}
if (required.length > 0) throw new DbErrorBadRequest(`${name}: missing value for ${required}`);
}
module.exports = validate;

View File

@@ -1,18 +1,19 @@
const crypto = require('crypto');
const { argon2i } = require('argon2-ffi');
const argon2 = require('argon2');
const util = require('util');
const { argon2i } = argon2;
const getRandomBytes = util.promisify(crypto.randomBytes);
const generateHashedPassword = async(password) => {
const salt = await getRandomBytes(32);
const passwordHash = await argon2i.hash(password, salt);
const passwordHash = await argon2.hash(password, { type: argon2i, salt });
return passwordHash;
};
const verifyPassword = async(passwordHash, password) => {
const isCorrect = await argon2i.verify(passwordHash, password);
return isCorrect;
const verifyPassword = (passwordHash, password) => {
return argon2.verify(passwordHash, password);
};
const hashString = (s) => crypto.createHash('md5').update(s).digest('hex');

View File

@@ -1,759 +0,0 @@
{
"sip:decline": {
"properties": {
"id": "string",
"status": "number",
"reason": "string",
"headers": "object"
},
"required": [
"status"
]
},
"sip:request": {
"properties": {
"id": "string",
"method": "string",
"body": "string",
"headers": "object",
"actionHook": "object|string"
},
"required": [
"method"
]
},
"sip:refer": {
"properties": {
"id": "string",
"referTo": "string",
"referredBy": "string",
"headers": "object",
"actionHook": "object|string",
"eventHook": "object|string"
},
"required": [
"referTo"
]
},
"config": {
"properties": {
"id": "string",
"synthesizer": "#synthesizer",
"recognizer": "#recognizer",
"bargeIn": "#bargeIn",
"record": "#recordOptions",
"amd": "#amd",
"notifyEvents": "boolean"
},
"required": []
},
"bargeIn": {
"properties": {
"enable": "boolean",
"sticky": "boolean",
"actionHook": "object|string",
"input": "array",
"finishOnKey": "string",
"numDigits": "number",
"minDigits": "number",
"maxDigits": "number",
"interDigitTimeout": "number",
"dtmfBargein": "boolean",
"minBargeinWordCount": "number"
},
"required": [
"enable"
]
},
"dequeue": {
"properties": {
"id": "string",
"name": "string",
"actionHook": "object|string",
"timeout": "number",
"beep": "boolean"
},
"required": [
"name"
]
},
"enqueue": {
"properties": {
"id": "string",
"name": "string",
"actionHook": "object|string",
"waitHook": "object|string",
"_": "object"
},
"required": [
"name"
]
},
"leave": {
"properties": {
"id": "string"
}
},
"hangup": {
"properties": {
"id": "string",
"headers": "object"
},
"required": [
]
},
"play": {
"properties": {
"id": "string",
"url": "string|array",
"loop": "number|string",
"earlyMedia": "boolean",
"seekOffset": "number|string",
"timeoutSecs": "number|string",
"actionHook": "object|string"
},
"required": [
"url"
]
},
"say": {
"properties": {
"id": "string",
"text": "string|array",
"loop": "number|string",
"synthesizer": "#synthesizer",
"earlyMedia": "boolean",
"disableTtsCache": "boolean"
},
"required": [
"text"
]
},
"gather": {
"properties": {
"id": "string",
"actionHook": "object|string",
"finishOnKey": "string",
"input": "array",
"numDigits": "number",
"minDigits": "number",
"maxDigits": "number",
"interDigitTimeout": "number",
"partialResultHook": "object|string",
"speechTimeout": "number",
"listenDuringPrompt": "boolean",
"dtmfBargein": "boolean",
"bargein": "boolean",
"minBargeinWordCount": "number",
"timeout": "number",
"recognizer": "#recognizer",
"play": "#play",
"say": "#say"
},
"required": [
]
},
"conference": {
"properties": {
"id": "string",
"name": "string",
"beep": "boolean",
"startConferenceOnEnter": "boolean",
"endConferenceOnExit": "boolean",
"maxParticipants": "number",
"joinMuted": "boolean",
"actionHook": "object|string",
"waitHook": "object|string",
"statusEvents": "array",
"statusHook": "object|string",
"enterHook": "object|string",
"record": "#record"
},
"required": [
"name"
]
},
"dial": {
"properties": {
"id": "string",
"actionHook": "object|string",
"answerOnBridge": "boolean",
"callerId": "string",
"confirmHook": "object|string",
"referHook": "object|string",
"dialMusic": "string",
"dtmfCapture": "object",
"dtmfHook": "object|string",
"headers": "object",
"listen": "#listen",
"target": ["#target"],
"timeLimit": "number",
"timeout": "number",
"proxy": "string",
"transcribe": "#transcribe",
"amd": "#amd"
},
"required": [
"target"
]
},
"dialogflow": {
"properties": {
"id": "string",
"credentials": "object|string",
"project": "string",
"environment": "string",
"region": {
"type": "string",
"enum": ["europe-west1", "europe-west2", "australia-southeast1", "asia-northeast1"]
},
"lang": "string",
"actionHook": "object|string",
"eventHook": "object|string",
"events": "[string]",
"welcomeEvent": "string",
"welcomeEventParams": "object",
"noInputTimeout": "number",
"noInputEvent": "string",
"passDtmfAsTextInput": "boolean",
"thinkingMusic": "string",
"tts": "#synthesizer",
"bargein": "boolean"
},
"required": [
"project",
"credentials",
"lang"
]
},
"dtmf": {
"properties": {
"id": "string",
"dtmf": "string",
"duration": "number"
},
"required": [
"dtmf"
]
},
"lex": {
"properties": {
"id": "string",
"botId": "string",
"botAlias": "string",
"credentials": "object",
"region": "string",
"locale": "string",
"intent": "#lexIntent",
"welcomeMessage": "string",
"metadata": "object",
"bargein": "boolean",
"passDtmf": "boolean",
"actionHook": "object|string",
"eventHook": "object|string",
"noInputTimeout": "number",
"tts": "#synthesizer"
},
"required": [
"botId",
"botAlias",
"region",
"credentials"
]
},
"listen": {
"properties": {
"id": "string",
"actionHook": "object|string",
"auth": "#auth",
"finishOnKey": "string",
"maxLength": "number",
"metadata": "object",
"mixType": {
"type": "string",
"enum": ["mono", "stereo", "mixed"]
},
"passDtmf": "boolean",
"playBeep": "boolean",
"sampleRate": "number",
"timeout": "number",
"transcribe": "#transcribe",
"url": "string",
"wsAuth": "#auth",
"earlyMedia": "boolean"
},
"required": [
"url"
]
},
"message": {
"properties": {
"id": "string",
"carrier": "string",
"account_sid": "string",
"message_sid": "string",
"to": "string",
"from": "string",
"text": "string",
"media": "string|array",
"actionHook": "object|string"
},
"required": [
"to",
"from"
]
},
"pause": {
"properties": {
"id": "string",
"length": "number"
},
"required": [
"length"
]
},
"rasa": {
"properties": {
"id": "string",
"url": "string",
"recognizer": "#recognizer",
"tts": "#synthesizer",
"prompt": "string",
"actionHook": "object|string",
"eventHook": "object|string"
},
"required": [
"url"
]
},
"record": {
"properties": {
"path": "string"
},
"required": [
"path"
]
},
"recordOptions": {
"properties": {
"action": {
"type": "string",
"enum": ["startCallRecording", "stopCallRecording", "pauseCallRecording", "resumeCallRecording"]
},
"recordingID": "string",
"siprecServerURL": "string"
},
"required": [
"action"
]
},
"redirect": {
"properties": {
"id": "string",
"actionHook": "object|string"
},
"required": [
"actionHook"
]
},
"rest:dial": {
"properties": {
"id": "string",
"account_sid": "string",
"application_sid": "string",
"call_hook": "object|string",
"call_status_hook": "object|string",
"from": "string",
"fromHost": "string",
"speech_synthesis_vendor": "string",
"speech_synthesis_voice": "string",
"speech_synthesis_language": "string",
"speech_recognizer_vendor": "string",
"speech_recognizer_language": "string",
"tag": "object",
"to": "#target",
"headers": "object",
"timeout": "number"
},
"required": [
"call_hook",
"from",
"to"
]
},
"tag": {
"properties": {
"id": "string",
"data": "object"
},
"required": [
"data"
]
},
"transcribe": {
"properties": {
"id": "string",
"transcriptionHook": "string",
"recognizer": "#recognizer",
"earlyMedia": "boolean"
},
"required": [
"recognizer"
]
},
"target": {
"properties": {
"type": {
"type": "string",
"enum": ["phone", "sip", "user", "teams"]
},
"confirmHook": "object|string",
"method": {
"type": "string",
"enum": ["GET", "POST"]
},
"headers": "object",
"from": "#dialFrom",
"name": "string",
"number": "string",
"sipUri": "string",
"auth": "#auth",
"vmail": "boolean",
"tenant": "string",
"trunk": "string",
"overrideTo": "string"
},
"required": [
"type"
]
},
"dialFrom": {
"properties": {
"user": "string",
"host": "string"
},
"required": [
]
},
"auth": {
"properties": {
"username": "string",
"password": "string"
},
"required": [
"username",
"password"
]
},
"synthesizer": {
"properties": {
"vendor": {
"type": "string",
"enum": ["google", "aws", "polly", "microsoft", "nuance", "ibm", "default"]
},
"language": "string",
"voice": "string",
"engine": {
"type": "string",
"enum": ["standard", "neural"]
},
"gender": {
"type": "string",
"enum": ["MALE", "FEMALE", "NEUTRAL"]
}
},
"required": [
"vendor"
]
},
"recognizer": {
"properties": {
"vendor": {
"type": "string",
"enum": ["google", "aws", "microsoft", "nuance", "deepgram", "ibm", "default"]
},
"language": "string",
"vad": "#vad",
"hints": "array",
"hintsBoost": "number",
"altLanguages": "array",
"profanityFilter": "boolean",
"interim": "boolean",
"singleUtterance": "boolean",
"dualChannel": "boolean",
"separateRecognitionPerChannel": "boolean",
"punctuation": "boolean",
"enhancedModel": "boolean",
"words": "boolean",
"diarization": "boolean",
"diarizationMinSpeakers": "number",
"diarizationMaxSpeakers": "number",
"interactionType": {
"type": "string",
"enum": [
"unspecified",
"discussion",
"presentation",
"phone_call",
"voicemail",
"voice_search",
"voice_command",
"dictation"
]
},
"naicsCode": "number",
"identifyChannels": "boolean",
"vocabularyName": "string",
"vocabularyFilterName": "string",
"filterMethod": {
"type": "string",
"enum": [
"remove",
"mask",
"tag"
]
},
"model": "string",
"outputFormat": {
"type": "string",
"enum": [
"simple",
"detailed"
]
},
"profanityOption": {
"type": "string",
"enum": [
"masked",
"removed",
"raw"
]
},
"requestSnr": "boolean",
"initialSpeechTimeoutMs": "number",
"azureServiceEndpoint": "string",
"azureSttEndpointId": "string",
"asrDtmfTerminationDigit": "string",
"asrTimeout": "number",
"nuanceOptions": "#nuanceOptions",
"deepgramOptions": "#deepgramOptions",
"ibmOptions": "#ibmOptions"
},
"required": [
"vendor"
]
},
"ibmOptions": {
"properties": {
"sttApiKey": "string",
"sttRegion": "string",
"ttsApiKey": "string",
"ttsRegion": "string",
"instanceId": "string",
"model": "string",
"languageCustomizationId": "string",
"acousticCustomizationId": "string",
"baseModelVersion": "string",
"watsonMetadata": "string",
"watsonLearningOptOut": "boolean"
},
"required": [
]
},
"deepgramOptions": {
"properties": {
"apiKey": "string",
"tier": {
"type": "string",
"enum": [
"enhanced",
"base"
]
},
"model": {
"type": "string",
"enum": [
"general",
"meeting",
"phonecall",
"voicemail",
"finance",
"conversationalai",
"video",
"custom"
]
},
"customModel": "string",
"version": "string",
"punctuate": "boolean",
"profanityFilter": "boolean",
"redact": {
"type": "string",
"enum": [
"pci",
"numbers",
"true",
"ssn"
]
},
"diarize": "boolean",
"diarizeVersion": "string",
"ner": "boolean",
"multichannel": "boolean",
"alternatives": "number",
"numerals": "boolean",
"search": "array",
"replace": "array",
"keywords": "array",
"endpointing": "boolean",
"vadTurnoff": "number",
"tag": "string"
}
},
"nuanceOptions": {
"properties": {
"clientId": "string",
"secret": "string",
"kryptonEndpoint": "string",
"topic": "string",
"utteranceDetectionMode": {
"type": "string",
"enum": [
"single",
"multiple",
"disabled"
]
},
"punctuation": "boolean",
"profanityFilter": "boolean",
"includeTokenization": "boolean",
"discardSpeakerAdaptation": "boolean",
"suppressCallRecording": "boolean",
"maskLoadFailures": "boolean",
"suppressInitialCapitalization": "boolean",
"allowZeroBaseLmWeight": "boolean",
"filterWakeupWord": "boolean",
"resultType": {
"type": "string",
"enum": [
"final",
"partial",
"immutable_partial"
]
},
"noInputTimeoutMs": "number",
"recognitionTimeoutMs": "number",
"utteranceEndSilenceMs": "number",
"maxHypotheses": "number",
"speechDomain": "string",
"formatting": "#formatting",
"clientData": "object",
"userId": "string",
"speechDetectionSensitivity": "number",
"resources": ["#resource"]
},
"required": [
]
},
"resource": {
"properties": {
"externalReference": "#resourceReference",
"inlineWordset": "string",
"builtin": "string",
"inlineGrammar": "string",
"wakeupWord": "[string]",
"weightName": {
"type": "string",
"enum": [
"defaultWeight",
"lowest",
"low",
"medium",
"high",
"highest"
]
},
"weightValue": "number",
"reuse": {
"type": "string",
"enum": [
"undefined_reuse",
"low_reuse",
"high_reuse"
]
}
},
"required": [
]
},
"resourceReference": {
"properties": {
"type": {
"type": "string",
"enum": [
"undefined_resource_type",
"wordset",
"compiled_wordset",
"domain_lm",
"speaker_profile",
"grammar",
"settings"
]
},
"uri": "string",
"maxLoadFailures": "boolean",
"requestTimeoutMs": "number",
"headers": "object"
},
"required": [
]
},
"formatting": {
"properties": {
"scheme": "string",
"options": "object"
},
"required": [
"scheme",
"options"
]
},
"lexIntent": {
"properties": {
"name": "string",
"slots": "object"
},
"required": [
"name"
]
},
"vad": {
"properties": {
"enable": "boolean",
"voiceMs": "number",
"mode": "number"
},
"required": [
"enable"
]
},
"amd": {
"properties": {
"actionHook": "object|string",
"thresholdWordCount": "number",
"timers": "#amdTimers",
"recognizer": "#recognizer"
},
"required": [
"actionHook"
]
},
"amdTimers": {
"properties": {
"noSpeechTimeoutMs": "number",
"decisionTimeoutMs": "number",
"toneTimeoutMs": "number",
"greetingCompletionTimeoutMs": "number"
}
}
}

View File

@@ -1,12 +1,28 @@
const ttsGoogle = require('@google-cloud/text-to-speech');
const sttGoogle = require('@google-cloud/speech').v1p1beta1;
const Polly = require('aws-sdk/clients/polly');
const AWS = require('aws-sdk');
const { TranscribeClient, ListVocabulariesCommand } = require('@aws-sdk/client-transcribe');
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 testSonioxStt = async(logger, credentials) => {
const api_key = credentials;
const soniox = new SpeechClient(api_key);
return new Promise(async(resolve, reject) => {
try {
const result = await soniox.transcribeFileShort('data/test_audio.wav');
if (result.words.length > 0) resolve(result);
else reject(new Error('no transcript returned'));
} catch (error) {
logger.info({error}, 'failed to get soniox transcript');
reject(error);
}
});
};
const testNuanceTts = async(logger, getTtsVoices, credentials) => {
const voices = await getTtsVoices({vendor: 'nuance', credentials});
return voices;
@@ -16,10 +32,10 @@ const testNuanceStt = async(logger, credentials) => {
return true;
};
const testGoogleTts = async(logger, getTtsVoices, credentials) => {
const voices = await getTtsVoices({vendor: 'google', credentials});
return voices;
const testGoogleTts = async(logger, credentials) => {
const client = new ttsGoogle.TextToSpeechClient({credentials});
await client.listVoices();
};
const testGoogleStt = async(logger, credentials) => {
@@ -103,25 +119,33 @@ const testMicrosoftStt = async(logger, credentials) => {
});
};
const testAwsTts = (logger, credentials) => {
const polly = new Polly(credentials);
return new Promise((resolve, reject) => {
polly.describeVoices({LanguageCode: 'en-US'}, (err, data) => {
if (err) return reject(err);
resolve();
});
});
const testAwsTts = async(logger, getTtsVoices, credentials) => {
try {
const voices = await getTtsVoices({vendor: 'aws', credentials});
return voices;
} catch (err) {
logger.info({err}, 'testMicrosoftTts - failed to list voices for region ${region}');
throw err;
}
};
const testAwsStt = (logger, credentials) => {
const transcribeservice = new AWS.TranscribeService(credentials);
return new Promise((resolve, reject) => {
transcribeservice.listVocabularies((err, data) => {
if (err) return reject(err);
logger.info({data}, 'retrieved language models');
resolve();
const testAwsStt = async(logger, credentials) => {
try {
const {region, accessKeyId, secretAccessKey} = credentials;
const client = new TranscribeClient({
region,
credentials: {
accessKeyId,
secretAccessKey
}
});
});
const command = new ListVocabulariesCommand({});
const response = await client.send(command);
return response;
} catch (err) {
logger.info({err}, 'testMicrosoftTts - failed to list voices for region ${region}');
throw err;
}
};
const testMicrosoftTts = async(logger, credentials) => {
@@ -181,7 +205,7 @@ const testWellSaidTts = async(logger, credentials) => {
const testIbmTts = async(logger, getTtsVoices, credentials) => {
const {tts_api_key, tts_region} = credentials;
const voices = await getTtsVoices({vendor: 'ibm', credentials: {api_key: tts_api_key, region: tts_region}});
const voices = await getTtsVoices({vendor: 'ibm', credentials: {tts_api_key, tts_region}});
return voices;
};
@@ -226,5 +250,6 @@ module.exports = {
testNuanceStt,
testDeepgramStt,
testIbmTts,
testIbmStt
testIbmStt,
testSonioxStt
};

3622
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": "v0.8.0",
"version": "v0.8.2",
"description": "",
"main": "app.js",
"scripts": {
@@ -18,21 +18,22 @@
"url": "https://github.com/jambonz/jambonz-api-server.git"
},
"dependencies": {
"@aws-sdk/client-transcribe": "^3.290.0",
"@deepgram/sdk": "^1.10.2",
"@google-cloud/speech": "^5.1.0",
"@google-cloud/text-to-speech": "^4.0.3",
"@jambonz/db-helpers": "^0.7.3",
"@jambonz/realtimedb-helpers": "^0.6.0",
"@jambonz/realtimedb-helpers": "^0.7.0",
"@jambonz/speech-utils": "^0.0.8",
"@jambonz/time-series": "^0.2.5",
"argon2-ffi": "^2.0.0",
"aws-sdk": "^2.1152.0",
"@jambonz/verb-specifications": "^0.0.3",
"@soniox/soniox-node": "^1.1.0",
"argon2": "^0.30.3",
"bent": "^7.3.12",
"cors": "^2.8.5",
"debug": "^4.3.4",
"express": "^4.18.1",
"express-rate-limit": "^6.4.0",
"form-data": "^2.5.1",
"form-urlencoded": "^6.1.0",
"helmet": "^5.1.0",
"ibm-watson": "^7.1.2",
"jsonwebtoken": "^9.0.0",

View File

@@ -219,6 +219,21 @@ test('account tests', async(t) => {
});
t.ok(result.statusCode === 201, 'successfully updated a call session limit to an account');
/* try to update an existing limit for an account giving a invalid sid */
try {
result = await request.post(`/Accounts/invalid-sid/Limits`, {
auth: authAdmin,
json: true,
resolveWithFullResponse: true,
body: {
category: 'voice_call_session',
quantity: 205
}
});
} catch (err) {
t.ok(err.statusCode === 400, 'returns 400 bad request if sid param is not a valid uuid');
}
/* query all limits for an account */
result = await request.get(`/Accounts/${sid}/Limits`, {
auth: authAdmin,

View File

@@ -133,4 +133,14 @@ services:
- "3100:3000/tcp"
networks:
jambonz-api:
ipv4_address: 172.58.0.9
ipv4_address: 172.58.0.9
webhook-tts-scaffold:
image: jambonz/webhook-tts-test-scaffold:latest
ports:
- "3101:3000/tcp"
volumes:
- ./test-apps:/tmp
networks:
jambonz-api:
ipv4_address: 172.58.0.10

29
test/email_utils.js Normal file
View File

@@ -0,0 +1,29 @@
const test = require('tape');
const {emailSimpleText} = require('../lib/utils/email-utils');
const bent = require('bent');
const getJSON = bent('json')
const logger = {
debug: () =>{},
info: () => {}
}
test('email-test', async(t) => {
// Prepare env:
process.env.CUSTOM_EMAIL_VENDOR_URL = 'http://127.0.0.1:3101/custom_email_vendor';
process.env.CUSTOM_EMAIL_VENDOR_USERNAME = 'USERNAME';
process.env.CUSTOM_EMAIL_VENDOR_PASSWORD = 'PASSWORD';
await emailSimpleText(logger, 'test@gmail.com', 'subject', 'body text');
const obj = await getJSON(`http://127.0.0.1:3101/lastRequest/custom_email_vendor`);
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>');
t.ok(obj.body.to == 'test@gmail.com');
t.ok(obj.body.subject == 'subject');
t.ok(obj.body.text == 'body text');
process.env.CUSTOM_EMAIL_VENDOR_URL = null;
process.env.CUSTOM_EMAIL_VENDOR_USERNAME = null;
process.env.CUSTOM_EMAIL_VENDOR_PASSWORD = null;
});

View File

@@ -17,4 +17,5 @@ require('./webapp_tests');
// require('./homer');
require('./call-test');
require('./password-settings');
require('./email_utils');
require('./docker_stop');

View File

@@ -107,6 +107,20 @@ test('service provider tests', async(t) => {
});
t.ok(result.statusCode === 204, 'successfully updated service provider');
/* try to update service providers with invalid sid format*/
try {
result = await request.put(`/ServiceProviders/123`, {
auth: authAdmin,
json: true,
resolveWithFullResponse: true,
body: {
name: 'robb'
}
});
} catch (err) {
t.ok(err.statusCode === 400, 'returns 400 bad request if sid param is not a valid uuid');
}
/* add an api key for a service provider */
result = await request.post(`/ApiKeys`, {
auth: authAdmin,

View File

@@ -22,6 +22,24 @@ test('speech credentials tests', async(t) => {
const service_provider_sid = await createServiceProvider(request);
const account_sid = await createAccount(request, service_provider_sid);
/* return 400 if invalid sid param is used */
try {
result = await request.post(`/ServiceProviders/foobarbaz/SpeechCredentials`, {
resolveWithFullResponse: true,
simple: false,
auth: authAdmin,
json: true,
body: {
vendor: 'google',
service_key: jsonKey,
use_for_tts: true,
use_for_stt: true
}
});
} catch (err) {
t.ok(err.statusCode === 400, 'returns 400 bad request if sid param is not a valid uuid');
}
/* add a speech credential to a service provider */
result = await request.post(`/ServiceProviders/${service_provider_sid}/SpeechCredentials`, {
resolveWithFullResponse: true,
@@ -73,8 +91,8 @@ test('speech credentials tests', async(t) => {
t.ok(result.statusCode === 201, 'successfully added speech credential');
const sid1 = result.body.sid;
/* return 403 if invalid account is used */
result = await request.post(`/Accounts/foobarbaz/SpeechCredentials`, {
/* return 403 if invalid account is used - randomSid: bed7ae17-f8b4-4b74-9e5b-4f6318aae9c9 */
result = await request.post(`/Accounts/bed7ae17-f8b4-4b74-9e5b-4f6318aae9c9/SpeechCredentials`, {
resolveWithFullResponse: true,
simple: false,
auth: authUser,
@@ -171,6 +189,35 @@ test('speech credentials tests', async(t) => {
t.ok(result.statusCode === 200 && result.body.stt.status === 'ok', 'successfully tested speech credential for microsoft stt');
}
/* add / test a credential for AWS */
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY && process.env.AWS_REGION) {
result = await request.post(`/Accounts/${account_sid}/SpeechCredentials`, {
resolveWithFullResponse: true,
auth: authUser,
json: true,
body: {
vendor: 'aws',
use_for_tts: true,
use_for_stt: true,
access_key_id: process.env.AWS_ACCESS_KEY_ID,
secret_access_key: process.env.AWS_SECRET_ACCESS_KEY,
aws_region: process.env.AWS_REGION
}
});
t.ok(result.statusCode === 201, 'successfully added speech credential for AWS');
const ms_sid = result.body.sid;
/* test the speech credential */
result = await request.get(`/Accounts/${account_sid}/SpeechCredentials/${ms_sid}/test`, {
resolveWithFullResponse: true,
auth: authUser,
json: true,
});
//console.log(JSON.stringify(result));
t.ok(result.statusCode === 200 && result.body.tts.status === 'ok', 'successfully tested speech credential for AWS tts');
t.ok(result.statusCode === 200 && result.body.stt.status === 'ok', 'successfully tested speech credential for AWS stt');
}
/* add a credential for wellsaid */
if (process.env.WELLSAID_API_KEY) {
result = await request.post(`/Accounts/${account_sid}/SpeechCredentials`, {
@@ -300,9 +347,97 @@ test('speech credentials tests', async(t) => {
t.ok(result.statusCode === 204, 'successfully deleted speech credential');
}
/* add a credential for Siniox */
if (process.env.SONIOX_API_KEY) {
result = await request.post(`/Accounts/${account_sid}/SpeechCredentials`, {
resolveWithFullResponse: true,
auth: authUser,
json: true,
body: {
vendor: 'soniox',
use_for_stt: true,
api_key: process.env.SONIOX_API_KEY
}
});
t.ok(result.statusCode === 201, 'successfully added speech credential for soniox');
const ms_sid = result.body.sid;
/* test the speech credential */
result = await request.get(`/Accounts/${account_sid}/SpeechCredentials/${ms_sid}/test`, {
resolveWithFullResponse: true,
auth: authUser,
json: true,
});
console.log(JSON.stringify(result));
t.ok(result.statusCode === 200 && result.body.stt.status === 'ok', 'successfully tested speech credential for soniox');
/* delete the credential */
result = await request.delete(`/Accounts/${account_sid}/SpeechCredentials/${ms_sid}`, {
auth: authUser,
resolveWithFullResponse: true,
});
t.ok(result.statusCode === 204, 'successfully deleted speech credential');
}
/* add a credential for nvidia */
result = await request.post(`/Accounts/${account_sid}/SpeechCredentials`, {
resolveWithFullResponse: true,
auth: authUser,
json: true,
body: {
vendor: 'nvidia',
use_for_stt: true,
use_for_tts: true,
riva_server_uri: "192.168.1.2:5060"
}
});
t.ok(result.statusCode === 201, 'successfully added speech credential for nvidia');
const ms_sid = result.body.sid;
/* test the speech credential */
result = await request.get(`/Accounts/${account_sid}/SpeechCredentials/${ms_sid}/test`, {
resolveWithFullResponse: true,
auth: authUser,
json: true,
});
// TODO Nvidia test.
t.ok(result.statusCode === 200 && result.body.stt.status === 'not tested', 'successfully tested speech credential for nvida stt');
/* delete the credential */
result = await request.delete(`/Accounts/${account_sid}/SpeechCredentials/${ms_sid}`, {
auth: authUser,
resolveWithFullResponse: true,
});
t.ok(result.statusCode === 204, 'successfully deleted speech credential');
/* add a credential for nuance */
result = await request.post(`/Accounts/${account_sid}/SpeechCredentials`, {
resolveWithFullResponse: true,
auth: authUser,
json: true,
body: {
vendor: 'nuance',
use_for_stt: true,
use_for_tts: true,
client_id: 'client_id',
secret: 'secret',
nuance_tts_uri: "192.168.1.2:5060",
nuance_stt_uri: "192.168.1.2:5061"
}
});
t.ok(result.statusCode === 201, 'successfully added speech credential for nuance');
const nuance_sid = result.body.sid;
/* delete the credential */
result = await request.delete(`/Accounts/${account_sid}/SpeechCredentials/${nuance_sid}`, {
auth: authUser,
resolveWithFullResponse: true,
});
t.ok(result.statusCode === 204, 'successfully deleted speech credential');
await deleteObjectBySid(request, '/Accounts', account_sid);
await deleteObjectBySid(request, '/ServiceProviders', service_provider_sid);
//t.end();
t.end();
}
catch (err) {
console.error(err);

View File

@@ -23,7 +23,7 @@ test('add an admin user', (t) => {
test('user tests', async(t) => {
const app = require('../app');
const password = await generateHashedPassword('abcd1234-');
const password = 'abcde12345-';
try {
let result;