This commit is contained in:
pfych 2024-10-12 14:08:09 +11:00
commit 0029086b3f
148 changed files with 19047 additions and 0 deletions

View file

@ -0,0 +1,3 @@
module.exports = {
extends: ['../../.eslintrc.js'],
};

55
packages/api/package.json Normal file
View file

@ -0,0 +1,55 @@
{
"name": "@baseline/api",
"version": "1.0.0",
"engines": {
"node": "20"
},
"scripts": {
"start:exposed": "pnpm run start --host 0.0.0.0",
"start": "./scripts/run-api-local.sh",
"debug": "export SLS_DEBUG=* && ./scripts/run-api-local-debug.sh",
"deploy:staging": "./scripts/deploy.sh staging",
"remove:staging": ". ../../scripts/project-variables.sh && npx serverless remove --stage staging --region $REGION",
"deploy:prod": "./scripts/deploy.sh prod",
"remove:prod": ". ../../scripts/project-variables.sh && npx serverless remove --stage prod --region $REGION",
"add:env:local": "../../scripts/add-env-var.sh local",
"add:env:staging": "../../scripts/add-env-var.sh staging",
"add:env:prod": "../../scripts/add-env-var.sh prod",
"aws:profile": "../../scripts/setup-aws-profile.sh",
"add:user:staging": "./scripts/add-cognito-user.sh staging",
"add:user:prod": "./scripts/add-cognito-user.sh prod",
"install:dynamodb": ". ../../scripts/project-variables.sh && npx serverless dynamodb install --stage staging --region $REGION",
"lint": "npx eslint --config '.eslintrc.js' 'src/**/*.{ts,tsx,js}'",
"build": ". ../../scripts/project-variables.sh && npx serverless package --stage staging --region $REGION",
"pretty": "npx prettier --write 'src/**/*.{ts,tsx,js,json,css,scss,md,yml,yaml,html}' && npx prettier --write '*.{yml,yaml,js}'"
},
"dependencies": {
"@aws-sdk/client-cognito-identity-provider": "3.530.0",
"@baseline/types": "workspace:1.0.0",
"@baselinejs/dynamodb": "0.2.4",
"@types/lodash-es": "^4.17.12",
"aws-lambda": "1.0.7",
"compression": "1.7.4",
"cors": "2.8.5",
"express": "4.21.0",
"http-status-codes": "2.2.0",
"lodash-es": "^4.17.21",
"serverless-http": "3.0.1"
},
"devDependencies": {
"@types/aws-lambda": "8.10.93",
"@types/compression": "1.7.2",
"@types/cors": "2.8.12",
"@types/express": "4.17.21",
"@types/node": "20.11.26",
"esbuild": "0.20.1",
"prettier": "2.4.1",
"serverless": "3.38.0",
"serverless-dynamodb": "0.2.50",
"serverless-esbuild": "1.52.1",
"serverless-offline": "13.3.3",
"stylelint": "16.2.1",
"stylelint-order": "6.0.4",
"typescript": "5.4.2"
}
}

View file

@ -0,0 +1,115 @@
#!/usr/bin/env bash
shopt -s failglob
CURRENT_DIR="$(pwd -P)"
PARENT_PATH="$(
cd "$(dirname "${BASH_SOURCE[0]}")" || exit
pwd -P
)/.."
cd "$PARENT_PATH" || exit
STAGE=$1
USER_EMAIL=$2
USER_PASSWORD=$3
# Sets REGION, APP_NAME, AWS_REGION, AWS_PROFILE
. ../../scripts/project-variables.sh
TABLE="${APP_NAME}-${STAGE}-admin"
echo "Getting Cognito User Pool Id from [$STAGE]..."
. ../../scripts/get-stack-outputs.sh "$STAGE" >/dev/null
COGNITO_USER_POOL_ID="${UserPoolId:-}"
if [ "$COGNITO_USER_POOL_ID" == "" ]; then
echo "Failed to get Cognito User Pool Id!"
echo 'Check your aws credentials are up to date, maybe run "npm run aws:profile"'
exit 1
else
echo "Cognito Pool Id [$COGNITO_USER_POOL_ID]"
fi
if [ -z "$USER_EMAIL" ]; then
printf "Email: "
read -r USER_EMAIL
fi
if [ "$USER_EMAIL" == "" ]; then
echo "Error: No user email set"
exit 1
fi
if [ -z "$USER_PASSWORD" ]; then
echo
echo "Password Requirements:"
echo "- 8 character minimum length"
echo "- Contains at least 1 number"
echo "- Contains at least 1 lowercase letter"
echo "- Contains at least 1 uppercase letter"
echo "- Contains at least 1 special character"
printf "Password: "
read -sr USER_PASSWORD
echo ""
fi
if [ "$USER_PASSWORD" == "" ]; then
echo "Error: No user password set"
exit 1
fi
EXISTING_USER=$(aws cognito-idp admin-get-user \
--profile "${AWS_PROFILE}" \
--region "${REGION}" \
--user-pool-id "${COGNITO_USER_POOL_ID:-}" \
--username "${USER_EMAIL}")
if [ "$EXISTING_USER" ]; then
echo "User already exists, will not modify password"
echo "Will attempt to add to DynamoDB"
else
echo "Creating User..."
aws cognito-idp admin-create-user \
--profile "${AWS_PROFILE}" \
--region "${REGION}" \
--user-pool-id "${COGNITO_USER_POOL_ID:-}" \
--username "${USER_EMAIL:-}" \
--user-attributes Name=email,Value="${USER_EMAIL:-}" Name=email_verified,Value=true \
--message-action SUPPRESS >/dev/null
echo "Setting Password..."
aws cognito-idp admin-set-user-password \
--profile "${AWS_PROFILE}" \
--region "${REGION}" \
--user-pool-id "${COGNITO_USER_POOL_ID:-}" \
--username "${USER_EMAIL:-}" \
--password "${USER_PASSWORD:-}" \
--permanent >/dev/null
fi
USER_SUB=$(aws cognito-idp admin-get-user \
--profile "${AWS_PROFILE}" \
--region "${REGION}" \
--user-pool-id "${COGNITO_USER_POOL_ID:-}" \
--username "${USER_EMAIL}" |
jq '.["Username"]' |
tr -d '"')
echo "User Sub: [${USER_SUB}]"
if [ "$USER_SUB" ]; then
echo "Found user sub, attempting to create DynamoDB record"
aws dynamodb put-item \
--table-name "${TABLE}" \
--item \
"{\"userSub\": {\"S\": \"${USER_SUB}\"}, \"userEmail\": {\"S\": \"${USER_EMAIL}\"}}" \
--profile "${AWS_PROFILE}" \
--region "${REGION}"
else
echo "User sub not found, cannot create DynamoDB record"
fi
echo "Done!"
cd "$CURRENT_DIR" || exit

