1
0
Fork 0

wip(backend): Initial work on people

This commit is contained in:
joostdecock 2022-11-12 17:33:55 +01:00
parent 75841ff0a2
commit d1f9528e70
11 changed files with 532 additions and 21 deletions

View file

@ -82,11 +82,13 @@ model Pattern {
model Person { model Person {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
data String? name String @default("")
notes String @default("")
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
userId Int userId Int
measies String @default("{}") measies String @default("{}")
Pattern Pattern[] Pattern Pattern[]
public Boolean @default(false)
@@index([userId]) @@index([userId])
} }

View file

@ -2,6 +2,7 @@ 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 { measurements } from '../../../config/measurements.mjs'
dotenv.config() dotenv.config()
// Allow these 2 to be imported // Allow these 2 to be imported
@ -55,6 +56,7 @@ const config = {
base: 'user', base: 'user',
}, },
languages: ['en', 'de', 'es', 'fr', 'nl'], languages: ['en', 'de', 'es', 'fr', 'nl'],
measies: measurements,
aws: { aws: {
ses: { ses: {
region: process.env.BACKEND_AWS_SES_REGION || 'us-east-1', 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 * which is not a given since there's a number of environment
* variables that need to be set for this backend to function. * variables that need to be set for this backend to function.
*/ */
export function verifyConfig() { export function verifyConfig(silent = false) {
const emptyString = (input) => { const emptyString = (input) => {
if (typeof input === 'string' && input.length > 0) return false if (typeof input === 'string' && input.length > 0) return false
return true return true
@ -219,19 +221,20 @@ export function verifyConfig() {
} }
} }
for (const o of ok) console.log(o) if (!silent) {
for (const o of ok) console.log(o)
for (const e of errors) { for (const e of errors) {
console.log( console.log(
chalk.redBright('Error:'), chalk.redBright('Error:'),
'Required environment variable', 'Required environment variable',
chalk.redBright(e), chalk.redBright(e),
"is missing. The backend won't start without it.", "is missing. The backend won't start without it.",
'\n', '\n',
chalk.yellow('See: '), chalk.yellow('See: '),
chalk.yellow.bold('https://freesewing.dev/reference/backend'), chalk.yellow.bold('https://freesewing.dev/reference/backend'),
'\n' '\n'
) )
}
} }
if (errors.length > 0) { if (errors.length > 0) {

View file

@ -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)
}

View file

@ -4,6 +4,15 @@ import http from 'passport-http'
import jwt from 'passport-jwt' import jwt from 'passport-jwt'
import { ApikeyModel } from './models/apikey.mjs' 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) { function loadExpressMiddleware(app) {
// FIXME: Is this still needed in FreeSewing v3? // FIXME: Is this still needed in FreeSewing v3?
//app.use(bodyParser.urlencoded({ extended: true })) //app.use(bodyParser.urlencoded({ extended: true }))
@ -27,7 +36,11 @@ function loadPassportMiddleware(passport, tools) {
...tools.config.jwt, ...tools.config.jwt,
}, },
(jwt_payload, done) => { (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),
})
} }
) )
) )

View file

@ -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
}

View file

@ -367,7 +367,6 @@ UserModel.prototype.unsafeUpdate = async function (body) {
}, },
userId: this.record.id, userId: this.record.id,
}) })
console.log(this.config.tests)
if (!isUnitTest || this.config.tests.sendEmail) { if (!isUnitTest || this.config.tests.sendEmail) {
// Send confirmation email // Send confirmation email
await this.mailer.send({ await this.mailer.send({

View file

@ -1,7 +1,9 @@
import { apikeyRoutes } from './apikey.mjs' import { apikeyRoutes } from './apikey.mjs'
import { userRoutes } from './user.mjs' import { userRoutes } from './user.mjs'
import { personRoutes } from './person.mjs'
export const routes = { export const routes = {
apikeyRoutes, apikeyRoutes,
userRoutes, userRoutes,
personRoutes,
} }

View file

@ -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)
)
}

View file

@ -1,12 +1,14 @@
import { userTests } from './user.mjs' import { userTests } from './user.mjs'
import { accountTests } from './account.mjs' import { accountTests } from './account.mjs'
import { apikeyTests } from './apikey.mjs' import { apikeyTests } from './apikey.mjs'
import { personTests } from './person.mjs'
import { setup } from './shared.mjs' import { setup } from './shared.mjs'
const runTests = async (...params) => { const runTests = async (...params) => {
await userTests(...params) //await userTests(...params)
await apikeyTests(...params) //await apikeyTests(...params)
await accountTests(...params) //await accountTests(...params)
await personTests(...params)
} }
// Load initial data required for tests // Load initial data required for tests

View file

@ -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()
})
})
})
}
}

View file

@ -7,7 +7,7 @@ import { randomString } from '../src/utils/crypto.mjs'
dotenv.config() dotenv.config()
const config = verifyConfig() const config = verifyConfig(true)
const expect = chai.expect const expect = chai.expect
chai.use(http) chai.use(http)
@ -26,6 +26,7 @@ export const setup = async () => {
user: '🧑 ', user: '🧑 ',
jwt: '🎫 ', jwt: '🎫 ',
key: '🎟️ ', key: '🎟️ ',
person: '🧕 ',
}, },
randomString, randomString,
} }