1
0
Fork 0

feat(backend): Bunch of changes for Docker

This commit is contained in:
joostdecock 2022-12-18 14:41:58 +01:00
parent 2b254c3b07
commit ab844024f6
11 changed files with 826 additions and 559 deletions

View file

@ -202,23 +202,28 @@ yuri:
backend: backend:
_: _:
'@aws-sdk/client-sesv2': '^3.200.0' '@aws-sdk/client-sesv2': '3.200.0'
'@prisma/client': '4.7.1' '@prisma/client': '4.7.1'
'bcryptjs': '^2.4.3' 'bcryptjs': '2.4.3'
'crypto': '^1.0.1' 'cors': '2.8.5'
'crypto': '1.0.1'
'dotenv': '16.0.3'
'express': '4.18.2' 'express': '4.18.2'
'mustache': '^4.2.0' 'lodash.get': *_get
'otplib': '^12.0.1' 'mustache': '4.2.0'
'passport': '^0.6.0' 'otplib': '12.0.1'
'passport-http': '^0.3.0' 'passport': '0.6.0'
'passport-jwt': '^4.0.0' 'passport-http': '0.3.0'
'pino': '^8.7.0' 'passport-jwt': '4.0.0'
'qrcode': '^1.5.1' 'pino': '8.7.0'
'qrcode': '1.5.1'
dev: dev:
'chai': *chai 'chai': *chai
'chai-http': '^4.3.0' 'chai-http': '4.3.0'
'esbuild': '0.16.9'
'mocha': *mocha 'mocha': *mocha
'mocha-steps': '^1.3.0' 'mocha-steps': '1.3.0'
'nodemon': '2.0.20'
'prisma': '4.7.1' 'prisma': '4.7.1'
dev: dev:

View file

@ -49,12 +49,16 @@ snapseries:
# Sites go here # Sites go here
backend: backend:
build: 'node build.mjs'
clean: 'rimraf dist'
dev: 'nodemon src/index.mjs' dev: 'nodemon src/index.mjs'
initdb: 'npx prisma db push' initdb: 'npx prisma db push'
mbuild: 'NO_MINIFY=1 node build.mjs'
newdb: 'node ./scripts/newdb.mjs' newdb: 'node ./scripts/newdb.mjs'
prettier: "npx prettier --write 'src/*.mjs' 'tests/*.mjs'" prettier: "npx prettier --write 'src/*.mjs' 'tests/*.mjs'"
rmdb: 'node ./scripts/rmdb.mjs' rmdb: 'node ./scripts/rmdb.mjs'
test: 'npx mocha --require mocha-steps tests/index.mjs' test: 'npx mocha --require mocha-steps tests/index.mjs'
vbuild: 'VERBOSE=1 node build.mjs'
dev: dev:
build: &nextBuild 'node --experimental-json-modules ../../node_modules/next/dist/bin/next build' build: &nextBuild 'node --experimental-json-modules ../../node_modules/next/dist/bin/next build'

View file

@ -1,2 +1,7 @@
# Protect auto-generated encryption keys
encryption.key
# SQLite databases
*.sqlite *.sqlite
*.sqlite-journal *.sqlite-journal

View file