25
packages/api/scripts/deploy.sh Executable file
View file

@ -0,0 +1,25 @@
#!/usr/bin/env bash
CURRENT_DIR="$(pwd -P)"
PARENT_PATH="$(
cd "$(dirname "${BASH_SOURCE[0]}")" || exit
pwd -P
)/.."
cd "$PARENT_PATH" || exit
STAGE=$1
. ../../scripts/project-variables.sh
. ../../scripts/get-stack-outputs.sh "$STAGE" >/dev/null
npx serverless deploy --verbose --stage "$STAGE" --region "$REGION"
# check if npx serverless deploy was successful
if [ $? -eq 0 ]; then
echo "Deploy successful"
else
echo "Deploy failed"
cd "$CURRENT_DIR" || exit
exit 1
fi
cd "$CURRENT_DIR" || exit

View file

@ -0,0 +1,38 @@
#!/usr/bin/env bash
CURRENT_DIR="$(pwd -P)"
PARENT_PATH="$(
cd "$(dirname "${BASH_SOURCE[0]}")" || exit
pwd -P
)/.."
cd "$PARENT_PATH" || exit
# Sets REGION, APP_NAME, AWS_REGION, AWS_PROFILE
. ../../scripts/project-variables.sh
echo "Testing AWS Keys..."
IAM_RESULT=$(aws sts get-caller-identity --query "Account" --output text --profile "$AWS_PROFILE")
if [ "$IAM_RESULT" ]; then
echo "AWS Credentials work!"
else
printf "\033[31mAWS Keys did not work!\033[39m\n"
exit
fi
# Set the user that will be used for private authorised endpoints - the user that logs in on the client will be ignored.
# AUTHORIZER is a value detected by serverless offline https://github.com/dherault/serverless-offline#remote-authorizers
# This user is and can be linked in local seed data so that there is user specific relationships.
# Restart the API when this is changed.
export AUTHORIZER='{"claims":{"email":"example@devika.com", "sub":"ed805890-d66b-4126-a5d9-0b22e70fce80"}}'
# Required to install/use local DynamoDB
pnpm run install:dynamodb
# Doesn't seem compatible with debug mode
# Provides stack trace using source map so the correct file and line numbers are shown
# export NODE_OPTIONS=--enable-source-maps
# Start the API with serverless
export SLS_DEBUG="*" && node --inspect ./node_modules/serverless/bin/serverless offline start --stage local --region "$REGION" --httpPort 4000 --verbose "$@"
cd "$CURRENT_DIR" || exit

View file

@ -0,0 +1,48 @@
#!/usr/bin/env bash
CURRENT_DIR="$(pwd -P)"
PARENT_PATH="$(
cd "$(dirname "${BASH_SOURCE[0]}")" || exit
pwd -P
)/.."
cd "$PARENT_PATH" || exit
# Sets REGION, APP_NAME, AWS_REGION, AWS_PROFILE
. ../../scripts/project-variables.sh
echo "Testing AWS Keys..."
IAM_RESULT=$(aws sts get-caller-identity --query "Account" --output text --profile "$AWS_PROFILE")
if [ "$IAM_RESULT" ]; then
echo "AWS Credentials work!"
else
printf "\033[31mAWS Keys did not work!\033[39m\n"
printf "Would you like to continue anyway (y/N)? "
old_stty_cfg=$(stty -g)
stty raw -echo
answer=$(head -c 1)
stty "$old_stty_cfg"
if echo "$answer" | grep -iq "^y"; then
echo Yes
echo "Continuing (be aware things may not work as expected)"
else
echo No
exit
fi
fi
# Set the user that will be used for private authorised endpoints - the user that logs in on the client will be ignored.
# AUTHORIZER is a value detected by serverless offline https://github.com/dherault/serverless-offline#remote-authorizers
# This user is and can be linked in local seed data so that there is user specific relationships.
# Restart the API when this is changed.
export AUTHORIZER='{"claims":{"email":"example@devika.com", "sub":"ed805890-d66b-4126-a5d9-0b22e70fce80"}}'
# Required to install/use local DynamoDB
pnpm run install:dynamodb
# Provides stack trace using source map so the correct file and line numbers are shown
export NODE_OPTIONS=--enable-source-maps
# Start the API with serverless
npx serverless offline start --stage local --region "$REGION" --httpPort 4000 --verbose "$@"
cd "$CURRENT_DIR" || exit

109
packages/api/serverless.yml Normal file
View file

