Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@ jspm_packages

# macOS
.DS_Store

# Redis
dump.rdb
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
20
22
2 changes: 1 addition & 1 deletion api/external.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ router.get('/readme', (req, res, next) => {

// `isReadOnly` is used for readme.io's access control
const user = { email, name: email, prefix, isReadOnly: true, ...client }
const link = `https://docs.locus.place/v1.0?auth_token=${signJWT(user, README_LOCUS_SECRET)}`
const link = `https://docs.locus.place/v1.0?auth_token=${signJWT(user, { secret: README_LOCUS_SECRET })}`
return res.status(200).json({ message: 'Login link generated', link })
}).catch(next)
})
Expand Down
12 changes: 6 additions & 6 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,19 @@ const serverless = require('serverless-http')
const express = require('express')
const cors = require('cors')

const { sentry, errorHandler } = require('./modules/errors')
const { initSentry, setupSentryErrorHandler, errorHandler } = require('./modules/errors')
const { rKillIdleOnExit, wKillIdleOnExit } = require('./modules/db')

const api = require('./api')

// initialize Sentry before express app
initSentry()

// express app
const app = express()
// trust proxy to get API Gateway/Cloud Front forwarded headers
app.enable('trust proxy')

// allow Sentry to access the request
app.use(sentry().requestHandler)

// enable CORS for endpoints and their pre-flight requests (when applicable)
app.use(cors())
app.options('*', cors())
Expand All @@ -27,8 +27,8 @@ app.use(rKillIdleOnExit, wKillIdleOnExit)
// mount API endpoints by stage
app.use(`/${process.env.STAGE || 'dev'}`, api)

// log all errors to Sentry
app.use(sentry().errorHandler)
// Sentry error handler (must be before custom error handler)
setupSentryErrorHandler(app)

// catch-all error handler
app.use(errorHandler)
Expand Down
10 changes: 9 additions & 1 deletion modules/auth-otp.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@ const crypto = require('crypto')
const Redis = require('ioredis')
const { AuthorizationError } = require('./errors')

const redisClient = new Redis(process.env.REDIS_URI)
const redisClient = new Redis(process.env.REDIS_URI, {
maxRetriesPerRequest: 3,
retryStrategy(times) {
return Math.min(times * 200, 2000)
},
})
redisClient.on('error', (err) => {
console.error('[ioredis] connection error:', err.message)
})