@ -1,37 +1,44 @@
## Stage 1: Builder ## Stage 1: Builder
FROM node:alpine as builder FROM node:16.15-slim as builder
## Set workdir ## Set workdir
WORKDIR /backend WORKDIR /backend
## Install build toolchain
#RUN apk add --no-cache python make g++
## Install node dependencies ## Install node dependencies
COPY package* ./ COPY package* ./
COPY prisma .
RUN apt-get update && apt-get install -y openssl
RUN npm install pm2 && npm ci RUN npm install pm2 && npm ci
RUN ls -l /backend/node_modules/prisma/libquery_engine-debian-openssl-1.1.x.so.node
RUN npx prisma generate
## Build app ## Build app
COPY package.json package.json COPY package.json package.json
COPY src src COPY src src
COPY prisma prisma
COPY local-config.mjs local-config.mjs
COPY build.mjs build.mjs COPY build.mjs build.mjs
RUN npm run build RUN npm run build
## Stage 2: App ## Stage 2: App
FROM node:alpine as app FROM node:16.15-slim as app
## Set workdir ## Set workdir
WORKDIR /backend WORKDIR /backend
## Copy built node modules and binaries without including the toolchain ## Copy built node modules and binaries without including the toolchain
COPY --from=builder /backend/node_modules/ /backend/node_modules/ COPY --from=builder /backend/node_modules/ /backend/node_modules/
COPY --from=builder /backend/build/ /backend/build/ COPY --from=builder /backend/dist/ /backend/dist/
COPY --from=builder /backend/package.json /backend/package.json COPY --from=builder /backend/package.json /backend/package.json
COPY --from=builder /backend/prisma /backend
COPY --from=builder /backend/prisma/schema.sqlite /backend/db.sqlite
COPY --from=builder /backend/local-config.mjs /backend/
RUN mkdir -p /backend/src/landing
COPY --from=builder /backend/src/landing/* /backend/src/landing/
## Add a user to run the app ## Add a user to run the app
RUN addgroup -S freesewing \ RUN useradd --home-dir /backend --comment FreeSewing --no-create-home --uid 20000 freesewing
&& adduser -S freesewing \ RUN chown -R freesewing /backend
&& chown -R freesewing /backend
## Drop privleges and run app ## Drop privleges and run app
USER freesewing USER freesewing

View file

@ -12,12 +12,23 @@ const banner = `/**
// Shared esbuild options // Shared esbuild options
const options = { const options = {
banner: { js: banner }, banner: {
js: `// See: https://github.com/evanw/esbuild/issues/1921
import { createRequire } from 'module';
import path from 'path';
import { fileURLToPath } from 'url';
const require = createRequire(import.meta.url);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
${banner}
`,
},
bundle: true, bundle: true,
entryPoints: ['src/index.mjs'], entryPoints: ['src/index.mjs'],
format: 'esm', format: 'esm',
outfile: 'dist/index.mjs', outfile: 'dist/index.mjs',
external: [], external: ['./local-config.mjs'],
metafile: process.env.VERBOSE ? true : false, metafile: process.env.VERBOSE ? true : false,
minify: process.env.NO_MINIFY ? false : true, minify: process.env.NO_MINIFY ? false : true,
sourcemap: true, sourcemap: true,

View file

@ -0,0 +1,16 @@
/*
* This method allows you to change/override the backend config
*
* It takes the initial (base) config object.
* It must return the (modified) config object.
*
* Note that you can configure a lot via environment variables
* but if you prefer to keep certain aspects of the config in
* code, you can override this file.
*
* If you're running this in Docker, you can volume-mount only this file.
* This gives you full control over the container configuration.
*/
export function postConfig(config) {
return config
}

View file