@ -0,0 +1,109 @@
service: ${env:APP_NAME}-api
frameworkVersion: '>=2.0.0 <4.0.0'
plugins:
- serverless-esbuild
- serverless-dynamodb
- serverless-offline
custom:
apiCorsOrigin: '*'
deletionPolicy:
local: Delete
staging: Delete
prod: Retain
updatePolicy:
local: Delete
staging: Delete
prod: Retain
esbuildAWSExclude:
local: ''
staging: '@aws-sdk'
prod: '@aws-sdk'
esbuild:
minify: false
packager: pnpm
keepOutputDirectory: true
sourcemap: linked
exclude:
- ${self:custom.esbuildAWSExclude.${opt:stage}}
watch:
pattern: src/**/*.ts
ignore: >-
scripts/**/* .build/**/* .dynamodb/**/* .serverless/**/* .esbuild/**/*
node_modules/**/*
serverless-dynamodb:
stages:
- local
start:
port: 8000
inMemory: true
migrate: true
seed: true
seed:
local:
sources:
- table: ${env:APP_NAME}-${opt:stage}-admin
sources:
- ./src/baseblocks/admin/admin.seed.json
- table: ${env:APP_NAME}-${opt:stage}-chart
sources:
- ./src/baseblocks/chart/chart.seed.json
package:
individually: true
provider:
name: aws
deploymentMethod: direct
runtime: nodejs20.x
profile: ${env:AWS_PROFILE}
stage: ${opt:stage}
stackTags:
AppName: ${env:APP_NAME}
Stage: ${opt:stage}
Region: ${opt:region}
Product: Baseline
timeout: 30
architecture: arm64
memorySize: 2048
logRetentionInDays: 90
versionFunctions: false
endpointType: REGIONAL
environment:
APP_NAME: ${env:APP_NAME}
NODE_OPTIONS: '--enable-source-maps'
NODE_ENV: ${opt:stage}
API_REGION: ${opt:region}
API_CORS_ORIGIN: ${self:custom.apiCorsOrigin}
COGNITO_USER_POOL_ID:
Ref: CognitoUserPool
apiGateway:
minimumCompressionSize: 1024
iam:
role:
statements:
- Effect: Allow
Action:
- dynamodb:Query
- dynamodb:Scan
- dynamodb:GetItem
- dynamodb:PutItem
- dynamodb:UpdateItem
- dynamodb:DeleteItem
- dynamodb:BatchGetItem
Resource:
- !Sub ${adminTable.Arn}
- !Sub ${adminTable.Arn}/index/*
- !Sub ${chartTable.Arn}
- !Sub ${chartTable.Arn}/index/*
- Effect: Allow
Action:
- cognito-idp:AdminCreateUser
- cognito-idp:AdminGetUser
- cognito-idp:ListUsers
Resource:
- !Sub ${CognitoUserPool.Arn}
resources:
- ${file(./src/baseblocks/cognito/cognito-resources.yml)}
- ${file(./src/baseblocks/admin/admin-dynamodb.yml)}
- ${file(./src/baseblocks/chart/chart-dynamodb.yml)}
functions:
- ${file(./src/baseblocks/admin/admin-functions.yml)}
- ${file(./src/baseblocks/chart/chart-functions.yml)}

View file

@ -0,0 +1,181 @@
import { Response } from 'express';
import { AdminMapper } from './admin';
import { isAdmin } from '../../middleware/is-admin';
import {
createUser,
getUserAttributesByEmail,
} from '../cognito/cognito.service';
import { RequestContext } from '../../util/request-context.type';
import { Admin } from '@baseline/types/admin';
import { getErrorMessage } from '../../util/error-message';
import createApp from '../../util/express-app';
import createAuthenticatedHandler from '../../util/create-authenticated-handler';
import { adminService } from './admin.service';
const app = createApp();
// app.use(isAdmin); // All private endpoints require the user to be an admin
export const handler = createAuthenticatedHandler(app);
app.patch('/admin', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const { userEmail, userSub } = req.body as Admin;
const adminData: Partial<Admin> = {
userSub: userSub,
userEmail: userEmail.toLowerCase(),
};
const admin = await adminService.update(adminData);
res.json(AdminMapper(admin));
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to update admin: ${message}`);
res.status(400).json({
error: 'Failed to update admin',
});
}
},
]);
app.post('/admin', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const { userEmail } = req.body as Admin;
if (!userEmail) {
res.status(400).json({
error: 'No email given',
});
return;
}
// do not attempt to create user if there is a db record for them already
const allAdmins = await adminService.getAll();
const existingAdmin = allAdmins.find(
(admin) => admin.userEmail === userEmail,
);
if (existingAdmin) {
console.log('Admin user already exists');
res.json(AdminMapper(existingAdmin));
return;
}
// determine if email is used in cognito already
let existingUserSub = '';
try {
const userAttributes = await getUserAttributesByEmail(userEmail);
existingUserSub = userAttributes?.sub || '';
} catch (error) {
console.log(error);
}
// when there is an existing cognito user all we need to do is create the db record
if (existingUserSub) {
console.log('Existing cognito user found, adding to db');
const adminData: Partial<Admin> = {
userSub: existingUserSub,
userEmail: userEmail.toLowerCase(),
};
const admin = await adminService.create(adminData);
res.json(AdminMapper(admin));
return;
}
// if there is no existing user create cognito user and db record
if (!existingUserSub) {
console.log('No existing cognito user, creating one');
const userAttributes = await createUser(userEmail);
if (!userAttributes?.sub) {
throw new Error('No user sub after create');
}
const adminData: Partial<Admin> = {
userSub: userAttributes?.sub,
userEmail: userEmail,
};
const admin = await adminService.create(adminData);
res.json(AdminMapper(admin));
return;
}
console.error(`Failed to delete admin`);
res.status(400).json({
error: 'Failed to create admin',
});
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to create admin: ${message}`);
res.status(400).json({
error:
'Failed to create admin, if working offline please edit serverless.yml',
});
}
},
]);
app.delete('/admin/:userSub', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const userSub = req.params.userSub;
await adminService.delete(userSub);
res.status(200);
res.send();
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to delete admin: ${message}`);
res.status(400).json({
error: 'Failed to delete admin',
});
}
},
]);
app.get('/admin/list', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const admins = await adminService.getAll();
const formattedAdmins = admins.map((data) => AdminMapper(data));
res.json(formattedAdmins);
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to get admins: ${message}`);
res.status(400).json({
error: 'Failed to get admins',
});
}
},
]);
app.get('/admin/:userId', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const admin = await adminService.get(req.params.userId);
res.json(AdminMapper(admin));
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to get admin: ${message}`);
res.status(400).json({
error: 'Failed to get admin',
});
}
},
]);
app.get('/admin', [
async (req: RequestContext, res: Response) => {
try {
const admin = await adminService.get(req.currentUserSub);
res.json(AdminMapper(admin));
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to get admin: ${message}`);
res.status(400).json({
error: 'Failed to get admin',
});
}
},
]);

View file

@ -0,0 +1,16 @@
Resources:
adminTable:
Type: AWS::DynamoDB::Table
DeletionPolicy: ${self:custom.deletionPolicy.${opt:stage}}
UpdateReplacePolicy: ${self:custom.updatePolicy.${opt:stage}}
Properties:
TableName: ${env:APP_NAME}-${opt:stage}-admin
AttributeDefinitions:
- AttributeName: userSub
AttributeType: S
KeySchema:
- AttributeName: userSub
KeyType: HASH
BillingMode: PAY_PER_REQUEST
PointInTimeRecoverySpecification:
PointInTimeRecoveryEnabled: true

View file

@ -0,0 +1,37 @@
ApiAdmin:
handler: src/baseblocks/admin/admin-api.handler
events:
- http:
path: /admin/{any+}
method: ANY
authorizer: # https://www.serverless.com/framework/docs/providers/aws/events/apigateway#http-endpoints-with-aws_iam-authorizers
type: COGNITO_USER_POOLS
authorizerId:
Ref: ApiGatewayAuthorizer
cors:
origin: ${self:custom.apiCorsOrigin}
headers:
- Content-Type
- X-Amz-Date
- Authorization
- X-Api-Key
- X-Amz-Security-Token
- X-Amz-User-Agent
allowCredentials: false
- http:
path: /admin
method: ANY
authorizer: # https://www.serverless.com/framework/docs/providers/aws/events/apigateway#http-endpoints-with-aws_iam-authorizers
type: COGNITO_USER_POOLS
authorizerId:
Ref: ApiGatewayAuthorizer
cors:
origin: ${self:custom.apiCorsOrigin}
headers:
- Content-Type
- X-Amz-Date
- Authorization
- X-Api-Key
- X-Amz-Security-Token
- X-Amz-User-Agent
allowCredentials: false

View file

@ -0,0 +1,14 @@
[
{
"userSub": "ed805890-d66b-4126-a5d9-0b22e70fce80",
"userEmail": "example@devika.com"
},
{
"userSub": "ed805890-d66b-4126-a5d9-0b22e70fce81",
"userEmail": "example+1@devika.com"
},
{
"userSub": "ed805890-d66b-4126-a5d9-0b22e70fce82",
"userEmail": "example+2@devika.com"
}
]

View file

@ -0,0 +1,27 @@
import { Admin } from '@baseline/types/admin';
import { getErrorMessage } from '../../util/error-message';
import { getDynamodbConnection } from '@baselinejs/dynamodb';
import { ServiceObject } from '../../util/service-object';
const dynamoDb = getDynamodbConnection({
region: `${process.env.API_REGION}`,
});
export const adminService = new ServiceObject<Admin>({
dynamoDb: dynamoDb,
objectName: 'Admin',
table: `${process.env.APP_NAME}-${process.env.NODE_ENV}-admin`,
primaryKey: 'userSub',
});
export const isAdminSub = async (userSub: string): Promise<boolean> => {
console.log(`Is ${userSub} Admin`);
try {
const admin = await adminService.get(userSub);
return !!admin?.userSub;
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to check if admin: ${message}`);
return false;
}
};

View file

@ -0,0 +1,9 @@
import { Admin } from '@baseline/types/admin';
export const AdminMapper = (data: Admin): Admin => {
const admin: Admin = {
userSub: data?.userSub,
userEmail: data?.userEmail,
};
return admin;
};

View file

@ -0,0 +1,114 @@
import { Response } from 'express';
import { chartMapper } from './chart';
import { isAdmin } from '../../middleware/is-admin';
import { RequestContext } from '../../util/request-context.type';
import { Chart } from '@baseline/types/chart';
import { getErrorMessage } from '../../util/error-message';
import createApp from '../../util/express-app';
import createAuthenticatedHandler from '../../util/create-authenticated-handler';
import { chartService } from './chart.service';
const app = createApp();
export const handler = createAuthenticatedHandler(app);
app.post('/chart/admin', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const { md5, sha256, resourceUri, parentChart, name, comment } =
req.body as Chart;
const chartData: Partial<Chart> = {
md5,
sha256,
resourceUri,
parentChart,
name,
comment,
};
const chart = await chartService.create(chartData);
res.json(chartMapper(chart));
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to create chart ${message}`);
res.status(400).json({ error: 'Failed to create chart' });
}
},
]);
app.patch('/chart/admin', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const { chartId, md5, sha256, resourceUri, parentChart, name, comment } =
req.body as Chart;
const chartData: Partial<Chart> = {
chartId,
md5,
sha256,
resourceUri,
parentChart,
name,
comment,
};
const chart = await chartService.update(chartData);
res.json(chartMapper(chart));
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to update chart: ${message}`);
res.status(400).json({
error: 'Failed to update chart',
});
}
},
]);
app.delete('/chart/admin/:chartId', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const chartId = req.params.chartId;
await chartService.delete(chartId);
res.status(200);
res.send();
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to delete chart: ${message}`);
res.status(400).json({
error: 'Failed to delete chart',
});
}
},
]);
app.get('/chart/admin/list', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const charts = await chartService.getAll();
const formattedCharts = charts.map(chartMapper);
res.json(formattedCharts);
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to get charts: ${message}`);
res.status(400).json({
error: 'Failed to get charts',
});
}
},
]);
app.get('/chart/admin/:chartId', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const chart = await chartService.get(req.params.chartId);
res.json(chartMapper(chart));
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to get chart: ${message}`);
res.status(400).json({
error: 'Failed to get chart',
});
}
},
]);

View file

@ -0,0 +1,41 @@
Resources:
chartTable:
Type: AWS::DynamoDB::Table
DeletionPolicy: ${self:custom.deletionPolicy.${opt:stage}}
UpdateReplacePolicy: ${self:custom.updatePolicy.${opt:stage}}
Properties:
TableName: ${env:APP_NAME}-${opt:stage}-chart
AttributeDefinitions:
- AttributeName: chartId
AttributeType: S
- AttributeName: parentChart
AttributeType: S
- AttributeName: md5
AttributeType: S
- AttributeName: sha256
AttributeType: S
KeySchema:
- AttributeName: chartId
KeyType: HASH
BillingMode: PAY_PER_REQUEST
PointInTimeRecoverySpecification:
PointInTimeRecoveryEnabled: true
GlobalSecondaryIndexes:
- IndexName: parentChart-index
KeySchema:
- AttributeName: parentChart
KeyType: HASH
Projection:
ProjectionType: ALL
- IndexName: md5-index
KeySchema:
- AttributeName: md5
KeyType: HASH
Projection:
ProjectionType: ALL
- IndexName: sha256-index
KeySchema:
- AttributeName: sha256
KeyType: HASH
Projection:
ProjectionType: ALL

View file

@ -0,0 +1,67 @@
ApiChartAdmin:
handler: src/baseblocks/chart/chart-admin-api.handler
events:
- http:
path: /chart/admin/{any+}
method: ANY
authorizer: # https://www.serverless.com/framework/docs/providers/aws/events/apigateway#http-endpoints-with-aws_iam-authorizers
type: COGNITO_USER_POOLS
authorizerId:
Ref: ApiGatewayAuthorizer
cors:
origin: ${self:custom.apiCorsOrigin}
headers:
- Content-Type
- X-Amz-Date
- Authorization
- X-Api-Key
- X-Amz-Security-Token
- X-Amz-User-Agent
allowCredentials: false
- http:
path: /chart/admin
method: ANY
authorizer: # https://www.serverless.com/framework/docs/providers/aws/events/apigateway#http-endpoints-with-aws_iam-authorizers
type: COGNITO_USER_POOLS
authorizerId:
Ref: ApiGatewayAuthorizer
cors:
origin: ${self:custom.apiCorsOrigin}
headers:
- Content-Type
- X-Amz-Date
- Authorization
- X-Api-Key
- X-Amz-Security-Token
- X-Amz-User-Agent
allowCredentials: false
ApiChartPublic:
handler: src/baseblocks/chart/chart-public-api.handler
events:
- http:
path: /chart/public/{any+}
method: ANY
cors:
origin: ${self:custom.apiCorsOrigin}
headers:
- Content-Type
- X-Amz-Date
- Authorization
- X-Api-Key
- X-Amz-Security-Token
- X-Amz-User-Agent
allowCredentials: false
- http:
path: /chart/public
method: ANY
cors:
origin: ${self:custom.apiCorsOrigin}
headers:
- Content-Type
- X-Amz-Date
- Authorization
- X-Api-Key
- X-Amz-Security-Token
- X-Amz-User-Agent
allowCredentials: false

View file

@ -0,0 +1,43 @@
import createApp from '../../util/express-app';
import createAuthenticatedHandler from '../../util/create-authenticated-handler';
import { RequestContext } from '../../util/request-context.type';
import { Response } from 'express';
import { getErrorMessage } from '../../util/error-message';
import { getChartsByMd5, getChartsBySha256 } from './chart.service';
import { flatten, keyBy } from 'lodash-es';
const app = createApp();
export const handler = createAuthenticatedHandler(app);
app.post('/chart/public', [
async (req: RequestContext, res: Response) => {
try {
const { md5 = [], sha256 = [] } = req.body as {
md5: string[];
sha256: string[];
};
/** @TODO Ask herman how to batch query */
const md5Charts = flatten(
await Promise.all(md5.map((md5) => getChartsByMd5(md5))),
);
const md5ChartsKeyedByMd5 = keyBy(md5Charts, 'md5');
console.log(md5Charts);
const sha256Charts = flatten(
await Promise.all(sha256.map((sha256) => getChartsBySha256(sha256))),
);
const sha256ChartsKeyedBySha256 = keyBy(sha256Charts, 'sha256');
res.json({
md5: md5ChartsKeyedByMd5,
sha256: sha256ChartsKeyedBySha256,
});
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to get charts ${message}`);
res.status(400).json({ error: 'Failed to get charts' });
}
},
]);

View file

@ -0,0 +1,3 @@
[
]

View file

@ -0,0 +1,65 @@
import { Chart } from '@baseline/types/chart';
import { getDynamodbConnection, queryItems } from '@baselinejs/dynamodb';
import { ServiceObject } from '../../util/service-object';
import { getErrorMessage } from '../../util/error-message';
const dynamoDb = getDynamodbConnection({
region: `${process.env.API_REGION}`,
});
export const chartService = new ServiceObject<Chart>({
dynamoDb: dynamoDb,
objectName: 'Chart',
table: `${process.env.APP_NAME}-${process.env.NODE_ENV}-chart`,
primaryKey: 'chartId',
});
export const getChartsByParentChartId = async (
parentChartId: string,
): Promise<Chart[]> => {
try {
return await queryItems<Chart>({
dynamoDb: chartService.dynamoDb,
table: chartService.table,
keyName: 'parentChart',
keyValue: parentChartId,
indexName: 'parentChart-index',
});
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to get ${chartService.objectName}: ${message}`);
throw new Error(message);
}
};
export const getChartsByMd5 = async (md5: string): Promise<Chart[]> => {
try {
return await queryItems<Chart>({
dynamoDb: chartService.dynamoDb,
table: chartService.table,
keyName: 'md5',
keyValue: md5,
indexName: 'md5-index',
});
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to get ${chartService.objectName}: ${message}`);
throw new Error(message);
}
};
export const getChartsBySha256 = async (sha256: string): Promise<Chart[]> => {
try {
return await queryItems<Chart>({
dynamoDb: chartService.dynamoDb,
table: chartService.table,
keyName: 'sha256',
keyValue: sha256,
indexName: 'sha256-index',
});
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to get ${chartService.objectName}: ${message}`);
throw new Error(message);
}
};

