diff --git a/sites/backend/prisma/schema.prisma b/sites/backend/prisma/schema.prisma index 3dc54a7ce6d..adc0800d60d 100644 --- a/sites/backend/prisma/schema.prisma +++ b/sites/backend/prisma/schema.prisma @@ -82,6 +82,7 @@ model Pattern { model Person { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) + img String @default("https://freesewing.org/avatar.svg") name String @default("") notes String @default("") user User @relation(fields: [userId], references: [id]) diff --git a/sites/backend/src/config.mjs b/sites/backend/src/config.mjs index f0d1973bb5b..fdd0078d293 100644 --- a/sites/backend/src/config.mjs +++ b/sites/backend/src/config.mjs @@ -18,21 +18,30 @@ const envToBool = (input = 'no') => { // Construct config object const config = { + // Feature flags + use: { + github: envToBool(process.env.BACKEND_ENABLE_GITHUB), + oauth: { + github: envToBool(process.env.BACKEND_ENABLE_OAUTH_GITHUB), + google: envToBool(process.env.BACKEND_ENABLE_OAUTH_GOOGLE), + }, + sanity: envToBool(process.env.BACKEND_ENABLE_SANITY), + ses: envToBool(process.env.BACKEND_ENABLE_AWS_SES), + tests: { + base: envToBool(process.env.BACKEND_ENABLE_TESTS), + email: envToBool(process.env.BACKEND_ENABLE_TESTS_EMAIL), + sanity: envToBool(process.env.BACKEND_ENABLE_TESTS_SANITY), + }, + }, + // Config api, - port, - website: { - domain: process.env.BACKEND_WEBSITE_DOMAIN || 'freesewing.org', - scheme: process.env.BACKEND_WEBSITE_SCHEME || 'https', + apikeys: { + levels: [0, 1, 2, 3, 4, 5, 6, 7, 8], + expiryMaxSeconds: 365 * 24 * 3600, }, db: { url: process.env.BACKEND_DB_URL, }, - tests: { - allow: envToBool(process.env.BACKEND_TEST_ALLOW), - domain: process.env.BACKEND_TEST_DOMAIN || 'freesewing.dev', - sendEmail: envToBool(process.env.BACKEND_TEST_SEND_EMAIL), - includeSanity: envToBool(process.env.BACKEND_TEST_SANITY), - }, encryption: { key: process.env.BACKEND_ENC_KEY, }, @@ -42,10 +51,9 @@ const config = { audience: process.env.BACKEND_JWT_ISSUER || 'freesewing.org', expiresIn: process.env.BACKEND_JWT_EXPIRY || '7d', }, - apikeys: { - levels: [0, 1, 2, 3, 4, 5, 6, 7, 8], - expiryMaxSeconds: 365 * 24 * 3600, - }, + languages: ['en', 'de', 'es', 'fr', 'nl'], + measies: measurements, + port, roles: { levels: { user: 4, @@ -55,49 +63,21 @@ const config = { }, base: 'user', }, - languages: ['en', 'de', 'es', 'fr', 'nl'], - measies: measurements, - aws: { - ses: { - region: process.env.BACKEND_AWS_SES_REGION || 'us-east-1', - from: process.env.BACKEND_AWS_SES_FROM || 'FreeSewing ', - replyTo: process.env.BACKEND_AWS_SES_REPLY_TO - ? JSON.parse(process.env.BACKEND_AWS_SES_REPLY_TO) - : ['FreeSewing '], - feedback: process.env.BACKEND_AWS_SES_FEEDBACK, - cc: process.env.BACKEND_AWS_SES_CC ? JSON.parse(process.env.BACKEND_AWS_SES_CC) : [], - bcc: process.env.BACKEND_AWS_SES_BCC - ? JSON.parse(process.env.BACKEND_AWS_SES_BCC) - : ['FreeSewing records '], - }, + website: { + domain: process.env.BACKEND_WEBSITE_DOMAIN || 'freesewing.org', + scheme: process.env.BACKEND_WEBSITE_SCHEME || 'https', }, - sanity: { - use: process.env.BACKEND_USE_SANITY || false, - project: process.env.SANITY_PROJECT, - dataset: process.env.SANITY_DATASET || 'production', - token: process.env.SANITY_TOKEN, - version: process.env.SANITY_VERSION || 'v2022-10-31', - api: `https://${process.env.SANITY_PROJECT || 'missing-project-id'}.api.sanity.io/${ - process.env.SANITY_VERSION || 'v2022-10-31' - }`, - }, - oauth: { - github: { - clientId: process.env.BACKEND_OAUTH_GITHUB_CLIENT_ID, - clientSecret: process.env.BACKEND_OAUTH_GITHUB_CLIENT_SECRET, - tokenUri: 'https://github.com/login/oauth/access_token', - dataUri: 'https://api.github.com/user', - emailUri: 'https://api.github.com/user/emails', - }, - google: { - clientId: process.env.BACKEND_OAUTH_GOOGLE_CLIENT_ID, - clientSecret: process.env.BACKEND_OAUTH_GOOGLE_CLIENT_SECRET, - tokenUri: 'https://oauth2.googleapis.com/token', - dataUri: - 'https://people.googleapis.com/v1/people/me?personFields=emailAddresses,names,photos', - }, - }, - github: { + oauth: {}, + github: {}, +} + +/* + * Config behind feature flags + */ + +// Github config +if (config.use.github) + config.github = { token: process.env.BACKEND_GITHUB_TOKEN, api: 'https://api.github.com', bot: { @@ -125,11 +105,64 @@ const config = { }, dflt: [process.env.BACKEND_GITHUB_NOTIFY_DEFAULT_USER || 'joostdecock'], }, - }, -} + } -// Stand-alone config -export const sanity = config.sanity +// Unit test config +if (config.use.tests.base) + config.tests = { + domain: process.env.BACKEND_TEST_DOMAIN || 'freesewing.dev', + } + +// Sanity config +if (config.use.sanity) + config.sanity = { + project: process.env.SANITY_PROJECT, + dataset: process.env.SANITY_DATASET || 'production', + token: process.env.SANITY_TOKEN, + version: process.env.SANITY_VERSION || 'v2022-10-31', + api: `https://${process.env.SANITY_PROJECT || 'missing-project-id'}.api.sanity.io/${ + process.env.SANITY_VERSION || 'v2022-10-31' + }`, + } + +// AWS SES config (for sending out emails) +if (config.use.ses) + config.aws = { + ses: { + region: process.env.BACKEND_AWS_SES_REGION || 'us-east-1', + from: process.env.BACKEND_AWS_SES_FROM || 'FreeSewing ', + replyTo: process.env.BACKEND_AWS_SES_REPLY_TO + ? JSON.parse(process.env.BACKEND_AWS_SES_REPLY_TO) + : ['FreeSewing '], + feedback: process.env.BACKEND_AWS_SES_FEEDBACK, + cc: process.env.BACKEND_AWS_SES_CC ? JSON.parse(process.env.BACKEND_AWS_SES_CC) : [], + bcc: process.env.BACKEND_AWS_SES_BCC + ? JSON.parse(process.env.BACKEND_AWS_SES_BCC) + : ['FreeSewing records '], + }, + } + +// Oauth config for Github as a provider +if (config.use.oauth?.github) + config.oauth.github = { + clientId: process.env.BACKEND_OAUTH_GITHUB_CLIENT_ID, + clientSecret: process.env.BACKEND_OAUTH_GITHUB_CLIENT_SECRET, + tokenUri: 'https://github.com/login/oauth/access_token', + dataUri: 'https://api.github.com/user', + emailUri: 'https://api.github.com/user/emails', + } + +// Oauth config for Google as a provider +if (config.use.oauth?.google) + config.oauth.google = { + clientId: process.env.BACKEND_OAUTH_GOOGLE_CLIENT_ID, + clientSecret: process.env.BACKEND_OAUTH_GOOGLE_CLIENT_SECRET, + tokenUri: 'https://oauth2.googleapis.com/token', + dataUri: 'https://people.googleapis.com/v1/people/me?personFields=emailAddresses,names,photos', + } + +// Exporting this stand-alone config +export const sanity = config.sanity || {} export const website = config.website const vars = { diff --git a/sites/backend/src/models/person.mjs b/sites/backend/src/models/person.mjs index 05c1de3185a..59644217cb1 100644 --- a/sites/backend/src/models/person.mjs +++ b/sites/backend/src/models/person.mjs @@ -6,6 +6,7 @@ export function PersonModel(tools) { this.prisma = tools.prisma this.decrypt = tools.decrypt this.encrypt = tools.encrypt + this.encryptedFields = ['measies', 'img', 'name', 'notes'] this.clear = {} // For holding decrypted data return this @@ -20,23 +21,29 @@ PersonModel.prototype.create = async function ({ body, user }) { const data = { name: body.name } if (body.notes || typeof body.notes === 'string') data.notes = body.notes if (body.public === true) data.public = true - if (body.measies) data.measies = this.encrypt(this.sanitizeMeasurements(body.measies)) + if (body.measies) data.measies = this.sanitizeMeasurements(body.measies) data.userId = user.uid // Create record try { - this.record = await this.prisma.person.create({ data }) + this.record = await this.prisma.person.create({ data: this.cloak(data) }) } catch (err) { log.warn(err, 'Could not create person') return this.setResponse(500, 'createPersonFailed') } - return this.setResponse(201, 'created', { - person: { - ...this.record, - measies: this.decrypt(this.record.measies), - }, - }) + // Update img? (now that we have the ID) + const img = + this.config.use.sanity && + typeof body.img === 'string' && + (!body.unittest || (body.unittest && this.config.use.tests?.sanity)) + ? await setPersonAvatar(this.record.id, body.img) + : false + + if (img) await this.safeUpdate(this.cloak({ img: img.url })) + else await this.read({ id: this.record.id }) + + return this.setResponse(201, 'created', { person: this.asPerson() }) } /* @@ -59,29 +66,30 @@ PersonModel.prototype.read = async function (where) { /* * Helper method to decrypt at-rest data */ -//PersonModel.prototype.reveal = async function (where) { -// this.clear = {} -// if (this.record) { -// this.clear.bio = this.decrypt(this.record.bio) -// this.clear.github = this.decrypt(this.record.github) -// this.clear.email = this.decrypt(this.record.email) -// this.clear.initial = this.decrypt(this.record.initial) -// } -// -// return this -//} +PersonModel.prototype.reveal = async function () { + this.clear = {} + if (this.record) { + for (const field of this.encryptedFields) { + // Default avatar is not encrypted + if (field === 'img' && this.record.img.slice(0, 4) === 'http') + this.clear.img = this.record.img + else this.clear[field] = this.decrypt(this.record[field]) + } + } else console.log('no record') + + return this +} /* * Helper method to encrypt at-rest data */ -//UserModel.prototype.cloak = function (data) { -// for (const field of ['bio', 'github', 'email']) { -// if (typeof data[field] !== 'undefined') data[field] = this.encrypt(data[field]) -// } -// if (typeof data.password === 'string') data.password = asJson(hashPassword(data.password)) -// -// return data -//} +PersonModel.prototype.cloak = function (data) { + for (const field of this.encryptedFields) { + if (typeof data[field] !== 'undefined') data[field] = this.encrypt(data[field]) + } + + return data +} /* * Loads a user from the database based on the where clause you pass it @@ -139,24 +147,24 @@ PersonModel.prototype.setExists = function () { } /* - * Updates the user data - Used when we create the data ourselves + * Updates the person data - Used when we create the data ourselves * so we know it's safe */ -//UserModel.prototype.safeUpdate = async function (data) { -// try { -// this.record = await this.prisma.user.update({ -// where: { id: this.record.id }, -// data, -// }) -// } catch (err) { -// log.warn(err, 'Could not update user record') -// process.exit() -// return this.setResponse(500, 'updateUserFailed') -// } -// await this.reveal() -// -// return this.setResponse(200) -//} +PersonModel.prototype.safeUpdate = async function (data) { + try { + this.record = await this.prisma.person.update({ + where: { id: this.record.id }, + data, + }) + } catch (err) { + log.warn(err, 'Could not update person record') + process.exit() + return this.setResponse(500, 'updatePersonFailed') + } + await this.reveal() + + return this.setResponse(200) +} /* * Updates the user data - Used when we pass through user-provided data @@ -263,7 +271,10 @@ PersonModel.prototype.setExists = function () { * Returns record data */ PersonModel.prototype.asPerson = function () { - return this.reveal() + return { + ...this.record, + ...this.clear, + } } /* diff --git a/sites/backend/src/models/user.mjs b/sites/backend/src/models/user.mjs index 5b879a68221..08e2dc0ddc9 100644 --- a/sites/backend/src/models/user.mjs +++ b/sites/backend/src/models/user.mjs @@ -1,7 +1,7 @@ import jwt from 'jsonwebtoken' import { log } from '../utils/log.mjs' import { hash, hashPassword, randomString, verifyPassword } from '../utils/crypto.mjs' -import { setPersonAvatar } from '../utils/sanity.mjs' +import { setUserAvatar } from '../utils/sanity.mjs' import { clean, asJson, i18nUrl } from '../utils/index.mjs' import { ConfirmationModel } from './confirmation.mjs' @@ -346,7 +346,7 @@ UserModel.prototype.unsafeUpdate = async function (body) { } // Image (img) if (typeof body.img === 'string') { - const img = await setPersonAvatar(this.record.id, body.img) + const img = await setUserAvatar(this.record.id, body.img) data.img = img.url } diff --git a/sites/backend/tests/person.mjs b/sites/backend/tests/person.mjs index 746da32442c..c124f6529dc 100644 --- a/sites/backend/tests/person.mjs +++ b/sites/backend/tests/person.mjs @@ -1,3 +1,4 @@ +import { cat } from './cat.mjs' /* id Int @id @default(autoincrement()) createdAt DateTime @default(now()) @@ -20,6 +21,7 @@ export const personTests = async (chai, config, expect, store) => { neck: 420, }, public: true, + unittest: true, }, key: { name: 'Sorcha', @@ -29,6 +31,8 @@ export const personTests = async (chai, config, expect, store) => { neck: 360, }, public: false, + img: cat, + unittest: true, }, } @@ -49,12 +53,12 @@ export const personTests = async (chai, config, expect, store) => { ) .send(data[auth]) .end((err, res) => { - console.log(res.body) expect(err === null).to.equal(true) expect(res.status).to.equal(201) expect(res.body.result).to.equal(`success`) for (const [key, val] of Object.entries(data[auth])) { - expect(res.body.person[key]).to.equal(val) + if (!['measies', 'img', 'unittest'].includes(key)) + expect(res.body.person[key]).to.equal(val) } done() })