// gets key or sets value if does not exist + sets/resets ttl (when ttl args provided)
redisClient.defineCommand('getOrSet', {
Expand Down
27 changes: 15 additions & 12 deletions modules/auth.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
/**
* User auth workflow
*/
const url = require('url')

const uuidv4 = require('uuid/v4')
const { v4: uuidv4 } = require('uuid')
const jwt = require('jsonwebtoken')
const moment = require('moment-timezone')
const isEqual = require('lodash.isequal')
Expand All @@ -20,9 +19,16 @@ const {
OTP_TTL = 5 * 60 * 1000, // in milliseconds
JWT_SECRET,
JWT_TTL = 90 * 24 * 60 * 60, // in seconds
APP_REVIEWER_OTP = '*'.charCodeAt(0).toString(2),
APP_REVIEWER_OTP,
} = process.env

if (!JWT_SECRET) {
throw new Error('JWT_SECRET environment variable is required')
}
if (!APP_REVIEWER_OTP) {
throw new Error('APP_REVIEWER_OTP environment variable is required')
}

const isPrivilegedUser = (email, prefix, api_access) => {
// returns true if this user is high-privilege
// A user is high privilege if
Expand Down Expand Up @@ -167,15 +173,12 @@ const loginUser = async ({ user, redirect, zone='utc', product = PRODUCT_ATOM, n
// localize TTL
ttl = moment.tz(ttl, zone).format('LLLL z')

// parse given redirect
let link = url.parse(redirect, true)
// inject query string params
link.query = link.query || {}
Object.assign(link.query, { user, otp, product })
// hack to enable link.query over ?search
link.search = undefined
// reconstruct into the effective magic link
link = url.format(link)
// build magic link from redirect URL
const linkUrl = new URL(redirect)
linkUrl.searchParams.set('user', user)
linkUrl.searchParams.set('otp', otp)
linkUrl.searchParams.set('product', product)
let link = linkUrl.toString()

// populate email
const message = nolink ? {
Expand Down
6 changes: 3 additions & 3 deletions modules/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
const isEmpty = require('lodash.isempty')

const DBPool = require('./db-pool')
const { APIError, sentry } = require('./errors')
const { APIError, logError } = require('./errors')

const rPool = new DBPool({ max: 1, host: process.env.PGHOST_READ, errorLogger: sentry().logError })
const wPool = new DBPool({ max: 1, errorLogger: sentry().logError })
const rPool = new DBPool({ max: 1, host: process.env.PGHOST_READ, errorLogger: logError })
const wPool = new DBPool({ max: 1, errorLogger: logError })

const _checkEmpty = ({ ...params }) => {
for (const [ k, v ] of Object.entries(params)) {
Expand Down
7 changes: 3 additions & 4 deletions modules/email.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const nodemailer = require('nodemailer')
const AWS = require('@aws-sdk/client-ses')
const { SES } = require('@aws-sdk/client-ses')
const { capitalizeFirstLetter } = require('./utils')


Expand All @@ -21,9 +21,8 @@ module.exports.sendMail = async message => {
})
}
else {
transport = nodemailer.createTransport({
SES: new AWS.SES(),
})
const ses = new SES()
transport = nodemailer.createTransport({ SES: { ses, aws: { SendRawEmailCommand: require('@aws-sdk/client-ses').SendRawEmailCommand } } })
}
return transport.sendMail(message)
}
Expand Down
63 changes: 22 additions & 41 deletions modules/errors.js
Original file line number Diff line number Diff line change
@@ -1,50 +1,29 @@
const _sentry = require('@sentry/node')
const Sentry = require('@sentry/node')
const { KEYWARDEN_VER, SENTRY_URL, STAGE = 'dev' } = process.env

const LOG_LEVEL_WARNING = 'WARNING'
const LOG_LEVEL_ERROR = 'ERROR'

// IIFE to encapsulate sentry in namespace
const sentry = (() => {
let sentryObj

const initSentry = () => {
_sentry.init({
debug: STAGE === 'local',
dsn: SENTRY_URL,
release: KEYWARDEN_VER,
environment: STAGE,
})

// middleware to start monitoring request
const requestHandler = _sentry.Handlers.requestHandler({
request: ['headers', 'method', 'query_string', 'url'],
serverName: false,
user: ['email'],
})

// middleware to push error to Sentry
const errorHandler = _sentry.Handlers.errorHandler({
// only log to Sentry unknown errors or errors we have categorized as 'ERROR'
shouldHandleError: (err) => err.logLevel === undefined || err.logLevel === LOG_LEVEL_ERROR,
})

// log errors outside error handler
const logError = (err) => _sentry.captureException(err)

return { client: _sentry, requestHandler, errorHandler, logError }

}
let sentryInitialized = false

const initSentry = () => {
if (sentryInitialized) return
sentryInitialized = true
Sentry.init({
debug: STAGE === 'local',
dsn: SENTRY_URL,
release: KEYWARDEN_VER,
environment: STAGE,
})
}

// returns sentryObj or call init if sentryObj has not been set
return () => {
if (sentryObj === undefined) {
sentryObj = initSentry()
}
return sentryObj
}
const setupSentryErrorHandler = (app) => {
Sentry.setupExpressErrorHandler(app, {
shouldHandleError: (err) => err.logLevel === undefined || err.logLevel === LOG_LEVEL_ERROR,
})
}

})()
const logError = (err) => Sentry.captureException(err)

class APIError extends Error {
/**
Expand Down Expand Up @@ -194,7 +173,9 @@ module.exports = {
AuthorizationError,
InternalServerError,
CustomError,
sentry,
initSentry,
setupSentryErrorHandler,
logError,
errorHandler,
LOG_LEVEL_ERROR,
LOG_LEVEL_WARNING,
Expand Down
14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,26 +15,26 @@
"license": "UNLICENSED",
"dependencies": {
"@aws-sdk/client-ses": "^3.58.0",
"@sentry/node": "5.15.4",
"@sentry/node": "^8.0.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.4",
"express": "^4.16.3",
"ioredis": "^4.17.3",
"jsonwebtoken": "^8.3.0",
"ioredis": "^5.3.0",
"jsonwebtoken": "^9.0.0",
"lodash.isempty": "^4.4.0",
"lodash.isequal": "^4.5.0",
"moment-timezone": "^0.5.21",
"nodemailer": "^4.6.8",
"nodemailer": "^6.9.0",
"pg": "^8.0.2",
"serverless-http": "^1.6.0",
"uuid": "^3.3.2"
"serverless-http": "^3.2.0",
"uuid": "^9.0.0"
},
"devDependencies": {
"eslint": "^8.10.0",
"nodemon": "^1.18.4",
"serverless-domain-manager": "^3.3.1"
},
"engines": {
"node": "20.x"
"node": "22.x"
}
}
19 changes: 9 additions & 10 deletions serverless.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ custom:

provider:
name: aws
runtime: nodejs20.x
runtime: nodejs22.x
timeout: 29
memorySize: 256
versionFunctions: false
Expand All @@ -32,6 +32,7 @@ provider:
OTP_TTL: ${env:OTP_TTL}
KEYWARDEN_VER: ${env:KEYWARDEN_VER}
STAGE: ${opt:stage, self:provider.stage}
APP_REVIEWER_OTP: ${env:APP_REVIEWER_OTP}
CLEARLAKE_SENDER: ${env:CLEARLAKE_SENDER}
CLEARLAKE_SUPPORT_EMAIL: ${env:CLEARLAKE_SUPPORT_EMAIL}
REDIS_URI:
Expand All @@ -49,18 +50,18 @@ provider:
]
]
]
vpc: # vpc-70658509 | EQ-DC-Tunnel
vpc:
securityGroupIds:
- sg-081b437d # api-gateway-dc
- ${env:VPC_SG_API_GATEWAY}
subnetIds:
- subnet-b59ae9fe # EQ-DC-Lambda Public 1A
- subnet-df12bb82 # EQ-DC-Lambda Public 1B
- ${env:VPC_SUBNET_1}
- ${env:VPC_SUBNET_2}
iamRoleStatements:
- Effect: Allow
Action:
- ses:SendEmail
- ses:SendRawEmail
Resource: arn:aws:ses:us-east-1:175398475102:*
Resource: arn:aws:ses:${env:AWS_REGION}:${env:AWS_ACCOUNT_ID}:*

functions:
app:
Expand All @@ -83,7 +84,6 @@ functions:

resources:
Resources:
# redis resource configured through cloudformation, without explicit manual work
KeywardenRedisCluster:
Type: AWS::ElastiCache::CacheCluster
Properties:
Expand All @@ -92,7 +92,6 @@ resources:
CacheNodeType: cache.t2.micro
Engine: redis
NumCacheNodes: 1
# this equates to the provider.vpc.subnetIds
CacheSubnetGroupName: redis-public-lambda
CacheSubnetGroupName: ${env:REDIS_SUBNET_GROUP}
VpcSecurityGroupIds:
- sg-52345126
- ${env:REDIS_SG}
Loading
Loading