View file

@ -0,0 +1,14 @@
import { Chart } from '@baseline/types/chart';
export const chartMapper = (data: Chart): Chart => {
const chart: Chart = {
chartId: data?.chartId,
md5: data?.md5,
sha256: data?.sha256,
resourceUri: data?.resourceUri,
parentChart: data?.parentChart,
name: data?.name,
comment: data?.comment,
};
return chart;
};

View file

@ -0,0 +1,105 @@
Resources:
CognitoUserPool:
Type: AWS::Cognito::UserPool
Properties:
# Generate a name based on the stage
UserPoolName: ${env:APP_NAME}-${opt:stage}-user-pool
# Set email as an alias
UsernameAttributes:
- email
AutoVerifiedAttributes:
- email
UsernameConfiguration:
CaseSensitive: false
CognitoUserPoolClient:
Type: AWS::Cognito::UserPoolClient
Properties:
# Generate an app client name based on the stage
ClientName: ${env:APP_NAME}-${opt:stage}-user-pool-client
UserPoolId:
Ref: CognitoUserPool
ExplicitAuthFlows:
- ADMIN_NO_SRP_AUTH
- USER_PASSWORD_AUTH
GenerateSecret: false
PreventUserExistenceErrors: ENABLED
# The federated identity for our user pool to auth with
CognitoIdentityPool:
Type: AWS::Cognito::IdentityPool
Properties:
# Generate a name based on the stage
IdentityPoolName: ${env:APP_NAME}-${opt:stage}-identity-pool
# Don't allow unathenticated users
AllowUnauthenticatedIdentities: false
# Link to our User Pool
CognitoIdentityProviders:
- ClientId:
Ref: CognitoUserPoolClient
ProviderName:
Fn::GetAtt: ['CognitoUserPool', 'ProviderName']
# IAM roles
CognitoIdentityPoolRoles:
Type: AWS::Cognito::IdentityPoolRoleAttachment
Properties:
IdentityPoolId:
Ref: CognitoIdentityPool
Roles:
authenticated:
Fn::GetAtt: [CognitoAuthRole, Arn]
# IAM role used for authenticated users
CognitoAuthRole:
Type: AWS::IAM::Role
Properties:
Path: /
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: 'Allow'
Principal:
Federated: 'cognito-identity.amazonaws.com'
Action:
- 'sts:AssumeRoleWithWebIdentity'
Condition:
StringEquals:
'cognito-identity.amazonaws.com:aud':
Ref: CognitoIdentityPool
'ForAnyValue:StringLike':
'cognito-identity.amazonaws.com:amr': authenticated
# API Gateway authorizer using Cognito
ApiGatewayAuthorizer:
Type: AWS::ApiGateway::Authorizer
Properties:
Name: ${env:APP_NAME}-${opt:stage}-api-gateway-authorizer
Type: COGNITO_USER_POOLS
IdentitySource: method.request.header.Authorization
RestApiId:
Ref: ApiGatewayRestApi
ProviderARNs:
- Fn::GetAtt:
- CognitoUserPool
- Arn
Outputs:
UserPoolId:
Description: 'Cognito UserPoolId'
Value:
Ref: CognitoUserPool
UserPoolClientId:
Description: 'Cognito UserPoolClientId'
Value:
Ref: CognitoUserPoolClient
IdentityPoolId:
Description: 'Cognito IdentityPoolId'
Value:
Ref: CognitoIdentityPool
CognitoAuthRole:
Description: 'Cognito CognitoAuthRole'
Value:
Ref: CognitoAuthRole