@ -14,37 +14,42 @@
"url": "https://freesewing.org/patrons/join" "url": "https://freesewing.org/patrons/join"
}, },
"scripts": { "scripts": {
"build": "node build.mjs",
"clean": "rimraf dist",
"dev": "nodemon src/index.mjs", "dev": "nodemon src/index.mjs",
"build": "node --experimental-json-modules build.mjs",
"test": "npx mocha --require mocha-steps tests/index.mjs",
"initdb": "npx prisma db push", "initdb": "npx prisma db push",
"mbuild": "NO_MINIFY=1 node build.mjs",
"newdb": "node ./scripts/newdb.mjs", "newdb": "node ./scripts/newdb.mjs",
"prettier": "npx prettier --write 'src/*.mjs' 'tests/*.mjs'", "prettier": "npx prettier --write 'src/*.mjs' 'tests/*.mjs'",
"rmdb": "node ./scripts/rmdb.mjs", "rmdb": "node ./scripts/rmdb.mjs",
"test": "npx mocha --require mocha-steps tests/index.mjs" "test": "npx mocha --require mocha-steps tests/index.mjs",
"vbuild": "VERBOSE=1 node build.mjs"
}, },
"peerDependencies": {}, "peerDependencies": {},
"dependencies": { "dependencies": {
"@aws-sdk/client-sesv2": "^3.200.0", "@aws-sdk/client-sesv2": "3.200.0",
"@prisma/client": "4.7.1", "@prisma/client": "4.7.1",
"bcryptjs": "^2.4.3", "bcryptjs": "2.4.3",
"crypto": "^1.0.1", "cors": "2.8.5",
"esbuild": "^0.16.8", "crypto": "1.0.1",
"dotenv": "16.0.3",
"express": "4.18.2", "express": "4.18.2",
"mustache": "^4.2.0", "lodash.get": "4.4.2",
"otplib": "^12.0.1", "mustache": "4.2.0",
"passport": "^0.6.0", "otplib": "12.0.1",
"passport-http": "^0.3.0", "passport": "0.6.0",
"passport-jwt": "^4.0.0", "passport-http": "0.3.0",
"pino": "^8.7.0", "passport-jwt": "4.0.0",
"qrcode": "^1.5.1", "pino": "8.7.0",
"cors": "latest" "qrcode": "1.5.1"
}, },
"devDependencies": { "devDependencies": {
"chai": "^4.2.0", "chai": "^4.2.0",
"chai-http": "^4.3.0", "chai-http": "4.3.0",
"esbuild": "0.16.9",
"mocha": "^10.0.0", "mocha": "^10.0.0",
"mocha-steps": "^1.3.0", "mocha-steps": "1.3.0",
"nodemon": "2.0.20",
"prisma": "4.7.1" "prisma": "4.7.1"
}, },
"engines": { "engines": {

View file

@ -1,5 +1,6 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-1.1.x"]
} }
datasource db { datasource db {

View file

@ -2,13 +2,22 @@ import chalk from 'chalk'
// Load environment variables // Load environment variables
import dotenv from 'dotenv' import dotenv from 'dotenv'
import { asJson } from './utils/index.mjs' import { asJson } from './utils/index.mjs'
import { randomString } from './utils/crypto.mjs'
import { measurements } from './measurements.mjs' import { measurements } from './measurements.mjs'
import get from 'lodash.get'
import { readFileSync, writeFileSync } from 'node:fs'
import { postConfig } from '../local-config.mjs'
dotenv.config() dotenv.config()
// Allow these 2 to be imported // Allow these 2 to be imported
export const port = process.env.BACKEND_PORT || 3000 export const port = process.env.BACKEND_PORT || 3000
export const api = process.env.BACKEND_URL || `http://localhost:${port}` export const api = process.env.BACKEND_URL || `http://localhost:${port}`
// Generate/Check encryption key only once
const encryptionKey = process.env.BACKEND_ENC_KEY
? process.env.BACKEND_ENC_KEY
: randomEncryptionKey()
// All environment variables are strings // All environment variables are strings
// This is a helper method to turn them into a boolean // This is a helper method to turn them into a boolean
const envToBool = (input = 'no') => { const envToBool = (input = 'no') => {
@ -17,7 +26,7 @@ const envToBool = (input = 'no') => {
} }
// Construct config object // Construct config object
const config = { const baseConfig = {
// Feature flags // Feature flags
use: { use: {
github: envToBool(process.env.BACKEND_ENABLE_GITHUB), github: envToBool(process.env.BACKEND_ENABLE_GITHUB),
@ -45,13 +54,13 @@ const config = {
pattern: process.env.BACKEND_AVATAR_PATTERN || 'https://freesewing.org/avatar.svg', pattern: process.env.BACKEND_AVATAR_PATTERN || 'https://freesewing.org/avatar.svg',
}, },
db: { db: {
url: process.env.BACKEND_DB_URL, url: process.env.BACKEND_DB_URL || './db.sqlite',
}, },
encryption: { encryption: {
key: process.env.BACKEND_ENC_KEY, key: encryptionKey,
}, },
jwt: { jwt: {
secretOrKey: process.env.BACKEND_ENC_KEY, secretOrKey: encryptionKey,
issuer: process.env.BACKEND_JWT_ISSUER || 'freesewing.org', issuer: process.env.BACKEND_JWT_ISSUER || 'freesewing.org',
audience: process.env.BACKEND_JWT_ISSUER || 'freesewing.org', audience: process.env.BACKEND_JWT_ISSUER || 'freesewing.org',
expiresIn: process.env.BACKEND_JWT_EXPIRY || '7d', expiresIn: process.env.BACKEND_JWT_EXPIRY || '7d',
@ -84,8 +93,8 @@ const config = {
*/ */
// Github config // Github config
if (config.use.github) if (baseConfig.use.github)
config.github = { baseConfig.github = {
token: process.env.BACKEND_GITHUB_TOKEN, token: process.env.BACKEND_GITHUB_TOKEN,
api: 'https://api.github.com', api: 'https://api.github.com',
bot: { bot: {
@ -116,17 +125,17 @@ if (config.use.github)
} }
// Unit test config // Unit test config
if (config.use.tests.base) if (baseConfig.use.tests.base)
config.tests = { baseConfig.tests = {
domain: process.env.BACKEND_TEST_DOMAIN || 'freesewing.dev', domain: process.env.BACKEND_TEST_DOMAIN || 'freesewing.dev',
} }
// Sanity config // Sanity config
if (config.use.sanity) if (baseConfig.use.sanity)
config.sanity = { baseConfig.sanity = {
project: process.env.SANITY_PROJECT, project: process.env.SANITY_PROJECT,
dataset: process.env.SANITY_DATASET || 'production', dataset: process.env.SANITY_DATASET || 'production',
token: process.env.SANITY_TOKEN, token: process.env.SANITY_TOKEN || 'fixmeSetSanityToken',
version: process.env.SANITY_VERSION || 'v2022-10-31', version: process.env.SANITY_VERSION || 'v2022-10-31',
api: `https://${process.env.SANITY_PROJECT || 'missing-project-id'}.api.sanity.io/${ api: `https://${process.env.SANITY_PROJECT || 'missing-project-id'}.api.sanity.io/${
process.env.SANITY_VERSION || 'v2022-10-31' process.env.SANITY_VERSION || 'v2022-10-31'
@ -134,8 +143,8 @@ if (config.use.sanity)
} }
// AWS SES config (for sending out emails) // AWS SES config (for sending out emails)
if (config.use.ses) if (baseConfig.use.ses)
config.aws = { baseConfig.aws = {
ses: { ses: {
region: process.env.BACKEND_AWS_SES_REGION || 'us-east-1', region: process.env.BACKEND_AWS_SES_REGION || 'us-east-1',
from: process.env.BACKEND_AWS_SES_FROM || 'FreeSewing <info@freesewing.org>', from: process.env.BACKEND_AWS_SES_FROM || 'FreeSewing <info@freesewing.org>',
@ -151,8 +160,8 @@ if (config.use.ses)
} }
// Oauth config for Github as a provider // Oauth config for Github as a provider
if (config.use.oauth?.github) if (baseConfig.use.oauth?.github)
config.oauth.github = { baseConfig.oauth.github = {
clientId: process.env.BACKEND_OAUTH_GITHUB_CLIENT_ID, clientId: process.env.BACKEND_OAUTH_GITHUB_CLIENT_ID,
clientSecret: process.env.BACKEND_OAUTH_GITHUB_CLIENT_SECRET, clientSecret: process.env.BACKEND_OAUTH_GITHUB_CLIENT_SECRET,
tokenUri: 'https://github.com/login/oauth/access_token', tokenUri: 'https://github.com/login/oauth/access_token',
@ -161,24 +170,27 @@ if (config.use.oauth?.github)
} }
// Oauth config for Google as a provider // Oauth config for Google as a provider
if (config.use.oauth?.google) if (baseConfig.use.oauth?.google)
config.oauth.google = { baseConfig.oauth.google = {
clientId: process.env.BACKEND_OAUTH_GOOGLE_CLIENT_ID, clientId: process.env.BACKEND_OAUTH_GOOGLE_CLIENT_ID,
clientSecret: process.env.BACKEND_OAUTH_GOOGLE_CLIENT_SECRET, clientSecret: process.env.BACKEND_OAUTH_GOOGLE_CLIENT_SECRET,
tokenUri: 'https://oauth2.googleapis.com/token', tokenUri: 'https://oauth2.googleapis.com/token',
dataUri: 'https://people.googleapis.com/v1/people/me?personFields=emailAddresses,names,photos', dataUri: 'https://people.googleapis.com/v1/people/me?personFields=emailAddresses,names,photos',
} }
// Load local config
const config = postConfig(baseConfig)
// Exporting this stand-alone config // Exporting this stand-alone config
export const sanity = config.sanity || {} export const sanity = config.sanity || {}
export const website = config.website export const website = config.website
const vars = { const vars = {
BACKEND_DB_URL: 'required', BACKEND_DB_URL: ['required', 'db.url'],
BACKEND_PORT: 'optional', BACKEND_PORT: 'optional',
BACKEND_WEBSITE_DOMAIN: 'optional', BACKEND_WEBSITE_DOMAIN: 'optional',
BACKEND_WEBSITE_SCHEME: 'optional', BACKEND_WEBSITE_SCHEME: 'optional',
BACKEND_ENC_KEY: 'requiredSecret', BACKEND_ENC_KEY: ['requiredSecret', 'encryption.key'],
BACKEND_JWT_ISSUER: 'optional', BACKEND_JWT_ISSUER: 'optional',
BACKEND_JWT_EXPIRY: 'optional', BACKEND_JWT_EXPIRY: 'optional',
// Feature flags // Feature flags
@ -249,12 +261,19 @@ export function verifyConfig(silent = false) {
const errors = [] const errors = []
const ok = [] const ok = []
for (const [key, type] of Object.entries(vars)) { for (let [key, type] of Object.entries(vars)) {
let configPath = false
let val
if (Array.isArray(type)) [type, configPath] = type
if (['required', 'requiredSecret'].includes(type)) { if (['required', 'requiredSecret'].includes(type)) {
if (typeof process.env[key] === 'undefined' || emptyString(process.env[key])) errors.push(key) if (typeof process.env[key] === 'undefined' || emptyString(process.env[key])) {
// Allow falling back to defaults for required config
if (configPath) val = get(config, configPath)
if (typeof val === 'undefined') errors.push(key)
}
if (type === 'requiredSecret') if (type === 'requiredSecret')
ok.push(`🔒 ${chalk.yellow(key)}: ` + chalk.grey('***redacted***')) ok.push(`🔒 ${chalk.yellow(key)}: ` + chalk.grey('***redacted***'))
else ok.push(`${chalk.green(key)}: ${chalk.grey(process.env[key])}`) else ok.push(`${chalk.green(key)}: ${chalk.grey(val)}`)
} else { } else {
if (typeof process.env[key] !== 'undefined' && !emptyString(process.env[key])) { if (typeof process.env[key] !== 'undefined' && !emptyString(process.env[key])) {
ok.push(`${chalk.green(key)}: ${chalk.grey(process.env[key])}`) ok.push(`${chalk.green(key)}: ${chalk.grey(process.env[key])}`)
@ -284,32 +303,54 @@ export function verifyConfig(silent = false) {
} }
if (envToBool(process.env.BACKEND_ENABLE_DUMP_CONFIG_AT_STARTUP)) { if (envToBool(process.env.BACKEND_ENABLE_DUMP_CONFIG_AT_STARTUP)) {
console.log( const dump = {
chalk.cyan.bold('Dumping configuration:\n'), ...config,
asJson( encryption: {
{ ...config.encryption,
...config, key: config.encryption.key.slice(0, 4) + '**redacted**' + config.encryption.key.slice(-4),
encryption: { },
...config.encryption, jwt: {
key: secretOrKey:
config.encryption.key.slice(0, 4) + '**redacted**' + config.encryption.key.slice(-4), config.jwt.secretOrKey.slice(0, 4) + '**redacted**' + config.jwt.secretOrKey.slice(-4),
}, },
jwt: { }
secretOrKey: if (config.sanity)
config.jwt.secretOrKey.slice(0, 4) + dump.sanity = {
'**redacted**' + ...config.sanity,
config.jwt.secretOrKey.slice(-4), token: config.sanity.token.slice(0, 4) + '**redacted**' + config.sanity.token.slice(-4),
}, }
sanity: { console.log(chalk.cyan.bold('Dumping configuration:\n'), asJson(dump, null, 2))
...config.sanity,
token: config.sanity.token.slice(0, 4) + '**redacted**' + config.sanity.token.slice(-4),
},
},
null,
2
)
)
} }
return config return config
} }
/*
* Generates a random key
*
* This is a convenience method, typically used in a scenario where people want
* to kick the tires by spinning up a Docker container running this backend.
* The backend won't start without a valid encryption key. So rather than add
* this roadblock to such users, it will auto-generate an encryption key and
* write it to disk.
*/
function randomEncryptionKey() {
const filename = 'encryption.key'
console.log(chalk.yellow('⚠️ No encryption key provided'))
let key = false
try {
console.log(chalk.dim('Checking for prior auto-generated encryption key'))
key = readFileSync(filename, 'utf-8')
} catch (err) {
console.log(chalk.dim('No prior auto-generated encryption key found.'))
}
if (key) {
console.log(chalk.green('✅ Prior encryption key found.'))
} else {
console.log(chalk.green('✅ Generating new random encryption key'))
key = randomString(64)
writeFileSync(filename, key)
}
return key
}

View file

@ -6,7 +6,9 @@ import { asJson } from './index.mjs'
/* /*
* Hashes an email address (or other string) * Hashes an email address (or other string)
*/ */
export const hash = (string) => createHash('sha256').update(string).digest('hex') export function hash(string) {
return createHash('sha256').update(string).digest('hex')
}
/* /*
* Generates a random string * Generates a random string
@ -14,7 +16,9 @@ export const hash = (string) => createHash('sha256').update(string).digest('hex'
* This is not used in anything cryptographic. It is only used as a temporary * This is not used in anything cryptographic. It is only used as a temporary
* username to avoid username collisions or to generate (long) API key secrets * username to avoid username collisions or to generate (long) API key secrets
*/ */
export const randomString = (bytes = 8) => randomBytes(bytes).toString('hex') export function randomString(bytes = 8) {
return randomBytes(bytes).toString('hex')
}
/* /*
* Returns an object holding encrypt() and decrypt() methods * Returns an object holding encrypt() and decrypt() methods
@ -23,7 +27,7 @@ export const randomString = (bytes = 8) => randomBytes(bytes).toString('hex')
* which makes things easier to read/understand for contributors, as well * which makes things easier to read/understand for contributors, as well
* as allowing scrutiny of the implementation in a single file. * as allowing scrutiny of the implementation in a single file.
*/ */
export const encryption = (stringKey, salt = 'FreeSewing') => { export function encryption(stringKey, salt = 'FreeSewing') {
// Shout-out to the OG crypto bros Joan and Vincent // Shout-out to the OG crypto bros Joan and Vincent
const algorithm = 'aes-256-cbc' const algorithm = 'aes-256-cbc'

1106
yarn.lock

File diff suppressed because it is too large Load diff