From d1f9528e7000c104642f4b9cd127fc27286f6e44 Mon Sep 17 00:00:00 2001 From: joostdecock Date: Sat, 12 Nov 2022 17:33:55 +0100 Subject: [PATCH] wip(backend): Initial work on people --- sites/backend/prisma/schema.prisma | 4 +- sites/backend/src/config.mjs | 31 ++- sites/backend/src/controllers/person.mjs | 48 ++++ sites/backend/src/middleware.mjs | 15 +- sites/backend/src/models/person.mjs | 336 +++++++++++++++++++++++ sites/backend/src/models/user.mjs | 1 - sites/backend/src/routes/index.mjs | 2 + sites/backend/src/routes/person.mjs | 41 +++ sites/backend/tests/index.mjs | 8 +- sites/backend/tests/person.mjs | 64 +++++ sites/backend/tests/shared.mjs | 3 +- 11 files changed, 532 insertions(+), 21 deletions(-) create mode 100644 sites/backend/src/controllers/person.mjs create mode 100644 sites/backend/src/models/person.mjs create mode 100644 sites/backend/src/routes/person.mjs create mode 100644 sites/backend/tests/person.mjs diff --git a/sites/backend/prisma/schema.prisma b/sites/backend/prisma/schema.prisma index 604f8755ac0..3dc54a7ce6d 100644 --- a/sites/backend/prisma/schema.prisma +++ b/sites/backend/prisma/schema.prisma @@ -82,11 +82,13 @@ model Pattern { model Person { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) - data String? + name String @default("") + notes String @default("") user User @relation(fields: [userId], references: [id]) userId Int measies String @default("{}") Pattern Pattern[] + public Boolean @default(false) @@index([userId]) } diff --git a/sites/backend/src/config.mjs b/sites/backend/src/config.mjs index 1d66bd5b1b5..f0d1973bb5b 100644 --- a/sites/backend/src/config.mjs +++ b/sites/backend/src/config.mjs @@ -2,6 +2,7 @@ import chalk from 'chalk' // Load environment variables import dotenv from 'dotenv' import { asJson } from './utils/index.mjs' +import { measurements } from '../../../config/measurements.mjs' dotenv.config() // Allow these 2 to be imported @@ -55,6 +56,7 @@ const config = { base: 'user', }, languages: ['en', 'de', 'es', 'fr', 'nl'], + measies: measurements, aws: { ses: { region: process.env.BACKEND_AWS_SES_REGION || 'us-east-1', @@ -197,7 +199,7 @@ if (envToBool(process.env.BACKEND_ENABLE_TESTS)) { * which is not a given since there's a number of environment * variables that need to be set for this backend to function. */ -export function verifyConfig() { +export function verifyConfig(silent = false) { const emptyString = (input) => { if (typeof input === 'string' && input.length > 0) return false return true @@ -219,19 +221,20 @@ export function verifyConfig() { } } - for (const o of ok) console.log(o) - - for (const e of errors) { - console.log( - chalk.redBright('Error:'), - 'Required environment variable', - chalk.redBright(e), - "is missing. The backend won't start without it.", - '\n', - chalk.yellow('See: '), - chalk.yellow.bold('https://freesewing.dev/reference/backend'), - '\n' - ) + if (!silent) { + for (const o of ok) console.log(o) + for (const e of errors) { + console.log( + chalk.redBright('Error:'), + 'Required environment variable', + chalk.redBright(e), + "is missing. The backend won't start without it.", + '\n', + chalk.yellow('See: '), + chalk.yellow.bold('https://freesewing.dev/reference/backend'), + '\n' + ) + } } if (errors.length > 0) { diff --git a/sites/backend/src/controllers/person.mjs b/sites/backend/src/controllers/person.mjs new file mode 100644 index 00000000000..611a9166565 --- /dev/null +++ b/sites/backend/src/controllers/person.mjs @@ -0,0 +1,48 @@ +import { PersonModel } from '../models/person.mjs' + +export function PersonController() {} + +/* + * Create a person for the authenticated user + * + * See: https://freesewing.dev/reference/backend/api + */ +PersonController.prototype.create = async (req, res, tools) => { + const Person = new PersonModel(tools) + await Person.create(req) + + return Person.sendResponse(res) +} + +/* + * Read a person + * + * See: https://freesewing.dev/reference/backend/api + */ +PersonController.prototype.read = async (req, res, tools) => { + //const Person = new PersonModel(tools) + //await Person.read({ id: req.params.id }) + //return Person.sendResponse(res) +} + +/* + * Update a person + * + * See: https://freesewing.dev/reference/backend/api + */ +PersonController.prototype.update = async (req, res, tools) => { + //const Person = new PersonModel(tools) + //await Person.update(req) + //return Person.sendResponse(res) +} + +/* + * Remove a person + * + * See: https://freesewing.dev/reference/backend/api + */ +PersonController.prototype.delete = async (req, res, tools) => { + //const Person = new PersonModel(tools) + //await Person.remove(req) + //return Person.sendResponse(res) +} diff --git a/sites/backend/src/middleware.mjs b/sites/backend/src/middleware.mjs index 00f3681b6f2..d6e1274abe3 100644 --- a/sites/backend/src/middleware.mjs +++ b/sites/backend/src/middleware.mjs @@ -4,6 +4,15 @@ import http from 'passport-http' import jwt from 'passport-jwt' import { ApikeyModel } from './models/apikey.mjs' +const levelFromRole = (role) => { + if (role === 'user') return 4 + if (role === 'bughunter') return 5 + if (role === 'support') return 6 + if (role === 'admin') return 8 + + return 0 +} + function loadExpressMiddleware(app) { // FIXME: Is this still needed in FreeSewing v3? //app.use(bodyParser.urlencoded({ extended: true })) @@ -27,7 +36,11 @@ function loadPassportMiddleware(passport, tools) { ...tools.config.jwt, }, (jwt_payload, done) => { - return done(null, { ...jwt_payload, uid: jwt_payload._id }) + return done(null, { + ...jwt_payload, + uid: jwt_payload._id, + level: levelFromRole(jwt_payload.role), + }) } ) ) diff --git a/sites/backend/src/models/person.mjs b/sites/backend/src/models/person.mjs new file mode 100644 index 00000000000..05c1de3185a --- /dev/null +++ b/sites/backend/src/models/person.mjs @@ -0,0 +1,336 @@ +import { log } from '../utils/log.mjs' +import { setPersonAvatar } from '../utils/sanity.mjs' + +export function PersonModel(tools) { + this.config = tools.config + this.prisma = tools.prisma + this.decrypt = tools.decrypt + this.encrypt = tools.encrypt + this.clear = {} // For holding decrypted data + + return this +} + +PersonModel.prototype.create = async function ({ body, user }) { + if (user.level < 3) return this.setResponse(403, 'insufficientAccessLevel') + if (Object.keys(body) < 1) return this.setResponse(400, 'postBodyMissing') + if (!body.name || typeof body.name !== 'string') return this.setResponse(400, 'nameMissing') + + // Prepare data + 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)) + data.userId = user.uid + + // Create record + try { + this.record = await this.prisma.person.create({ 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), + }, + }) +} + +/* + * Loads a person from the database based on the where clause you pass it + * + * Stores result in this.record + */ +PersonModel.prototype.read = async function (where) { + try { + this.record = await this.prisma.person.findUnique({ where }) + } catch (err) { + log.warn({ err, where }, 'Could not read person') + } + + this.reveal() + + return this.setExists() +} + +/* + * 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 +//} + +/* + * 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 +//} + +/* + * Loads a user from the database based on the where clause you pass it + * In addition prepares it for returning the account data + * + * Stores result in this.record + */ +//UserModel.prototype.readAsAccount = async function (where) { +// await this.read(where) +// +// return this.setResponse(200, false, { +// result: 'success', +// account: this.asAccount(), +// }) +//} + +/* + * Finds a user based on one of the accepted unique fields which are: + * - lusername (lowercase username) + * - ehash + * - id + * + * Stores result in this.record + */ +//UserModel.prototype.find = async function (body) { +// try { +// this.record = await this.prisma.user.findFirst({ +// where: { +// OR: [ +// { lusername: { equals: clean(body.username) } }, +// { ehash: { equals: hash(clean(body.username)) } }, +// { id: { equals: parseInt(body.username) || -1 } }, +// ], +// }, +// }) +// } catch (err) { +// log.warn({ err, body }, `Error while trying to find user: ${body.username}`) +// } +// +// this.reveal() +// +// return this.setExists() +//} + +/* + * Checks this.record and sets a boolean to indicate whether + * the user exists or not + * + * Stores result in this.exists + */ +PersonModel.prototype.setExists = function () { + this.exists = this.record ? true : false + + return this +} + +/* + * Updates the user 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) +//} + +/* + * Updates the user data - Used when we pass through user-provided data + * so we can't be certain it's safe + */ +//UserModel.prototype.unsafeUpdate = async function (body) { +// const data = {} +// const notes = [] +// // Bio +// if (typeof body.bio === 'string') data.bio = body.bio +// // Consent +// if ([0, 1, 2, 3].includes(body.consent)) data.consent = body.consent +// // Github +// if (typeof body.github === 'string') data.github = body.github.split('@').pop() +// // Imperial +// if ([true, false].includes(body.imperial)) data.imperial = body.imperial +// // Language +// if (this.config.languages.includes(body.language)) data.language = body.language +// // Newsletter +// if ([true, false].includes(body.newsletter)) data.newsletter = body.newsletter +// // Password +// if (typeof body.password === 'string') data.password = body.password // Will be cloaked below +// // Username +// if (typeof body.username === 'string') { +// const available = await this.isLusernameAvailable(body.username) +// if (available) { +// data.username = body.username.trim() +// data.lusername = clean(body.username) +// } else { +// log.info(`Rejected user name change from ${data.username} to ${body.username.trim()}`) +// notes.push('usernameChangeRejected') +// } +// } +// // Image (img) +// if (typeof body.img === 'string') { +// const img = await setPersonAvatar(this.record.id, body.img) +// data.img = img.url +// } +// +// // Now update the record +// await this.safeUpdate(this.cloak(data)) +// +// const isUnitTest = this.isUnitTest(body) +// if (typeof body.email === 'string' && this.clear.email !== clean(body.email)) { +// // Email change (requires confirmation) +// this.confirmation = await this.Confirmation.create({ +// type: 'emailchange', +// data: { +// language: this.record.language, +// email: { +// current: this.clear.email, +// new: body.email, +// }, +// }, +// userId: this.record.id, +// }) +// if (!isUnitTest || this.config.tests.sendEmail) { +// // Send confirmation email +// await this.mailer.send({ +// template: 'emailchange', +// language: this.record.language, +// to: body.email, +// cc: this.clear.email, +// replacements: { +// actionUrl: i18nUrl(this.language, `/confirm/emailchange/${this.Confirmation.record.id}`), +// whyUrl: i18nUrl(this.language, `/docs/faq/email/why-emailchange`), +// supportUrl: i18nUrl(this.language, `/patrons/join`), +// }, +// }) +// } +// } else if (typeof body.confirmation === 'string' && body.confirm === 'emailchange') { +// // Handle email change confirmation +// await this.Confirmation.read({ id: body.confirmation }) +// +// if (!this.Confirmation.exists) { +// log.warn(err, `Could not find confirmation id ${params.id}`) +// return this.setResponse(404, 'failedToFindConfirmationId') +// } +// +// if (this.Confirmation.record.type !== 'emailchange') { +// log.warn(err, `Confirmation mismatch; ${params.id} is not an emailchange id`) +// return this.setResponse(404, 'confirmationIdTypeMismatch') +// } +// +// const data = this.Confirmation.clear.data +// if (data.email.current === this.clear.email && typeof data.email.new === 'string') { +// await this.safeUpdate({ +// email: this.encrypt(data.email.new), +// ehash: hash(clean(data.email.new)), +// }) +// } +// } +// +// const returnData = { +// result: 'success', +// account: this.asAccount(), +// } +// if (isUnitTest) returnData.confirmation = this.Confirmation.record.id +// +// return this.setResponse(200, false, returnData) +//} + +/* + * Returns record data + */ +PersonModel.prototype.asPerson = function () { + return this.reveal() +} + +/* + * Helper method to set the response code, result, and body + * + * Will be used by this.sendResponse() + */ +PersonModel.prototype.setResponse = function (status = 200, error = false, data = {}) { + this.response = { + status, + body: { + result: 'success', + ...data, + }, + } + if (status > 201) { + this.response.body.error = error + this.response.body.result = 'error' + this.error = true + } else this.error = false + + return this.setExists() +} + +/* + * Helper method to send response + */ +PersonModel.prototype.sendResponse = async function (res) { + return res.status(this.response.status).send(this.response.body) +} + +/* + * Update method to determine whether this request is + * part of a unit test + */ +//UserModel.prototype.isUnitTest = function (body) { +// if (!body.unittest) return false +// if (!this.clear.email.split('@').pop() === this.config.tests.domain) return false +// if (body.email && !body.email.split('@').pop() === this.config.tests.domain) return false +// +// return true +//} + +/* + * Helper method to check an account is ok + */ +//UserModel.prototype.isOk = function () { +// if ( +// this.exists && +// this.record && +// this.record.status > 0 && +// this.record.consent > 0 && +// this.record.role && +// this.record.role !== 'blocked' +// ) +// return true +// +// return false +//} + +/* Helper method to parse user-supplied measurements */ +PersonModel.prototype.sanitizeMeasurements = function (input) { + const measies = {} + if (typeof input !== 'object') return measies + for (const [m, val] of Object.entries(input)) { + if (this.config.measies.includes(m) && typeof val === 'number' && val > 0) measies[m] = val + } + + return measies +} diff --git a/sites/backend/src/models/user.mjs b/sites/backend/src/models/user.mjs index 781147de300..5b879a68221 100644 --- a/sites/backend/src/models/user.mjs +++ b/sites/backend/src/models/user.mjs @@ -367,7 +367,6 @@ UserModel.prototype.unsafeUpdate = async function (body) { }, userId: this.record.id, }) - console.log(this.config.tests) if (!isUnitTest || this.config.tests.sendEmail) { // Send confirmation email await this.mailer.send({ diff --git a/sites/backend/src/routes/index.mjs b/sites/backend/src/routes/index.mjs index b7723d0e4db..1c4a70abc9e 100644 --- a/sites/backend/src/routes/index.mjs +++ b/sites/backend/src/routes/index.mjs @@ -1,7 +1,9 @@ import { apikeyRoutes } from './apikey.mjs' import { userRoutes } from './user.mjs' +import { personRoutes } from './person.mjs' export const routes = { apikeyRoutes, userRoutes, + personRoutes, } diff --git a/sites/backend/src/routes/person.mjs b/sites/backend/src/routes/person.mjs new file mode 100644 index 00000000000..ba4e8ad5d20 --- /dev/null +++ b/sites/backend/src/routes/person.mjs @@ -0,0 +1,41 @@ +import { PersonController } from '../controllers/person.mjs' + +const Person = new PersonController() +const jwt = ['jwt', { session: false }] +const bsc = ['basic', { session: false }] + +export function personRoutes(tools) { + const { app, passport } = tools + + // Create person + app.post('/people/jwt', passport.authenticate(...jwt), (req, res) => + Person.create(req, res, tools) + ) + app.post('/people/key', passport.authenticate(...bsc), (req, res) => + Person.create(req, res, tools) + ) + + // Read person + app.get('/people/:handle/jwt', passport.authenticate(...jwt), (req, res) => + Person.read(req, res, tools) + ) + app.get('/people/:handle/jwt', passport.authenticate(...bsc), (req, res) => + Person.read(req, res, tools) + ) + + // Update person + app.put('/people/:handle/jwt', passport.authenticate(...jwt), (req, res) => + Person.update(req, res, tools) + ) + app.put('/people/:handle/key', passport.authenticate(...bsc), (req, res) => + Person.update(req, res, tools) + ) + + // Delete person + app.delete('/people/:handle/jwt', passport.authenticate(...jwt), (req, res) => + Person.delete(req, res, tools) + ) + app.delete('/people/:handle/key', passport.authenticate(...bsc), (req, res) => + Person.delete(req, res, tools) + ) +} diff --git a/sites/backend/tests/index.mjs b/sites/backend/tests/index.mjs index a7ec91441ee..9cef6931d35 100644 --- a/sites/backend/tests/index.mjs +++ b/sites/backend/tests/index.mjs @@ -1,12 +1,14 @@ import { userTests } from './user.mjs' import { accountTests } from './account.mjs' import { apikeyTests } from './apikey.mjs' +import { personTests } from './person.mjs' import { setup } from './shared.mjs' const runTests = async (...params) => { - await userTests(...params) - await apikeyTests(...params) - await accountTests(...params) + //await userTests(...params) + //await apikeyTests(...params) + //await accountTests(...params) + await personTests(...params) } // Load initial data required for tests diff --git a/sites/backend/tests/person.mjs b/sites/backend/tests/person.mjs new file mode 100644 index 00000000000..746da32442c --- /dev/null +++ b/sites/backend/tests/person.mjs @@ -0,0 +1,64 @@ +/* + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + name String @default("") + notes String @default("") + user User @relation(fields: [userId], references: [id]) + userId Int + measies String @default("{}") + Pattern Pattern[] + public Boolean @default(false) +*/ + +export const personTests = async (chai, config, expect, store) => { + const data = { + jwt: { + name: 'Joost', + notes: 'These are them notes', + measies: { + chest: 1000, + neck: 420, + }, + public: true, + }, + key: { + name: 'Sorcha', + notes: 'These are also notes', + measies: { + chest: 930, + neck: 360, + }, + public: false, + }, + } + + for (const auth of ['jwt', 'key']) { + describe(`${store.icon('person', auth)} Person tests (${auth})`, () => { + it(`${store.icon('person', auth)} Should create a new person (${auth})`, (done) => { + chai + .request(config.api) + .post(`/people/${auth}`) + .set( + 'Authorization', + auth === 'jwt' + ? 'Bearer ' + store.account.token + : 'Basic ' + + new Buffer(`${store.account.apikey.key}:${store.account.apikey.secret}`).toString( + 'base64' + ) + ) + .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) + } + done() + }) + }) + }) + } +} diff --git a/sites/backend/tests/shared.mjs b/sites/backend/tests/shared.mjs index ef4de6c1cf0..21ddb7b3184 100644 --- a/sites/backend/tests/shared.mjs +++ b/sites/backend/tests/shared.mjs @@ -7,7 +7,7 @@ import { randomString } from '../src/utils/crypto.mjs' dotenv.config() -const config = verifyConfig() +const config = verifyConfig(true) const expect = chai.expect chai.use(http) @@ -26,6 +26,7 @@ export const setup = async () => { user: '🧑 ', jwt: '🎫 ', key: '🎟️ ', + person: '🧕 ', }, randomString, }