View file

@ -0,0 +1,151 @@
import * as AWS_CognitoIdentityServiceProvider from '@aws-sdk/client-cognito-identity-provider';
const { CognitoIdentityProvider: CognitoIdentityServiceProvider } =
AWS_CognitoIdentityServiceProvider;
const cognito = new CognitoIdentityServiceProvider({
region: process.env.API_REGION || 'ap-southeast-2',
});
export async function getUserAttributesByEmail(userEmail: string) {
try {
const formattedEmail = userEmail?.toLowerCase();
const existingResponse = await cognito.adminGetUser({
UserPoolId: `${process.env.COGNITO_USER_POOL_ID}`,
Username: `${formattedEmail}`,
});
console.log(JSON.stringify(existingResponse, null, 2));
const attributes = existingResponse?.UserAttributes?.reduce(
(prev, attr) => {
if (attr?.Name) {
prev[attr.Name] = `${attr.Value}`;
}
return prev;
},
{} as { [key: string]: string },
);
return attributes;
} catch (error) {
console.log('No user found: ', error);
}
}
export async function createUser(userEmail: string) {
try {
const formattedEmail = userEmail?.toLowerCase();
const userAttributes = [
{
Name: 'email',
Value: formattedEmail,
},
{
Name: 'email_verified',
Value: 'true',
},
];
const cognitoUser = await cognito.adminCreateUser({
UserPoolId: process.env.COGNITO_USER_POOL_ID,
Username: formattedEmail,
UserAttributes: userAttributes,
DesiredDeliveryMediums: ['EMAIL'],
});
console.log(JSON.stringify(cognitoUser, null, 2));
const attributes = cognitoUser.User?.Attributes?.reduce((prev, attr) => {
if (attr.Name) {
prev[attr.Name] = `${attr.Value}`;
}
return prev;
}, {} as { [key: string]: string });
return attributes;
} catch (error) {
console.log('Failed to create cognito user', error);
}
}
export async function getUsers(args: {
subFilter?: string;
usernameFilter?: string;
emailFilter?: string;
phoneNumberFilter?: string;
nameFilter?: string;
givenNameFilter?: string;
familyNameFilter?: string;
pageSize?: number;
paginationToken?: string;
isExactMatch?: boolean;
}): Promise<
AWS_CognitoIdentityServiceProvider.ListUsersCommandOutput | undefined
> {
const {
subFilter,
usernameFilter,
emailFilter,
nameFilter,
givenNameFilter,
familyNameFilter,
phoneNumberFilter,
pageSize,
paginationToken,
isExactMatch,
} = args;
try {
const requestArgs = {
UserPoolId: process.env.COGNITO_USER_POOL_ID,
Limit: pageSize || 50,
PaginationToken: paginationToken,
} as AWS_CognitoIdentityServiceProvider.ListUsersCommandInput;
let field = '';
let value = '';
const comparison = `${isExactMatch ? '' : '^'}=`;
if (subFilter) {
field = 'sub';
value = subFilter;
}
if (usernameFilter) {
field = 'username';
value = usernameFilter;
}
if (emailFilter) {
field = 'email';
value = emailFilter;
}
if (phoneNumberFilter) {
field = 'phone_number';
value = phoneNumberFilter;
}
if (nameFilter) {
field = 'name';
value = nameFilter;
}
if (givenNameFilter) {
field = 'given_name';
value = givenNameFilter;
}
if (familyNameFilter) {
field = 'family_name';
value = familyNameFilter;
}
if (field) {
requestArgs.Filter = `${field} ${comparison} "${value}"`;
}
const response = await cognito.listUsers(requestArgs);
return response;
} catch (error) {
console.log('No users found: ', error);
}
}

