Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix build #197

Closed
wants to merge 16 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func Instance() *redis.Client {
func InitCacheConnection() *redis.Client {
// init client
cacheConnection = redis.NewClient(&redis.Options{
Network: "unix",
Network: configuration.Config.RedisNetworkType,
Addr: configuration.Config.RedisAddress,
Password: "",
DB: 0, // cache database
Expand Down
1 change: 1 addition & 0 deletions api/configuration/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ type Configuration struct {
Password string `json:"password"`
} `json:"freepbx_database"`
ListenAddress string `json:"listen_address"`
RedisNetworkType string `json:"redis_network_type"`
RedisAddress string `json:"redis_address"`
TTLCache int `json:"ttl_cache"`
Secret string `json:"secret"`
Expand Down
2 changes: 0 additions & 2 deletions api/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ go 1.14

require (
github.com/appleboy/gin-jwt/v2 v2.6.4
github.com/bgentry/speakeasy v0.1.0 // indirect
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect
github.com/gin-contrib/cors v1.3.1
github.com/gin-contrib/gzip v0.0.3
Expand All @@ -14,7 +13,6 @@ require (
github.com/google/uuid v1.1.2
github.com/jinzhu/gorm v1.9.16
github.com/juliangruber/go-intersect v1.0.0
github.com/msteinert/pam v0.0.0-20200810204841-913b8f8cdf8b
github.com/nleeper/goment v1.4.0
github.com/onsi/gomega v1.14.0 // indirect
github.com/pkg/errors v0.9.1
Expand Down
4 changes: 0 additions & 4 deletions api/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ github.com/appleboy/gin-jwt/v2 v2.6.4 h1:4YlMh3AjCFnuIRiL27b7TXns7nLx8tU/TiSgh40
github.com/appleboy/gin-jwt/v2 v2.6.4/go.mod h1:CZpq1cRw+kqi0+yD2CwVw7VGXrrx4AqBdeZnwxVmoAs=
github.com/appleboy/gofight/v2 v2.1.2 h1:VOy3jow4vIK8BRQJoC/I9muxyYlJ2yb9ht2hZoS3rf4=
github.com/appleboy/gofight/v2 v2.1.2/go.mod h1:frW+U1QZEdDgixycTj4CygQ48yLTUhplt43+Wczp3rw=
github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand Down Expand Up @@ -94,8 +92,6 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/msteinert/pam v0.0.0-20200810204841-913b8f8cdf8b h1:UZ7RWBA77dedMow4Zkek/gfJ/DRbti7C+Ny/Pf9D3gM=
github.com/msteinert/pam v0.0.0-20200810204841-913b8f8cdf8b/go.mod h1:np1wUFZ6tyoke22qDJZY40URn9Ae51gX7ljIWXN5TJs=
github.com/nleeper/goment v1.4.0 h1:k+PnV26S9wnxI+ryxKEkpG7JhVtDJpM6+CzeRxh5S54=
github.com/nleeper/goment v1.4.0/go.mod h1:zDl5bAyDhqxwQKAvkSXMRLOdCowrdZz53ofRJc4VhTo=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
Expand Down
8 changes: 1 addition & 7 deletions api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ package main

import (
"flag"
"io/ioutil"
"net/http"
"os"

Expand All @@ -43,11 +42,6 @@ func main() {
flag.Parse()
configuration.Init(ConfigFilePtr)

// disable log to stdout when running in release mode
if gin.Mode() == gin.ReleaseMode {
gin.DefaultWriter = ioutil.Discard
}

// init routers
router := gin.Default()

Expand All @@ -63,7 +57,7 @@ func main() {
}

// define API
api := router.Group("/api")
api := router

// define login endpoint
api.POST("/login", middleware.InstanceJWT().LoginHandler)
Expand Down
46 changes: 11 additions & 35 deletions api/methods/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,50 +25,26 @@ package methods
import (
"encoding/json"
"io/ioutil"
"os"
"net/http"
"os"
"os/exec"
"time"

"github.com/pkg/errors"

jwt "github.com/appleboy/gin-jwt/v2"
"github.com/gin-gonic/gin"

"github.com/msteinert/pam"
"github.com/nethesis/nethvoice-report/api/configuration"
"github.com/nethesis/nethvoice-report/api/models"
"github.com/nethesis/nethvoice-report/api/source"
"github.com/nethesis/nethvoice-report/api/utils"
)

func PamAuth(username string, password string) error {
// init PAM authentication
t, errInit := pam.StartFunc("system-auth", username, func(s pam.Style, msg string) (string, error) {
switch s {
case pam.PromptEchoOff:
return password, nil
case pam.PromptEchoOn:
return username, nil
default:
return "", errors.New("error during PAM authentication")
}
})

// check error
if errInit != nil {
return errInit
}

// check authentication
errAuth := t.Authenticate(0)
if errAuth != nil {
return errAuth
}
errAuth = t.AcctMgmt(0)
if errAuth != nil {
return errAuth
}
return nil
// execute pam login
cmd := exec.Command("ldap-authenticate", username, password)
return cmd.Run()
}

func ParseUserAuthorizationsFile() ([]models.UserAuthorizations, error) {
Expand Down Expand Up @@ -100,8 +76,8 @@ func GetUserAuthorizations(username string) (models.UserAuthorizations, error) {
userAuthorizations.Queues = ua.Queues
userAuthorizations.Groups = ua.Groups
userAuthorizations.Agents = ua.Agents
userAuthorizations.Users = ua.Users
userAuthorizations.Cdr = ua.Cdr
userAuthorizations.Users = ua.Users
userAuthorizations.Cdr = ua.Cdr
return userAuthorizations, nil
}
}
Expand Down Expand Up @@ -142,7 +118,7 @@ func ParseAuthFileStats() (models.AuthStats, error) {
}
authStats := models.AuthStats{
FileName: fileInfo.Name(),
ModTime: fileInfo.ModTime().Unix(),
ModTime: fileInfo.ModTime().Unix(),
}
// return authorization file stats
return authStats, nil
Expand Down Expand Up @@ -206,7 +182,7 @@ func ParseAuthMap(c *gin.Context, username string) (models.AuthMap, error) {
func GetAuthMap(c *gin.Context) {
// parse authorizations map
authMap, err := ParseAuthMap(c, "")
if err != nil {
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"message": "error parsing user authorizations file"})
return
}
Expand All @@ -215,11 +191,11 @@ func GetAuthMap(c *gin.Context) {
return
}

func CacheHasValidAuth(ttl time.Duration, modTime int64) (bool) {
func CacheHasValidAuth(ttl time.Duration, modTime int64) bool {
// check if cached data has valid authorizations
now := time.Now().Unix()
ttlTime := int64(ttl.Seconds())
ttlCache := int64((time.Duration(configuration.Config.TTLCache)*time.Minute).Seconds())
ttlCache := int64((time.Duration(configuration.Config.TTLCache) * time.Minute).Seconds())
// return true when time passed from the data insert in chache
// is lower than time passed from the authorizations modify time
if (ttlCache - ttlTime) < (now - modTime) {
Expand Down
8 changes: 7 additions & 1 deletion api/methods/queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,12 @@ func executeSqlQuery(filter models.Filter, report string, section string, view s
return "", errors.Wrap(errTpl, "invalid query template compiling")
}

// print executed sql query
// ...it can be useful for debug
if gin.Mode() == gin.DebugMode {
utils.LogInfo(queryFile + " | " + utils.InlineQuery(queryString.String()))
}

// execute query
db := source.CDRInstance()
results, errQuery := db.Query(queryString.String())
Expand Down Expand Up @@ -526,7 +532,7 @@ func buildCdrQuery(queryFile string, filter models.Filter) (string, error) {
}

// append limit to query

queryBuilder.WriteString(" LIMIT " + queryLimit)

return queryBuilder.String(), nil
Expand Down
16 changes: 16 additions & 0 deletions api/utils/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,22 @@ func LogError(err error) {
os.Stderr.WriteString(err.Error() + "\n")
}

func LogInfo(message string) {
currentTime := time.Now().Format("2006/01/02 - 15:04:05")
_, _ = os.Stderr.WriteString("[INFO] " + currentTime + " | " + message + "\n")
}

func InlineQuery(query string) string {
// Remove SQL comments (lines starting with --)
commentPattern := regexp.MustCompile(`--.*$`)
query = commentPattern.ReplaceAllString(query, "")

// Remove line breaks and extra spaces
query = strings.ReplaceAll(query, "\n", " ")
query = strings.Join(strings.Fields(query), " ")
return query
}

func Contains(a string, values []string) bool {
for _, b := range values {
if b == a {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
$OUT = '';
}\{
"listen_address": "0.0.0.0:8585",
"redis_network_type": "unix",
"redis_address": "/var/run/redis-nethvoice-report/nethvoice-report.sock",
"ttl_cache": 480,
"cdr_database": \{
Expand Down

This file was deleted.

This file was deleted.

This file was deleted.

1 change: 0 additions & 1 deletion root/opt/nethvoice-report/api/user_authorizations.json

This file was deleted.

13 changes: 6 additions & 7 deletions root/opt/nethvoice-report/scripts/queue-miner.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/usr/bin/php
#!/usr/bin/env php
<?php
/*
* This scripts consolidates queue statistics
Expand Down Expand Up @@ -34,7 +34,7 @@
$conf = $conf['cdr_database'];

try {
$cdrdb = new PDO("mysql:dbname={$conf['name']};host={$conf['host']}", $conf['user'], $conf['password']);
$cdrdb = new PDO("mysql:dbname={$conf['name']};host={$conf['host']};port={$conf['port']}", $conf['user'], $conf['password']);
} catch (PDOException $e) {
fputs(STDERR, 'Connection failed: ' . $e->getMessage());
exit(1);
Expand Down Expand Up @@ -153,7 +153,7 @@ function do_time_queries($start_ts,$end_ts) {
$sqls[] = "INSERT IGNORE INTO report_queue ( id,timestamp_out,timestamp_in,qname,action,position,duration,hold,data4,agent,qdescr,agents)
select id,UNIX_TIMESTAMP(time) as timestamp_out, callid as timestamp_in, queuename as qname,
event as action,
cast(data1 as UNSIGNED) as position,
if (event in ('ABANDON','EXITWITHTIMEOUT','EXITEMPTY'), cast(data1 as UNSIGNED), '') as position,
cast(data2 as UNSIGNED) as duration,
cast(data3 as UNSIGNED) as hold,
cast(data4 as UNSIGNED) as data4,
Expand All @@ -180,7 +180,7 @@ function do_time_queries($start_ts,$end_ts) {
cast(data1 as UNSIGNED) as hold,
cast(data4 as UNSIGNED) as data4,
agent,qc.descr as qdescr,
(SELECT GROUP_CONCAT(DISTINCT name SEPARATOR ',') from tmp_cdr LEFT JOIN agent_extensions on SUBSTRING(REPLACE(dstchannel,'PJSIP/',''),1,POSITION('-' IN REPLACE(dstchannel,'PJSIP/',''))-1) = agent_extensions.extension where linkedid != uniqueid and billsec > 0 and dstchannel not like 'Local%' AND UNIX_TIMESTAMP(calldate) > $start_ts AND UNIX_TIMESTAMP(calldate) < $end_ts AND linkedid = callid) as agents
(SELECT GROUP_CONCAT(DISTINCT name SEPARATOR ',') from tmp_cdr LEFT JOIN agent_extensions on SUBSTRING(REPLACE(dstchannel,'PJSIP/',''),1,POSITION('-' IN REPLACE(dstchannel,'PJSIP/',''))-1) = agent_extensions.extension where linkedid != uniqueid and disposition ='ANSWERED' and dstchannel not like 'Local%' AND UNIX_TIMESTAMP(calldate) > $start_ts AND UNIX_TIMESTAMP(calldate) < $end_ts AND linkedid = callid) as agents
from queue_log_history a inner join asterisk.queues_config qc on queuename=qc.extension
where event in ('COMPLETEAGENT','COMPLETECALLER') AND UNIX_TIMESTAMP(time) > $start_ts AND UNIX_TIMESTAMP(time) < $end_ts";

Expand All @@ -206,9 +206,9 @@ function do_time_queries($start_ts,$end_ts) {
INNER JOIN asterisk.queues_config qc ON queuename=qc.extension
INNER JOIN (
SELECT dst_cnam,billsec,dstchannel, zid FROM (
SELECT dst_cnam,billsec,dstchannel, linkedid AS zid FROM tmp_cdr WHERE linkedid != uniqueid AND billsec > 0 AND dstchannel NOT LIKE 'Local%' AND UNIX_TIMESTAMP(calldate) > $start_ts AND UNIX_TIMESTAMP(calldate) < $end_ts
SELECT dst_cnam,billsec,dstchannel, linkedid AS zid FROM tmp_cdr WHERE linkedid != uniqueid AND disposition ='ANSWERED' AND dstchannel NOT LIKE 'Local%' AND UNIX_TIMESTAMP(calldate) > $start_ts AND UNIX_TIMESTAMP(calldate) < $end_ts
UNION ALL
SELECT dst_cnam,billsec,dstchannel, uniqueid AS zid FROM tmp_cdr WHERE linkedid != uniqueid AND billsec > 0 AND dstchannel NOT LIKE 'Local%' AND UNIX_TIMESTAMP(calldate) > $start_ts AND UNIX_TIMESTAMP(calldate) < $end_ts
SELECT dst_cnam,billsec,dstchannel, uniqueid AS zid FROM tmp_cdr WHERE linkedid != uniqueid AND disposition ='ANSWERED' AND dstchannel NOT LIKE 'Local%' AND UNIX_TIMESTAMP(calldate) > $start_ts AND UNIX_TIMESTAMP(calldate) < $end_ts
) temp GROUP BY zid
) c ON zid = callid
LEFT JOIN agent_extensions ON SUBSTRING(REPLACE(dstchannel,'PJSIP/',''),1,POSITION('-' IN REPLACE(dstchannel,'PJSIP/',''))-1) = agent_extensions.extension
Expand Down Expand Up @@ -428,4 +428,3 @@ function do_static_queries() {
}

}

140 changes: 0 additions & 140 deletions root/opt/nethvoice-report/scripts/schema.sql

This file was deleted.

15 changes: 15 additions & 0 deletions root/opt/nethvoice-report/scripts/schema.sql.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
USE `@database_name`;

START TRANSACTION;

DROP FUNCTION IF EXISTS get_trunk_name;
CREATE FUNCTION get_trunk_name (s VARCHAR(255))
RETURNS VARCHAR(255) CHARACTER SET 'utf8' COLLATE 'utf8_bin' DETERMINISTIC
RETURN Substring_index(SUBSTRING(s,1,LENGTH(s)-LOCATE('-',REVERSE(s))), '/', -1);

DROP FUNCTION IF EXISTS clean_prefix;
CREATE FUNCTION clean_prefix (s VARCHAR(255))
RETURNS VARCHAR(255) CHARACTER SET 'utf8' COLLATE 'utf8_bin' DETERMINISTIC
RETURN REPLACE(Substring_index(s,"@international_prefix",-1),"+","00");

COMMIT;
2 changes: 2 additions & 0 deletions tasks/cmd/queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
package cmd

import (
"crypto/tls"
"encoding/json"
"fmt"
"io/ioutil"
Expand Down Expand Up @@ -241,6 +242,7 @@ func login() (string, error) {
loginURL := configuration.Config.APIEndpoint + "/login"

// login request
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
resp, err := http.PostForm(loginURL, url.Values{"username": {username}, "password": {password}})
if err != nil {
return "", errors.Wrap(err, "Login error")
Expand Down
7 changes: 3 additions & 4 deletions tasks/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,12 @@ module github.com/nethesis/nethvoice-report/tasks
go 1.14

require (
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect
github.com/fatih/color v1.9.0
github.com/gin-gonic/gin v1.7.7 // indirect
github.com/nethesis/nethvoice-report/api v0.0.0-20210419090104-4330b1e5fe02
github.com/onsi/gomega v1.14.0 // indirect
github.com/nethesis/nethvoice-report/api v0.0.0
github.com/pkg/errors v0.9.1
github.com/spf13/cobra v1.0.0
github.com/spf13/viper v1.7.1
github.com/thoas/go-funk v0.7.0
)

replace github.com/nethesis/nethvoice-report/api => ../api
5 changes: 0 additions & 5 deletions tasks/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmV
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
Expand Down Expand Up @@ -215,11 +214,7 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/msteinert/pam v0.0.0-20200810204841-913b8f8cdf8b h1:UZ7RWBA77dedMow4Zkek/gfJ/DRbti7C+Ny/Pf9D3gM=
github.com/msteinert/pam v0.0.0-20200810204841-913b8f8cdf8b/go.mod h1:np1wUFZ6tyoke22qDJZY40URn9Ae51gX7ljIWXN5TJs=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nethesis/nethvoice-report/api v0.0.0-20210419090104-4330b1e5fe02 h1:0iqb7I3ZMizTTUV2pbisn29Ob3hv3GGinuTAkQMD4A4=
github.com/nethesis/nethvoice-report/api v0.0.0-20210419090104-4330b1e5fe02/go.mod h1:EVlrgK6wN3zaCi8E0e8A9YwMlTKj+2u8lbFF811x5K8=
github.com/nleeper/goment v1.4.0 h1:k+PnV26S9wnxI+ryxKEkpG7JhVtDJpM6+CzeRxh5S54=
github.com/nleeper/goment v1.4.0/go.mod h1:zDl5bAyDhqxwQKAvkSXMRLOdCowrdZz53ofRJc4VhTo=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
Expand Down
14 changes: 5 additions & 9 deletions ui/Containerfile
Original file line number Diff line number Diff line change
@@ -1,20 +1,16 @@
FROM node:10 as base
FROM docker.io/library/node:14.21.2-alpine as base
WORKDIR /app

FROM base as node_modules
# Install node_modules
COPY package.json .
COPY package-lock.json .
RUN npm i

FROM base as build
RUN npm ci
# Copy application files
COPY public public
COPY src src
COPY .browserslistrc .
COPY .eslintrc.js .
COPY babel.config.js .
COPY vue.config.js .
COPY package.json .
COPY package-lock.json .
COPY --from=node_modules /app/node_modules node_modules
# Build dist
ENV NODE_ENV=production
RUN npm run build
4 changes: 4 additions & 0 deletions ui/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# NethVoice Report UI

## Node Version

Due to compatibility issues with `node-sass` and node > `14`, please use only node `14.x` and npm `6.14.x`. You can use [nvm](https://github.com/nvm-sh/nvm) to get a node `14` up and running.

## Project setup
```
npm install
Expand Down
Loading
Loading