View file

@ -0,0 +1,19 @@
import { NextFunction, Response } from 'express';
import { isAdminSub } from '../baseblocks/admin/admin.service';
import { RequestContext } from '../util/request-context.type';
export const isAdmin = async (
req: RequestContext,
res: Response,
next: NextFunction,
) => {
const userSub = req.currentUserSub;
const isAdmin = await isAdminSub(userSub);
if (!isAdmin) {
res.status(403).json({
error: 'User does not have permission',
});
return;
}
next();
};

View file

@ -0,0 +1,11 @@
import { NextFunction, Response } from 'express';
import { RequestContext } from '../util/request-context.type';
export const logRoute = (
req: RequestContext,
res: Response,
next: NextFunction,
) => {
console.log(`Request: ${req.method} ${req.originalUrl}`);
next();
};

View file

@ -0,0 +1,19 @@
import serverless from 'serverless-http';
import { APIGatewayProxyEventBase } from 'aws-lambda';
import { Authorizer, RequestContext } from './request-context.type';
import { Application } from 'express';
const createAuthenticatedHandler = (app: Application) => {
const handler = serverless(app, {
request(
request: RequestContext,
event: APIGatewayProxyEventBase<Authorizer>,
) {
request.context = event.requestContext;
request.currentUserSub = `${request.context?.authorizer?.claims?.sub}`;
},
});
return handler;
};
export default createAuthenticatedHandler;

View file

@ -0,0 +1,9 @@
export function getErrorMessage(error: unknown) {
let message: string;
if (error instanceof Error) {
message = error.message;
} else {
message = String(error);
}
return message;
}

View file

@ -0,0 +1,22 @@
import compression from 'compression';
import cors from 'cors';
import express, { Application } from 'express';
import { logRoute } from '../middleware/log-route';
const createApp = (): Application => {
const corsOptions = {
origin: process.env.API_CORS_ORIGIN,
optionsSuccessStatus: 200, // some legacy browsers (IE11, various SmartTVs) choke on 204
};
const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(compression());
app.use(cors(corsOptions));
app.options('*', cors(corsOptions));
app.use(logRoute);
return app;
};
export default createApp;

View file

@ -0,0 +1,23 @@
import { PagedResponse } from '@baseline/types/paging';
export interface PageDataParams<T> {
items: T[];
limit: string | number;
offset: string | number;
}
export function pageData<T>(params: PageDataParams<T>): PagedResponse<T> {
const limit = parseInt(`${params.limit}`) || 10;
const offset = parseInt(`${params.offset}`) || 0;
const items = params.items;
return {
data: items.slice(offset, offset + limit),
pagination: {
limit: limit,
totalRecords: items.length,
nextFrom: offset + limit < items.length ? offset + limit : undefined,
pages: parseInt(`${Math.ceil(items.length / limit) - 1}`),
currentPage: parseInt(`${offset / limit}`),
},
};
}

View file

@ -0,0 +1,15 @@
import {
APIGatewayEventDefaultAuthorizerContext,
APIGatewayEventRequestContextWithAuthorizer,
} from 'aws-lambda';
import { Request } from 'express';
export type RequestContext = Request & {
context: APIGatewayEventRequestContextWithAuthorizer<Authorizer>;
} & {
currentUserSub: string;
};
export type Authorizer = APIGatewayEventDefaultAuthorizerContext & {
claims: { email?: string; sub?: string };
};

View file

@ -0,0 +1,178 @@
import { getErrorMessage } from './error-message';
import {
batchGetItems,
deleteItem,
getItem,
getAllItems,
putItem,
updateItem,
DynamoDbDocumentClient,
} from '@baselinejs/dynamodb';
import { randomUUID } from 'crypto';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export class ServiceObject<T extends Record<string, any>> {
table: string;
dynamoDb: DynamoDbDocumentClient;
objectName: string;
primaryKey: string;
ownerField: string | undefined;
constructor(params: {
table: string;
objectName: string;
dynamoDb: DynamoDbDocumentClient;
primaryKey: string;
ownerField?: string;
}) {
this.dynamoDb = params.dynamoDb;
this.table = params.table;
this.objectName = params.objectName;
this.primaryKey = params.primaryKey;
this.ownerField = params.ownerField;
}
async getAll(): Promise<T[]> {
console.log(`Get all ${this.objectName} records`);
try {
if (!this.dynamoDb) {
throw new Error('DynamoDB not connected');
}
return getAllItems<T>({
dynamoDb: this.dynamoDb,
table: this.table,
});
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to get ${this.objectName}: ${message}`);
throw new Error(message);
}
}
async get(key: string): Promise<T> {
console.log(`Get ${this.objectName} by ${this.primaryKey} [${key}]`);
try {
if (!this.dynamoDb) {
throw new Error('DynamoDB not connected');
}
return await getItem<T>({
dynamoDb: this.dynamoDb,
table: this.table,
key: {
[this.primaryKey]: `${key}`,
},
});
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to get ${this.objectName}: ${message}`);
throw new Error(message);
}
}
async create(record: Partial<T>): Promise<T> {
console.log(`Create ${this.objectName}`);
try {
if (!this.dynamoDb) {
throw new Error('DynamoDB not connected');
}
const item: Partial<T> = {
[this.primaryKey]: randomUUID(),
...record,
};
return await putItem<T>({
dynamoDb: this.dynamoDb,
table: this.table,
item: item as T,
});
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to create ${this.objectName}: ${message}`);
throw new Error(message);
}
}
async update(record: Partial<T>): Promise<T> {
console.log(
`Update ${this.objectName} ${this.primaryKey} [${
record[this.primaryKey]
}]`,
);
try {
if (!this.dynamoDb) {
throw new Error('DynamoDB not connected');
}
if (!record[this.primaryKey]) {
throw new Error(`Cannot update without ${this.primaryKey}`);
}
const partial = {} as Partial<T>;
Object.keys(record).forEach((key: keyof T) => {
if (key !== this.primaryKey) {
partial[key] = record[key];
}
});
return await updateItem<T>({
dynamoDb: this.dynamoDb,
table: this.table,
key: {
[this.primaryKey]: `${record[this.primaryKey]}`,
},
fields: partial,
});
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to update ${this.objectName}: ${message}`);
throw new Error(message);
}
}
async delete(keyValue: string): Promise<boolean> {
console.log(`Delete ${this.objectName} ${keyValue}`);
try {
if (!this.dynamoDb) {
throw new Error('DynamoDB not connected');
}
return await deleteItem({
dynamoDb: this.dynamoDb,
table: this.table,
key: {
[this.primaryKey]: keyValue,
},
});
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to delete ${this.objectName}: ${message}`);
throw new Error(message);
}
}
async batchGet(ids: string[]): Promise<T[]> {
console.log(`Getting all ${this.objectName} by ids`);
try {
if (!this.dynamoDb) {
throw new Error('DynamoDB not connected');
}
const keys = ids.map((id) => {
return {
[this.primaryKey]: id,
};
});
return await batchGetItems<T>({
dynamoDb: this.dynamoDb,
keys: keys,
table: this.table,
});
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to batch get ${this.objectName}: ${message}`);
throw new Error(message);
}
}
async isOwner(id: string, userSub: string) {
if (this.ownerField) {
const record = await this.get(id);
return record[this.ownerField] === userSub;
}
return false;
}
}

View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
"preserveConstEnums": true,
"strictNullChecks": true,
"sourceMap": true,
"allowJs": true,
"module": "CommonJS",
"target": "es5",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"lib": ["es2015"],
"rootDirs": ["./"],
"outDir": ".esbuild"
},
"include": ["../../shared/types", "src"],
"watchOptions": {
"excludeDirectories": [
"node_modules",
".esbuild",
".dynamodb",
".serverless",
"scripts"
]
}
}