1
0
Fork 0

feat(backend): Refactor and optimize

This commit is contained in:
joostdecock 2023-08-13 09:39:05 +02:00
parent 30d48f1c07
commit b05e1e20cf
23 changed files with 2345 additions and 1566 deletions

View file

@ -10,26 +10,41 @@ import { postConfig } from '../local-config.mjs'
import { roles } from '../../../config/roles.mjs' import { roles } from '../../../config/roles.mjs'
dotenv.config() dotenv.config()
// Allow these 2 to be imported /*
* Make this easy to update
*/
const languages = ['en', 'de', 'es', 'fr', 'nl', 'uk']
/*
* 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 /*
* Generate/Check encryption key only once
*/
const encryptionKey = process.env.BACKEND_ENC_KEY const encryptionKey = process.env.BACKEND_ENC_KEY
? process.env.BACKEND_ENC_KEY ? process.env.BACKEND_ENC_KEY
: randomEncryptionKey() : randomEncryptionKey()
// All environment variables are strings /*
// This is a helper method to turn them into a boolean * All environment variables are strings
* This is a helper method to turn them into a boolean
*/
const envToBool = (input = 'no') => { const envToBool = (input = 'no') => {
if (['yes', '1', 'true'].includes(input.toLowerCase())) return true if (['yes', '1', 'true'].includes(input.toLowerCase())) return true
return false return false
} }
// Save ourselves some typing /*
* Save ourselves some typing
*/
const crowdinProject = 'https://translate.freesewing.org/project/freesewing/' const crowdinProject = 'https://translate.freesewing.org/project/freesewing/'
// Construct config object /*
* Construct config object
*/
const baseConfig = { const baseConfig = {
// Environment // Environment
env: process.env.NODE_ENV || 'development', env: process.env.NODE_ENV || 'development',
@ -69,6 +84,16 @@ const baseConfig = {
encryption: { encryption: {
key: encryptionKey, key: encryptionKey,
}, },
enums: {
user: {
consent: [0, 1, 2, 3],
control: [1, 2, 3, 4, 5],
language: languages,
compare: [true, false],
imperial: [true, false],
newsletter: [true, false],
},
},
github: { github: {
token: process.env.BACKEND_GITHUB_TOKEN, token: process.env.BACKEND_GITHUB_TOKEN,
}, },
@ -87,8 +112,8 @@ const baseConfig = {
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',
}, },
languages: ['en', 'de', 'es', 'fr', 'nl', 'uk'], languages,
translations: ['de', 'es', 'fr', 'nl', 'uk'], translations: languages.filter((lang) => lang !== 'en'),
measies: measurements, measies: measurements,
mfa: { mfa: {
service: process.env.BACKEND_MFA_SERVICE || 'FreeSewing', service: process.env.BACKEND_MFA_SERVICE || 'FreeSewing',
@ -150,6 +175,7 @@ if (baseConfig.use.cloudflareImages) {
api: `https://api.cloudflare.com/client/v4/accounts/${account}/images/v1`, api: `https://api.cloudflare.com/client/v4/accounts/${account}/images/v1`,
token: process.env.BACKEND_CLOUDFLARE_IMAGES_TOKEN || 'fixmeSetCloudflareToken', token: process.env.BACKEND_CLOUDFLARE_IMAGES_TOKEN || 'fixmeSetCloudflareToken',
import: envToBool(process.env.BACKEND_IMPORT_CLOUDFLARE_IMAGES), import: envToBool(process.env.BACKEND_IMPORT_CLOUDFLARE_IMAGES),
useInTests: baseConfig.use.tests.cloudflareImages,
} }
} }
@ -195,6 +221,7 @@ const config = postConfig(baseConfig)
// Exporting this stand-alone config // Exporting this stand-alone config
export const cloudflareImages = config.cloudflareImages || {} export const cloudflareImages = config.cloudflareImages || {}
export const website = config.website export const website = config.website
export const githubToken = config.github.token
const vars = { const vars = {
BACKEND_DB_URL: ['required', 'db.url'], BACKEND_DB_URL: ['required', 'db.url'],
@ -235,10 +262,10 @@ if (envToBool(process.env.BACKEND_USE_CLOUDFLARE_IMAGES)) {
// Vars for Github integration // Vars for Github integration
if (envToBool(process.env.BACKEND_ENABLE_GITHUB)) { if (envToBool(process.env.BACKEND_ENABLE_GITHUB)) {
vars.BACKEND_GITHUB_TOKEN = 'requiredSecret' vars.BACKEND_GITHUB_TOKEN = 'requiredSecret'
vars.BACKEND_GITHUB_USER = 'required' vars.BACKEND_GITHUB_USER = 'optional'
vars.BACKEND_GITHUB_USER_NAME = 'required' vars.BACKEND_GITHUB_USER_NAME = 'optional'
vars.BACKEND_GITHUB_USER_EMAIL = 'required' vars.BACKEND_GITHUB_USER_EMAIL = 'optional'
vars.BACKEND_GITHUB_NOTIFY_DEFAULT_USER = 'required' vars.BACKEND_GITHUB_NOTIFY_DEFAULT_USER = 'optional'
} }
// Vars for Oauth via Github integration // Vars for Oauth via Github integration
if (envToBool(process.env.BACKEND_ENABLE_OAUTH_GITHUB)) { if (envToBool(process.env.BACKEND_ENABLE_OAUTH_GITHUB)) {

View file

@ -67,7 +67,7 @@ ApikeysController.prototype.whoami = async (req, res, tools) => {
key: key[0].id, key: key[0].id,
level: key[0].level, level: key[0].level,
expiresAt: key[0].expiresAt, expiresAt: key[0].expiresAt,
name: key[0].name, name: Apikey.decrypt(key[0].name),
userId: key[0].userId, userId: key[0].userId,
}, },
}) })

View file

@ -1,166 +1,289 @@
import { log } from '../utils/log.mjs' import { log } from '../utils/log.mjs'
import { hashPassword, randomString, verifyPassword } from '../utils/crypto.mjs' import { hashPassword, randomString, verifyPassword } from '../utils/crypto.mjs'
import { asJson } from '../utils/index.mjs' import { asJson } from '../utils/index.mjs'
import { UserModel } from './user.mjs' import { decorateModel } from '../utils/model-decorator.mjs'
/*
* This model handles all apikey updates
*
* @param {tools} object - A set of tools loaded in src/index.js
* @returns {ApikeyModel} object - The ApikeyModel
*/
export function ApikeyModel(tools) { export function ApikeyModel(tools) {
this.config = tools.config /*
this.prisma = tools.prisma * See utils/model-decorator.mjs for details
this.rbac = tools.rbac */
this.User = new UserModel(tools) return decorateModel(this, tools, {
name: 'apikey',
return this encryptedFields: ['name'],
} models: ['user'],
})
ApikeyModel.prototype.setExists = function () {
this.exists = this.record ? true : false
return this
}
ApikeyModel.prototype.setResponse = function (status = 200, error = false, data = {}) {
this.response = {
status,
body: {
result: 'success',
...data,
},
}
if (status === 201) this.response.body.result = 'created'
else if (status > 204) {
this.response.body.error = error
this.response.body.result = 'error'
this.error = true
} else this.error = false
return this.setExists()
}
ApikeyModel.prototype.sendResponse = async function (res) {
return res.status(this.response.status).send(this.response.body)
} }
/*
* Verifies an API key and secret
* Will set this.verified to true or false before returning the model
*
* @param {key} string - The API key
* @param {secret} string - The API secret
* @returns {ApikeyModel} object - The ApikeyModel
*/
ApikeyModel.prototype.verify = async function (key, secret) { ApikeyModel.prototype.verify = async function (key, secret) {
await this.unguardedRead({ id: key }) /*
* Attempt to read the record from the database
*/
await this.read({ id: key })
/*
* Apikey secret is just like a password, and we verify it the same way
*/
const [valid] = verifyPassword(secret, this.record.secret) const [valid] = verifyPassword(secret, this.record.secret)
/*
* Store result in the verified property
*/
this.verified = valid this.verified = valid
return this return this
} }
/*
* Reads an API key
* This is guarded so it enforces access control and validates input
*
* @param {params} object - The request (url) parameters
* @param {user} object - The user as loaded by the authentication middleware
* @returns {ApikeyModel} object - The ApikeyModel
*/
ApikeyModel.prototype.guardedRead = async function ({ params, user }) { ApikeyModel.prototype.guardedRead = async function ({ params, user }) {
/*
* Enforece RBAC
*/
if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel') if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
/*
* Ensure the account is active
*/
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
await this.unguardedRead({ id: params.id }) /*
* Attempt to read record from database
*/
await this.read({ id: params.id })
/*
* If it's not found, return 404
*/
if (!this.record) return this.setResponse(404) if (!this.record) return this.setResponse(404)
if (this.record.userId !== user.uid) { /*
// Not own key - only admin can do that * Only admins can read other users
if (!this.rbac.admin(user)) return this.setResponse(403, 'insufficientAccessLevel') */
if (this.record.userId !== user.uid && !this.rbac.admin(user)) {
return this.setResponse(403, 'insufficientAccessLevel')
} }
return this.setResponse(200, 'success', { /*
* Decrypt data that is encrypted at rest
*/
await this.reveal()
return this.setResponse200({
apikey: { apikey: {
key: this.record.id, key: this.record.id,
level: this.record.level, level: this.record.level,
createdAt: this.record.createdAt, createdAt: this.record.createdAt,
expiresAt: this.record.expiresAt, expiresAt: this.record.expiresAt,
name: this.record.name, name: this.clear.name,
userId: this.record.userId, userId: this.record.userId,
}, },
}) })
} }
/*
* Deletes an API key
* This is guarded so it enforces access control and validates input
*
* @param {params} object - The request (url) parameters
* @param {user} object - The user as loaded by the authentication middleware
* @returns {ApikeyModel} object - The ApikeyModel
*/
ApikeyModel.prototype.guardedDelete = async function ({ params, user }) { ApikeyModel.prototype.guardedDelete = async function ({ params, user }) {
/*
* Enforece RBAC
*/
if (!this.rbac.user(user)) return this.setResponse(403, 'insufficientAccessLevel') if (!this.rbac.user(user)) return this.setResponse(403, 'insufficientAccessLevel')
/*
* Ensure the account is active
*/
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
await this.unguardedRead({ id: params.id }) /*
* Attempt to read record from database
*/
await this.read({ id: params.id })
/*
* If it's not found, return 404
*/
if (!this.record) return this.setResponse(404) if (!this.record) return this.setResponse(404)
if (this.record.userId !== user.uid) { /*
// Not own key - only admin can do that * Only admins can delete other users
if (!this.rbac.admin(user)) return this.setResponse(403, 'insufficientAccessLevel') */
if (this.record.userId !== user.uid && !this.rbac.admin(user)) {
return this.setResponse(403, 'insufficientAccessLevel')
} }
await this.unguardedDelete() /*
* Delete record from the database
*/
await this.delete()
return this.setResponse(204) return this.setResponse(204)
} }
ApikeyModel.prototype.unguardedRead = async function (where) { /*
this.record = await this.prisma.apikey.findUnique({ where }) * Returns all API keys for a user with uid
*
return this * @param {uid} string - The uid of the user
} * Note that the uid is the ID, but we user uid when it comes from middleware
* @returns {keys} array - An array of Apikeys
ApikeyModel.prototype.unguardedDelete = async function () { */
await this.prisma.apikey.delete({ where: { id: this.record.id } })
this.record = null
this.clear = null
return this.setExists()
}
ApikeyModel.prototype.userApikeys = async function (uid) { ApikeyModel.prototype.userApikeys = async function (uid) {
/*
* Guard against missing input
*/
if (!uid) return false if (!uid) return false
/*
* Wrap async code with try ... catch
*/
let keys let keys
try { try {
/*
* Attempt to read records from database
*/
keys = await this.prisma.apikey.findMany({ where: { userId: uid } }) keys = await this.prisma.apikey.findMany({ where: { userId: uid } })
} catch (err) { } catch (err) {
/*
* Something went wrong, log a warning and return 404
*/
log.warn(`Failed to search apikeys for user ${uid}: ${err}`) log.warn(`Failed to search apikeys for user ${uid}: ${err}`)
return this.setResponse(404)
} }
/*
* Keys are an array, remove sercrets with map() and decrypt prior to returning
*/
return keys.map((key) => { return keys.map((key) => {
delete key.secret delete key.secret
key.name = this.decrypt(key.name)
return key return key
}) })
} }
/*
* Creates an Apikey
*
* @param {body} object - The request body
* @param {user} object - The user as loaded by the authentication middleware
* @returns {ApikeyModel} object - The ApikeyModel
*/
ApikeyModel.prototype.create = async function ({ body, user }) { ApikeyModel.prototype.create = async function ({ body, user }) {
/*
* Do we have a POST body?
*/
if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing') if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing')
/*
* Is the name set?
*/
if (!body.name) return this.setResponse(400, 'nameMissing') if (!body.name) return this.setResponse(400, 'nameMissing')
/*
* Is the level set?
*/
if (!body.level) return this.setResponse(400, 'levelMissing') if (!body.level) return this.setResponse(400, 'levelMissing')
/*
* Is level numeric?
*/
if (typeof body.level !== 'number') return this.setResponse(400, 'levelNotNumeric') if (typeof body.level !== 'number') return this.setResponse(400, 'levelNotNumeric')
/*
* Is level a known/valid level?
*/
if (!this.config.apikeys.levels.includes(body.level)) return this.setResponse(400, 'invalidLevel') if (!this.config.apikeys.levels.includes(body.level)) return this.setResponse(400, 'invalidLevel')
/*
* Is expiresIn set?
*/
if (!body.expiresIn) return this.setResponse(400, 'expiresInMissing') if (!body.expiresIn) return this.setResponse(400, 'expiresInMissing')
/*
* Is expiresIn numberic?
*/
if (typeof body.expiresIn !== 'number') return this.setResponse(400, 'expiresIsNotNumeric') if (typeof body.expiresIn !== 'number') return this.setResponse(400, 'expiresIsNotNumeric')
/*
* Is expiresIn above the maximum?
*/
if (body.expiresIn > this.config.apikeys.maxExpirySeconds) if (body.expiresIn > this.config.apikeys.maxExpirySeconds)
return this.setResponse(400, 'expiresIsHigherThanMaximum') return this.setResponse(400, 'expiresIsHigherThanMaximum')
// Load user making the call /*
* Load authenticated user from the database
*/
await this.User.loadAuthenticatedUser(user) await this.User.loadAuthenticatedUser(user)
if (body.level > this.config.roles.levels[this.User.authenticatedUser.role])
return this.setResponse(400, 'keyLevelExceedsRoleLevel')
// Generate api secret /*
* Is the user allowed to create a key of this level?
*/
if (body.level > this.config.roles.levels[this.User.authenticatedUser.role]) {
return this.setResponse(400, 'keyLevelExceedsRoleLevel')
}
/*
* Generate the apikey secret
*/
const secret = randomString(32) const secret = randomString(32)
/*
* Calculate expiry date
*/
const expiresAt = new Date(Date.now() + body.expiresIn * 1000) const expiresAt = new Date(Date.now() + body.expiresIn * 1000)
/*
* Attempt to create the record in the database
*/
try { try {
this.record = await this.prisma.apikey.create({ this.record = await this.prisma.apikey.create({
data: { data: this.cloak({
expiresAt, expiresAt,
name: body.name, name: body.name,
level: body.level, level: body.level,
secret: asJson(hashPassword(secret)), secret: asJson(hashPassword(secret)),
userId: user.uid, userId: user.uid,
}, }),
}) })
} catch (err) { } catch (err) {
/*
* That did not work. Log and error and return 500
*/
log.warn(err, 'Could not create apikey') log.warn(err, 'Could not create apikey')
return this.setResponse(500, 'createApikeyFailed') return this.setResponse(500, 'createApikeyFailed')
} }
return this.setResponse(201, 'created', { return this.setResponse201({
apikey: { apikey: {
key: this.record.id, key: this.record.id,
secret, secret,
level: this.record.level, level: this.record.level,
createdAt: this.record.createdAt, createdAt: this.record.createdAt,
expiresAt: this.record.expiresAt, expiresAt: this.record.expiresAt,
name: this.record.name, name: body.name,
userId: this.record.userId, userId: this.record.userId,
}, },
}) })

View file

@ -1,112 +1,58 @@
import { log } from '../utils/log.mjs' import { log } from '../utils/log.mjs'
import { decorateModel } from '../utils/model-decorator.mjs'
/*
* This model handles all confirmation updates
*
* @param {tools} object - A set of tools loaded in src/index.js
* @returns {ConfirmationModel} object - The ConfirmationModel
*/
export function ConfirmationModel(tools) { export function ConfirmationModel(tools) {
this.config = tools.config /*
this.prisma = tools.prisma * See utils/model-decorator.mjs for details
this.decrypt = tools.decrypt */
this.encrypt = tools.encrypt return decorateModel(this, tools, {
this.encryptedFields = ['data'] name: 'confirmation',
this.clear = {} // For holding decrypted data encryptedFields: ['data'],
return this
}
ConfirmationModel.prototype.unguardedRead = async function (where) {
this.record = await this.prisma.confirmation.findUnique({ where })
await this.reveal()
return this
}
ConfirmationModel.prototype.read = async function (where) {
this.record = await this.prisma.confirmation.findUnique({
where,
include: {
user: true,
},
}) })
this.clear.data = this.record?.data ? this.decrypt(this.record.data) : {}
return this.setExists()
}
ConfirmationModel.prototype.setExists = function () {
this.exists = this.record ? true : false
return this
}
ConfirmationModel.prototype.setResponse = function (status = 200, error = false, data = {}) {
this.response = {
status,
body: {
result: 'success',
...data,
},
}
if (status === 201) this.response.body.result = 'created'
else if (status > 204) {
this.response.body.error = error
this.response.body.result = 'error'
this.error = true
} else this.error = false
return this.setExists()
}
ConfirmationModel.prototype.sendResponse = async function (res) {
return res.status(this.response.status).send(this.response.body)
}
ConfirmationModel.prototype.guardedRead = async function ({ params }) {
if (typeof params.id === 'undefined') return this.setResponse(404)
if (typeof params.check === 'undefined') return this.setResponse(404)
await this.unguardedRead({ id: params.id })
if (!this.record) return this.setResponse(404)
if (this.clear.data.check === params.check)
return this.setResponse(200, 'success', {
confirmation: {
id: this.record.id,
check: this.clear.data.check,
},
})
return this.setResponse(404)
}
ConfirmationModel.prototype.create = async function (data = {}) {
try {
this.record = await this.prisma.confirmation.create({
data: { ...data, data: this.encrypt(data.data) },
})
} catch (err) {
log.warn(err, 'Could not create confirmation record')
return this.setResponse(500, 'createConfirmationFailed')
}
log.info({ confirmation: this.record.id }, 'Confirmation created')
return this.setResponse(201)
}
ConfirmationModel.prototype.unguardedDelete = async function () {
await this.prisma.confirmation.delete({ where: { id: this.record.id } })
this.record = null
this.clear = null
return this.setExists()
} }
/* /*
* Helper method to decrypt at-rest data * Reads a confirmation - Anonymous route
*
* @param {params} object - The request (url) parameters
* @returns {ConfirmationModel} object - The ConfirmationModel
*/ */
ConfirmationModel.prototype.reveal = async function () { ConfirmationModel.prototype.guardedRead = async function ({ params }) {
this.clear = {} /*
if (this.record) { * Is the id set?
for (const field of this.encryptedFields) { */
this.clear[field] = this.decrypt(this.record[field]) if (typeof params.id === 'undefined') return this.setResponse(404)
}
} /*
return this * Is the check set?
*/
if (typeof params.check === 'undefined') return this.setResponse(404)
/*
* Attempt to read record from the database
*/
await this.read({ id: params.id })
/*
* Does it exist?
*/
if (!this.record) return this.setResponse(404)
/*
* Return data only if the check matches
*/
return this.clear.data.check === params.check
? this.setResponse200({
confirmation: {
id: this.record.id,
check: this.clear.data.check,
},
})
: this.setResponse(404)
} }

View file

@ -2,21 +2,43 @@ import { capitalize } from '../utils/index.mjs'
import { log } from '../utils/log.mjs' import { log } from '../utils/log.mjs'
import { storeImage } from '../utils/cloudflare-images.mjs' import { storeImage } from '../utils/cloudflare-images.mjs'
import yaml from 'js-yaml' import yaml from 'js-yaml'
import { decorateModel } from '../utils/model-decorator.mjs'
/*
* This model handles all curated set updates
*/
export function CuratedSetModel(tools) { export function CuratedSetModel(tools) {
this.config = tools.config return decorateModel(this, tools, { name: 'curatedSet' })
this.prisma = tools.prisma
this.rbac = tools.rbac
return this
} }
/*
* Creates a curated set
*
* @param {body} object - The request body
* @returns {CuratedSetModel} object - The CureatedSetModel
*/
CuratedSetModel.prototype.guardedCreate = async function ({ body, user }) { CuratedSetModel.prototype.guardedCreate = async function ({ body, user }) {
/*
* Enforce RBAC
*/
if (!this.rbac.curator(user)) return this.setResponse(403, 'insufficientAccessLevel') if (!this.rbac.curator(user)) return this.setResponse(403, 'insufficientAccessLevel')
/*
* Do we have a POST body?
*/
if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing') if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing')
/*
* Is nameEn set?
*/
if (!body.nameEn || typeof body.nameEn !== 'string') return this.setResponse(400, 'nameEnMissing') if (!body.nameEn || typeof body.nameEn !== 'string') return this.setResponse(400, 'nameEnMissing')
// Prepare data /*
* Prepare data to create the record
* A curated set is special in that it has a name and notes like a regular set, but it
* has those in every supported languages (eg nameEn and notesEn)
* So we need to iterated over languages + fields
*/
const data = {} const data = {}
for (const lang of this.config.languages) { for (const lang of this.config.languages) {
for (const field of ['name', 'notes']) { for (const field of ['name', 'notes']) {
@ -26,105 +48,101 @@ CuratedSetModel.prototype.guardedCreate = async function ({ body, user }) {
const key = 'tags' + capitalize(lang) const key = 'tags' + capitalize(lang)
if (body[key] && Array.isArray(body[key])) data[key] = body[key] if (body[key] && Array.isArray(body[key])) data[key] = body[key]
} }
/*
* Add the (sanitized) measurements if there are any
*/
if (body.measies) data.measies = this.sanitizeMeasurements(body.measies) if (body.measies) data.measies = this.sanitizeMeasurements(body.measies)
else data.measies = {} else data.measies = {}
// Set this one initially as we need the ID to store an image on cloudflare
/*
* Set an initial img as we need the record ID to store an image on cloudflare
*/
data.img = this.config.avatars.set data.img = this.config.avatars.set
// Create record /*
await this.unguardedCreate(data) * Create the database record
*/
await this.createRecord(data)
// Update img? (now that we have the ID) /*
const img = * Now that we have a record and ID, we can update the image after uploading it to cloudflare
this.config.use.cloudflareImages && */
typeof body.img === 'string' && const img = await storeImage(
(!body.test || (body.test && this.config.use.tests?.cloudflareImages)) {
? await storeImage({ id: `cset-${this.record.id}`,
id: `cset-${this.record.id}`, metadata: { user: user.uid },
metadata: { user: user.uid }, b64: body.img,
b64: body.img, },
}) this.isTest(body)
: false )
if (img) await this.unguardedUpdate({ img: img.url }) /*
else await this.read({ id: this.record.id }) * If an image was uploaded, update the record with the image ID
*/
if (img) await this.update({ img: img.url })
/*
* If not, just read the record from the datbasa
*/ else await this.read({ id: this.record.id })
return this.setResponse(201, 'created', { curatedSet: this.asCuratedSet() }) /*
} * Record created, return data in the proper format
*/
CuratedSetModel.prototype.unguardedCreate = async function (data) { return this.setResponse201({ curatedSet: this.asCuratedSet() })
// FIXME: See https://github.com/prisma/prisma/issues/3786
if (data.measies && typeof data.measies === 'object') data.measies = JSON.stringify(data.measies)
for (const lang of this.config.languages) {
const key = `tags${capitalize(lang)}`
if (Array.isArray(data[key])) data[key] = JSON.stringify(data[key] || [])
}
try {
this.record = await this.prisma.curatedSet.create({ data })
} catch (err) {
log.warn(err, 'Could not create set')
return this.setResponse(500, 'createSetFailed')
}
return this
}
/*
* Loads a measurements set from the database based on the where clause you pass it
*
* Stores result in this.record
*/
CuratedSetModel.prototype.read = async function (where) {
try {
this.record = await this.prisma.curatedSet.findUnique({ where })
} catch (err) {
log.warn({ err, where }, 'Could not read curated measurements set')
}
if (this.record) {
// FIXME: Convert JSON to object. See https://github.com/prisma/prisma/issues/3786
this.record.measies = JSON.parse(this.record.measies)
for (const lang of this.config.languages) {
const key = `tags${capitalize(lang)}`
this.record[key] = JSON.parse(this.record[key] || '[]')
}
}
return this.curatedSetExists()
} }
/* /*
* Loads a measurements set from the database based on the where clause you pass it * Loads a measurements set from the database based on the where clause you pass it
* In addition prepares it for returning the set data * In addition prepares it for returning the set data
* *
* Stores result in this.record * @param {params} object - The (URL) params from the request
* @param {format} string - The format to use (json or yaml)
* @returns {CuratedSetModel} object - The CureatedSetModel
*/ */
CuratedSetModel.prototype.guardedRead = async function ({ params }, format = false) { CuratedSetModel.prototype.guardedRead = async function ({ params }, format = false) {
/*
* Read record from database
*/
await this.read({ id: parseInt(params.id) }) await this.read({ id: parseInt(params.id) })
/*
* If no format is specified, return as object
*/
if (!format) if (!format)
return this.setResponse(200, false, { return this.setResponse200({
result: 'success', result: 'success',
curatedSet: this.asCuratedSet(), curatedSet: this.asCuratedSet(),
}) })
return this.setResponse(200, false, this.asData(), true) return this.setResponse200(this.asData(), true)
} }
/* /*
* Returns a list of all curated sets * Returns a list of all curated sets
*
* @returns {list} array - The list of curated sets
*/ */
CuratedSetModel.prototype.allCuratedSets = async function () { CuratedSetModel.prototype.allCuratedSets = async function () {
/*
* Attempt to read all curates sets from the database
*/
let curatedSets let curatedSets
try { try {
curatedSets = await this.prisma.curatedSet.findMany() curatedSets = await this.prisma.curatedSet.findMany()
} catch (err) { } catch (err) {
log.warn(`Failed to search curated sets: ${err}`) log.warn(`Failed to search curated sets: ${err}`)
} }
/*
* Iterate over list to do some housekeeping and JSON wrangling
*/
const list = [] const list = []
for (const curatedSet of curatedSets) { for (const curatedSet of curatedSets) {
// FIXME: Convert object to JSON. See https://github.com/prisma/prisma/issues/3786
const asPojo = { ...curatedSet } const asPojo = { ...curatedSet }
/*
* We need to parse this from JSON
* See https://github.com/prisma/prisma/issues/3786
*/
asPojo.measies = JSON.parse(asPojo.measies) asPojo.measies = JSON.parse(asPojo.measies)
for (const lang of this.config.languages) { for (const lang of this.config.languages) {
const key = `tags${capitalize(lang)}` const key = `tags${capitalize(lang)}`
@ -140,152 +158,174 @@ CuratedSetModel.prototype.allCuratedSets = async function () {
* Clones a curated measurements set (into a regular set) * Clones a curated measurements set (into a regular set)
* In addition prepares it for returning the set data * In addition prepares it for returning the set data
* *
* Stores result in this.record * @param {params} object - The (URL) params from the request
* @param {user} string - The user object as loaded by auth middleware
* @param {body} string - The request body
* @param {Set} SetModel - The Set to clone into
* @returns {CuratedSetModel} object - The CureatedSetModel
*/ */
CuratedSetModel.prototype.guardedClone = async function ({ params, user, body }, Set) { CuratedSetModel.prototype.guardedClone = async function ({ params, user, body }, Set) {
/*
* Enforce RBAC
*/
if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel') if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
/*
* Verify JWT
*/
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
/*
* Is language set?
*/
if (!body.language || !this.config.languages.includes(body.language)) if (!body.language || !this.config.languages.includes(body.language))
return this.setResponse(403, 'languageMissing') return this.setResponse(403, 'languageMissing')
/*
* Read record from database
*/
await this.read({ id: parseInt(params.id) }) await this.read({ id: parseInt(params.id) })
// Clone curated set /*
const data = {} * Create data for the cloned set
*/
const lang = capitalize(body.language.toLowerCase()) const lang = capitalize(body.language.toLowerCase())
data.name = this.record[`name${lang}`] const data = {
data.notes = this.record[`notes${lang}`] lang,
data.measies = this.record.measies name: this.record[`name${lang}`],
notes: this.record[`notes${lang}`],
measies: this.record.measies,
}
/*
* Clone curated set into regular set
*/
await Set.guardedCreate({ params, user, body: data }) await Set.guardedCreate({ params, user, body: data })
return return
} }
/*
* Checks this.record and sets a boolean to indicate whether
* the curated set exists or not
*
* Stores result in this.exists
*/
CuratedSetModel.prototype.curatedSetExists = function () {
this.exists = this.record ? true : false
return this
}
/*
* Updates the set data - Used when we create the data ourselves
* so we know it's safe
*/
CuratedSetModel.prototype.unguardedUpdate = async function (data) {
// FIXME: Convert object to JSON. See https://github.com/prisma/prisma/issues/3786
if (data.measies && typeof data.measies === 'object') data.measies = JSON.stringify(data.measies)
for (const lang of this.config.languages) {
const key = `tags${capitalize(lang)}`
if (data[key] && Array.isArray(data[key])) data[key] = JSON.stringify(data[key] || [])
}
try {
this.record = await this.prisma.curatedSet.update({
where: { id: this.record.id },
data,
})
// FIXME: Convert JSON to object. See https://github.com/prisma/prisma/issues/3786
this.record.measies = JSON.parse(this.record.measies)
for (const lang of this.config.languages) {
const key = `tags${capitalize(lang)}`
this.record[key] = JSON.parse(this.record[key])
}
} catch (err) {
log.warn(err, 'Could not update set record')
process.exit()
return this.setResponse(500, 'updateSetFailed')
}
return this.setResponse(200)
}
/* /*
* Updates the set data - Used when we pass through user-provided data * Updates the set data - Used when we pass through user-provided data
* so we can't be certain it's safe * so we can't be certain it's safe
*
* @param {params} object - The (URL) params from the request
* @param {body} string - The request body
* @param {user} string - The user object as loaded by auth middleware
* @returns {CuratedSetModel} object - The CureatedSetModel
*/ */
CuratedSetModel.prototype.guardedUpdate = async function ({ params, body, user }) { CuratedSetModel.prototype.guardedUpdate = async function ({ params, body, user }) {
/*
* Enforce RBAC
*/
if (!this.rbac.curator(user)) return this.setResponse(403, 'insufficientAccessLevel') if (!this.rbac.curator(user)) return this.setResponse(403, 'insufficientAccessLevel')
/*
* Verify JWT token for user status
*/
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
/*
* Attempt to read database record
*/
await this.read({ id: parseInt(params.id) }) await this.read({ id: parseInt(params.id) })
// Prepare data /*
* Prepare data for updating the record
*/
const data = {} const data = {}
/*
* Unlike a regular set, curated set have notes and name in each language
*/
for (const lang of this.config.languages) { for (const lang of this.config.languages) {
for (const field of ['name', 'notes']) { for (const field of ['name', 'notes']) {
const key = field + capitalize(lang) const key = field + capitalize(lang)
if (body[key] && typeof body[key] === 'string') data[key] = body[key] if (body[key] && typeof body[key] === 'string') data[key] = body[key]
} }
} }
// Measurements /*
const measies = {} * Handle the measurements
*/
if (typeof body.measies === 'object') { if (typeof body.measies === 'object') {
const remove = [] data.measies = this.sanitizeMeasurements({
for (const [key, val] of Object.entries(body.measies)) { ...this.record.measies,
if (this.config.measies.includes(key)) { ...body.measies,
if (val === null) remove.push(key) })
else if (typeof val == 'number' && val > 0) measies[key] = val
}
}
data.measies = { ...this.record.measies, ...measies }
for (const key of remove) delete data.measies[key]
} }
// Image (img) /*
* Handle the image, if there is one
*/
if (typeof body.img === 'string') { if (typeof body.img === 'string') {
const img = await storeImage({ const img = await storeImage(
id: `cset-${this.record.id}`, {
metadata: { user: this.user.uid }, id: `cset-${this.record.id}`,
b64: body.img, metadata: { user: this.user.uid },
}) b64: body.img,
},
this.isTest(body)
)
data.img = img.url data.img = img.url
} }
// Now update the record /*
await this.unguardedUpdate(data) * Now update the record
*/
await this.update(data)
return this.setResponse(200, false, { set: this.asCuratedSet() }) /*
* Return 200 with updated data
*/
return this.setResponse200({ set: this.asCuratedSet() })
} }
/* /*
* Removes the set - No questions asked * Removes the set - But checks permissions
*/ *
CuratedSetModel.prototype.unguardedDelete = async function () { * @param {params} object - The (URL) params from the request
await this.prisma.curatedSet.delete({ where: { id: this.record.id } }) * @param {user} string - The user object as loaded by auth middleware
this.record = null * @returns {CuratedSetModel} object - The CureatedSetModel
this.clear = null
return this.curatedSetExists()
}
/*
* Removes the set - Checks permissions
*/ */
CuratedSetModel.prototype.guardedDelete = async function ({ params, user }) { CuratedSetModel.prototype.guardedDelete = async function ({ params, user }) {
/*
* Enforce RBAC
*/
if (!this.rbac.curator(user)) return this.setResponse(403, 'insufficientAccessLevel') if (!this.rbac.curator(user)) return this.setResponse(403, 'insufficientAccessLevel')
/*
* Make sure the account is ok by checking the JWT
*/
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
/*
* Find the database record
*/
await this.read({ id: parseInt(params.id) }) await this.read({ id: parseInt(params.id) })
await this.unguardedDelete()
/*
* Now delete it
*/
await this.delete()
/*
* Return 204
*/
return this.setResponse(204, false) return this.setResponse(204, false)
} }
/* /*
* Returns record data fit for public publishing * Returns record data fit for public publishing
*
* @returns {curatedSet} object - The Cureated Set as a plain object
*/ */
CuratedSetModel.prototype.asCuratedSet = function () { CuratedSetModel.prototype.asCuratedSet = function () {
return { ...this.record } return { ...this.unserialize(this.record) }
} }
/* /*
* Returns record data fit for public publishing * Returns record data fit for public publishing
*
* @returns {curatedSet} object - The Cureated Set as a plain object
*/ */
CuratedSetModel.prototype.asData = function () { CuratedSetModel.prototype.asData = function () {
const data = { const data = {
@ -299,57 +339,3 @@ CuratedSetModel.prototype.asData = function () {
return data return data
} }
/*
* Helper method to set the response code, result, and body
*
* Will be used by this.sendResponse()
*/
CuratedSetModel.prototype.setResponse = function (
status = 200,
error = false,
data = {},
rawData = false
) {
this.response = {
status,
body: rawData
? data
: {
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.curatedSetExists()
}
/*
* Helper method to send response (as JSON)
*/
CuratedSetModel.prototype.sendResponse = async function (res) {
return res.status(this.response.status).send(this.response.body)
}
/*
* Helper method to send response as YAML
*/
CuratedSetModel.prototype.sendYamlResponse = async function (res) {
return res.status(this.response.status).type('yaml').send(yaml.dump(this.response.body))
}
/* Helper method to parse user-supplied measurements */
CuratedSetModel.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

@ -1,29 +1,53 @@
import { UserModel } from './user.mjs'
import { i18nUrl } from '../utils/index.mjs' import { i18nUrl } from '../utils/index.mjs'
import { decorateModel } from '../utils/model-decorator.mjs'
/*
* This model handles all flows (typically that involves sending out emails)
*/
export function FlowModel(tools) { export function FlowModel(tools) {
this.config = tools.config return decorateModel(this, tools, {
this.mailer = tools.email name: 'flow',
this.rbac = tools.rbac models: ['user'],
this.User = new UserModel(tools) })
return this
} }
/* /*
* Send a translator invite * Send a translator invite
*
* @param {body} object - The request body
* @param {user} object - The user as loaded by auth middleware
* @returns {FlowModel} object - The FlowModel
*/ */
FlowModel.prototype.sendTranslatorInvite = async function ({ body, user }) { FlowModel.prototype.sendTranslatorInvite = async function ({ body, user }) {
/*
* Enforce RBAC
*/
if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel') if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
/*
* Do we have a POST body?
*/
if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing') if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing')
/*
* Is language set?
*/
if (!body.language) return this.setResponse(400, 'languageMissing') if (!body.language) return this.setResponse(400, 'languageMissing')
/*
* Is language a valid language?
*/
if (!this.config.translations.includes(body.language)) if (!this.config.translations.includes(body.language))
return this.setResponse(400, 'languageInvalid') return this.setResponse(400, 'languageInvalid')
// Load user making the call /*
* Load user record from database
*/
await this.User.revealAuthenticatedUser(user) await this.User.revealAuthenticatedUser(user)
// Send the invite email /*
* Send the invite email
*/
await this.mailer.send({ await this.mailer.send({
template: 'transinvite', template: 'transinvite',
language: body.language, language: body.language,
@ -35,21 +59,43 @@ FlowModel.prototype.sendTranslatorInvite = async function ({ body, user }) {
}, },
}) })
return this.setResponse(200, 'sent', {}) /*
* Return 200
*/
return this.setResponse200({})
} }
/* /*
* Send a language suggestion to the maintainer * Send a language suggestion to the maintainer
*
* @param {body} object - The request body
* @param {user} object - The user as loaded by auth middleware
* @returns {FlowModel} object - The FlowModel
*/ */
FlowModel.prototype.sendLanguageSuggestion = async function ({ body, user }) { FlowModel.prototype.sendLanguageSuggestion = async function ({ body, user }) {
/*
* Enforce RBAC
*/
if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel') if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
/*
* Do we have a POST body?
*/
if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing') if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing')
/*
* Is language set?
*/
if (!body.language) return this.setResponse(400, 'languageMissing') if (!body.language) return this.setResponse(400, 'languageMissing')
// Load user making the call /*
* Load user making the call
*/
await this.User.revealAuthenticatedUser(user) await this.User.revealAuthenticatedUser(user)
// Send the invite email /*
* Send the invite email
*/
await this.mailer.send({ await this.mailer.send({
template: 'langsuggest', template: 'langsuggest',
language: body.language, language: body.language,
@ -61,35 +107,8 @@ FlowModel.prototype.sendLanguageSuggestion = async function ({ body, user }) {
}, },
}) })
return this.setResponse(200, 'sent', {}) /*
} * Return 200
*/
/* return this.setResponse200({})
* Helper method to set the response code, result, and body
*
* Will be used by this.sendResponse()
*/
FlowModel.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
if (status === 404) this.response.body = null
return this
}
/*
* Helper method to send response
*/
FlowModel.prototype.sendResponse = async function (res) {
return res.status(this.response.status).send(this.response.body)
} }

View file

@ -1,73 +1,47 @@
import fetch from 'node-fetch' import { decorateModel } from '../utils/model-decorator.mjs'
import { UserModel } from './user.mjs' import { createIssue } from '../utils/github.mjs'
/*
* This model handles all flows (typically that involves sending out emails)
*/
export function IssueModel(tools) { export function IssueModel(tools) {
this.config = tools.config return decorateModel(this, tools, {
this.prisma = tools.prisma name: 'flow',
this.User = new UserModel(tools) models: ['user'],
this.token = tools.config.github.token })
return this
}
IssueModel.prototype.setResponse = function (status = 200, error = false, data = {}) {
this.response = {
status,
body: {
result: 'success',
...data,
},
}
if (status === 201) this.response.body.result = 'created'
else if (status > 204) {
this.response.body.error = error
this.response.body.result = 'error'
this.error = true
} else this.error = false
return this
}
IssueModel.prototype.sendResponse = async function (res) {
return res.status(this.response.status).send(this.response.body)
}
IssueModel.prototype.unguardedDelete = async function () {
await this.prisma.apikey.delete({ where: { id: this.record.id } })
this.record = null
this.clear = null
return this.setExists()
} }
/*
* Create an issue
*
* @param {body} object - The request body
* @returns {IssueModel} object - The IssueModel
*/
IssueModel.prototype.create = async function ({ body }) { IssueModel.prototype.create = async function ({ body }) {
if (!this.token) return this.setResponse(400, 'notEnabled') /*
* Is issue creation enabled
*/
if (!this.config.use.github) return this.setResponse(400, 'notEnabled')
/*
* Do we have a POST body?
*/
if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing') if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing')
/*
* Is title set?
*/
if (!body.title) return this.setResponse(400, 'titleMissing') if (!body.title) return this.setResponse(400, 'titleMissing')
/*
* Is body set?
*/
if (!body.body) return this.setResponse(400, 'bodyMissing') if (!body.body) return this.setResponse(400, 'bodyMissing')
const apiUrl = `https://api.github.com/repos/freesewing/freesewing/issues` /*
let response * Create the issue
try { */
response = await fetch(apiUrl, { const issue = await createIssue(body)
method: 'POST',
headers: {
Authorization: `Bearer ${this.token}`,
Accept: 'application/vnd.github.v3+json',
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
if (response.status === 201) response = await response.json() return issue ? this.setResponse201({ issue }) : this.setResponse(400)
else {
console.log(response)
response = false
}
} catch (error) {
console.error('An error occurred while creating a GitHub issue:', error.message)
response = false
}
return response ? this.setResponse(201, 'created', { issue: response }) : this.setResponse(400)
} }

View file

@ -2,26 +2,34 @@ import { log } from '../utils/log.mjs'
import { capitalize } from '../utils/index.mjs' import { capitalize } from '../utils/index.mjs'
import { storeImage } from '../utils/cloudflare-images.mjs' import { storeImage } from '../utils/cloudflare-images.mjs'
import yaml from 'js-yaml' import yaml from 'js-yaml'
import { SetModel } from './set.mjs' import { decorateModel } from '../utils/model-decorator.mjs'
/*
* This model handles all flows (typically that involves sending out emails)
*/
export function PatternModel(tools) { export function PatternModel(tools) {
this.config = tools.config return decorateModel(this, tools, {
this.prisma = tools.prisma name: 'pattern',
this.decrypt = tools.decrypt encryptedFields: ['data', 'img', 'name', 'notes', 'settings'],
this.encrypt = tools.encrypt models: ['set'],
this.rbac = tools.rbac })
this.encryptedFields = ['data', 'img', 'name', 'notes', 'settings']
this.clear = {} // For holding decrypted data
this.Set = new SetModel(tools)
return this
} }
/* /*
* Returns a list of sets for the user making the API call * Returns a list of patterns for the user making the API call
*
* @param {uid} string - uid of the user, as provided by the auth middleware
* @returns {patterns} array - The list of patterns
*/ */
PatternModel.prototype.userPatterns = async function (uid) { PatternModel.prototype.userPatterns = async function (uid) {
/*
* No uid no deal
*/
if (!uid) return false if (!uid) return false
/*
* Run query returning all patterns from the database
*/
let patterns let patterns
try { try {
patterns = await this.prisma.pattern.findMany({ patterns = await this.prisma.pattern.findMany({
@ -34,315 +42,346 @@ PatternModel.prototype.userPatterns = async function (uid) {
} catch (err) { } catch (err) {
log.warn(`Failed to search patterns for user ${uid}: ${err}`) log.warn(`Failed to search patterns for user ${uid}: ${err}`)
} }
const list = []
for (const pattern of patterns) list.push(this.revealPattern(pattern))
/*
* Decrypt data for all patterns found
*/
const list = patterns.map((pattern) => this.revealPattern(pattern))
/*
* Return the list of patterns
*/
return list return list
} }
/*
* Creates a new pattern - Takes user input so we validate data and access
*
* @param {body} object - The request body
* @param {user} object - The user data as provided by the auth middleware
* @returns {PatternModel} object - The PatternModel
*/
PatternModel.prototype.guardedCreate = async function ({ body, user }) { PatternModel.prototype.guardedCreate = async function ({ body, user }) {
/*
* Enforce RBAC
*/
if (!this.rbac.user(user)) return this.setResponse(403, 'insufficientAccessLevel') if (!this.rbac.user(user)) return this.setResponse(403, 'insufficientAccessLevel')
/*
* Do we have a POST body?
*/
if (Object.keys(body).length < 2) return this.setResponse(400, 'postBodyMissing') if (Object.keys(body).length < 2) return this.setResponse(400, 'postBodyMissing')
if (!body.set && !body.cset) return this.setResponse(400, 'setOrCsetMissing')
if (typeof body.set !== 'undefined' && typeof body.set !== 'number') /*
return this.setResponse(400, 'setNotNumeric') * Is settings set?
if (typeof body.cset !== 'undefined' && typeof body.cset !== 'number') */
return this.setResponse(400, 'csetNotNumeric')
if (typeof body.settings !== 'object') return this.setResponse(400, 'settingsNotAnObject') if (typeof body.settings !== 'object') return this.setResponse(400, 'settingsNotAnObject')
/*
* Is data set?
*/
if (body.data && typeof body.data !== 'object') return this.setResponse(400, 'dataNotAnObject') if (body.data && typeof body.data !== 'object') return this.setResponse(400, 'dataNotAnObject')
/*
* Is design set?
*/
if (!body.design && !body.data?.design) return this.setResponse(400, 'designMissing') if (!body.design && !body.data?.design) return this.setResponse(400, 'designMissing')
/*
* Is design a string?
*/
if (typeof body.design !== 'string') return this.setResponse(400, 'designNotStringy') if (typeof body.design !== 'string') return this.setResponse(400, 'designNotStringy')
// Prepare data /*
const data = { * Create initial record
*/
await this.createRecord({
csetId: body.cset ? body.cset : null,
data: typeof body.data === 'object' ? body.data : {},
design: body.design, design: body.design,
settings: body.settings, img: this.config.avatars.pattern,
} setId: body.set ? body.set : null,
if (data.settings.measurements) delete data.settings.measurements settings: {
if (body.set) data.setId = body.set ...body.settings,
else if (body.cset) data.csetId = body.cset measurements: body.settings.measurements === 'object' ? body.settings.measurements : {},
else return this.setResponse(400, 'setOrCsetMissing') },
userId: user.uid,
name: typeof body.name === 'string' && body.name.length > 0 ? body.name : '--',
notes: typeof body.notes === 'string' && body.notes.length > 0 ? body.notes : '--',
public: body.public === true ? true : false,
})
// Data (will be encrypted, so always set _some_ value) /*
if (typeof body.data === 'object') data.data = body.data * Now that we have a record ID, we can update the image
else data.data = {} */
// Name (will be encrypted, so always set _some_ value) const img = await storeImage(
if (typeof body.name === 'string' && body.name.length > 0) data.name = body.name {
else data.name = '--' id: `pattern-${this.record.id}`,
// Notes (will be encrypted, so always set _some_ value) metadata: { user: user.uid },
if (typeof body.notes === 'string' && body.notes.length > 0) data.notes = body.notes b64: body.img,
else data.notes = '--' },
// Public this.isTest(body)
if (body.public === true) data.public = true )
data.userId = user.uid
// Set this one initially as we need the ID to store an image on cloudflare
data.img = this.config.avatars.pattern
// Create record /*
await this.unguardedCreate(data) * If an image was created, update the record with its ID
* If not, just update the record from the database
*/
if (img) await this.update(this.cloak({ img: img.url }))
else await this.read({ id: this.record.id }, { set: true, cset: true })
// Update img? (now that we have the ID) /*
const img = * Now return 201 and the record data
this.config.use.cloudflareImages && */
typeof body.img === 'string' && return this.setResponse201({ pattern: this.asPattern() })
(!body.test || (body.test && this.config.use.tests?.cloudflareImages))
? await storeImage({
id: `pattern-${this.record.id}`,
metadata: { user: user.uid },
b64: body.img,
})
: false
if (img) await this.unguardedUpdate(this.cloak({ img: img.url }))
else await this.read({ id: this.record.id })
return this.setResponse(201, 'created', { pattern: this.asPattern() })
}
PatternModel.prototype.unguardedCreate = async function (data) {
try {
this.record = await this.prisma.pattern.create({ data: this.cloak(data) })
} catch (err) {
log.warn(err, 'Could not create pattern')
return this.setResponse(500, 'createPatternFailed')
}
return this
}
/*
* Loads a pattern from the database based on the where clause you pass it
*
* Stores result in this.record
*/
PatternModel.prototype.read = async function (where) {
try {
this.record = await this.prisma.pattern.findUnique({
where,
include: {
set: true,
cset: true,
},
})
} catch (err) {
log.warn({ err, where }, 'Could not read pattern')
}
this.reveal()
return this.setExists()
} }
/* /*
* Loads a pattern from the database but only if it's public * Loads a pattern from the database but only if it's public
* *
* Stores result in this.record * @param {params} object - The request (URL) parameters
* @returns {PatternModel} object - The PatternModel
*/ */
PatternModel.prototype.publicRead = async function ({ params }) { PatternModel.prototype.publicRead = async function ({ params }) {
await this.read({ id: parseInt(params.id) }) /*
if (this.record.public !== true) { * Attempt to read the database record
// Note that we return 404 */
// because we don't want to reveal that a non-public pattern exists. await this.read({ id: parseInt(params.id) }, { set: true, cset: true })
return this.setResponse(404)
}
return this.setResponse(200, false, this.asPublicPattern(), true) /*
* Ensure it is public and if it is not public, return 404
* rather than reveal that a non-public pattern exists
*/
if (this.record.public !== true) return this.setResponse(404)
/*
* Return pattern
*/
return this.setResponse200(this.asPublicPattern())
} }
/* /*
* Loads a pattern from the database based on the where clause you pass it * Loads a pattern from the database based on the where clause you pass it
* In addition prepares it for returning the pattern data * In addition prepares it for returning the pattern data
* *
* Stores result in this.record * @param {params} object - The request (URL) parameters
* @param {user} object - The user data as provided by the auth middleware
* @returns {PatternModel} object - The PatternModel
*/ */
PatternModel.prototype.guardedRead = async function ({ params, user }) { PatternModel.prototype.guardedRead = async function ({ params, user }) {
/*
* Enforce RBAC
*/
if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel') if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
/*
* Check JWT for status
*/
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
/*
* Is the id set?
*/
if (typeof params.id !== 'undefined' && !Number(params.id)) if (typeof params.id !== 'undefined' && !Number(params.id))
return this.setResponse(403, 'idNotNumeric') return this.setResponse(403, 'idNotNumeric')
await this.read({ id: parseInt(params.id) }) /*
* Attempt to read record from database
*/
await this.read({ id: parseInt(params.id) }, { set: true, cset: true })
/*
* Return 404 if it cannot be found
*/
if (!this.record) return this.setResponse(404, 'notFound') if (!this.record) return this.setResponse(404, 'notFound')
/*
* You need at least the bughunter role to read another user's pattern
*/
if (this.record.userId !== user.uid && !this.rbac.bughunter(user)) { if (this.record.userId !== user.uid && !this.rbac.bughunter(user)) {
return this.setResponse(403, 'insufficientAccessLevel') return this.setResponse(403, 'insufficientAccessLevel')
} }
return this.setResponse(200, false, { /*
result: 'success', * Return the loaded pattern
pattern: this.asPattern(), */
}) return this.setResponse200({ pattern: this.asPattern() })
} }
/* /*
* Clones a pattern * Clones a pattern
* In addition prepares it for returning the pattern data * In addition prepares it for returning the pattern data
* *
* Stores result in this.record * @param {params} object - The request (URL) parameters
* @param {user} object - The user data as provided by the auth middleware
* @returns {PatternModel} object - The PatternModel
*/ */
PatternModel.prototype.guardedClone = async function ({ params, user }) { PatternModel.prototype.guardedClone = async function ({ params, user }) {
/*
* Enforce RBAC
*/
if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel') if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
/*
* Check JWT
*/
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
/*
* Attempt to read record from database
*/
await this.read({ id: parseInt(params.id) }) await this.read({ id: parseInt(params.id) })
/*
* You need the support role to clone another user's pattern that is not public
*/
if (this.record.userId !== user.uid && !this.record.public && !this.rbac.support(user)) { if (this.record.userId !== user.uid && !this.record.public && !this.rbac.support(user)) {
return this.setResponse(403, 'insufficientAccessLevel') return this.setResponse(403, 'insufficientAccessLevel')
} }
// Clone pattern /*
* Now clone the pattern
*/
const data = this.asPattern() const data = this.asPattern()
delete data.id delete data.id
data.name += ` (cloned from #${this.record.id})` data.name += ` (cloned from #${this.record.id})`
data.notes += ` (Note: This pattern was cloned from pattern #${this.record.id})` data.notes += ` (Note: This pattern was cloned from pattern #${this.record.id})`
await this.unguardedCreate(data)
// Update unencrypted data /*
* Write it to the database
*/
await this.createRecord(data)
/*
* Update record with unencrypted data
*/
this.reveal() this.reveal()
return this.setResponse(200, false, { /*
result: 'success', * And return the cloned pattern
pattern: this.asPattern(), */
}) return this.setResponse200({ pattern: this.asPattern() })
}
/*
* Helper method to decrypt at-rest data
*/
PatternModel.prototype.reveal = async function () {
this.clear = {}
if (this.record) {
for (const field of this.encryptedFields) {
this.clear[field] = this.decrypt(this.record[field])
}
}
// Parse JSON content
if (this.record?.cset) {
for (const lang of this.config.languages) {
const key = `tags${capitalize(lang)}`
if (this.record.cset[key]) this.record.cset[key] = JSON.parse(this.record.cset[key])
}
if (this.record.cset.measies) this.record.cset.measies = JSON.parse(this.record.cset.measies)
}
if (this.record?.set) {
this.record.set = this.Set.revealSet(this.record.set)
}
return this
}
/*
* Helper method to encrypt at-rest data
*/
PatternModel.prototype.cloak = function (data) {
for (const field of this.encryptedFields) {
if (typeof data[field] !== 'undefined') {
data[field] = this.encrypt(data[field])
}
}
return data
}
/*
* Checks this.record and sets a boolean to indicate whether
* the pattern exists or not
*
* Stores result in this.exists
*/
PatternModel.prototype.setExists = function () {
this.exists = this.record ? true : false
return this
}
/*
* Updates the pattern data - Used when we create the data ourselves
* so we know it's safe
*/
PatternModel.prototype.unguardedUpdate = async function (data) {
try {
this.record = await this.prisma.pattern.update({
where: { id: this.record.id },
data,
include: {
set: true,
cset: true,
},
})
} catch (err) {
log.warn(err, 'Could not update pattern record')
process.exit()
return this.setResponse(500, 'updatePatternFailed')
}
await this.reveal()
return this.setResponse(200)
} }
/* /*
* Updates the pattern data - Used when we pass through user-provided data * Updates the pattern data - Used when we pass through user-provided data
* so we can't be certain it's safe * so we can't be certain it's safe
*
* @param {params} object - The request (URL) parameters
* @param {body} object - The request body
* @param {user} object - The user data as provided by the auth middleware
* @returns {PatternModel} object - The PatternModel
*/ */
PatternModel.prototype.guardedUpdate = async function ({ params, body, user }) { PatternModel.prototype.guardedUpdate = async function ({ params, body, user }) {
/*
* Enforce RBAC
*/
if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel') if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
/*
* Check JWT
*/
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
await this.read({ id: parseInt(params.id) })
/*
* Attempt to read record from the database
*/
await this.read({ id: parseInt(params.id) }, { set: true, cset: true })
/*
* Only admins can update other people's patterns
*/
if (this.record.userId !== user.uid && !this.rbac.admin(user)) { if (this.record.userId !== user.uid && !this.rbac.admin(user)) {
return this.setResponse(403, 'insufficientAccessLevel') return this.setResponse(403, 'insufficientAccessLevel')
} }
/*
* Prepare data for updating the record
*/
const data = {} const data = {}
// Name /*
* name
*/
if (typeof body.name === 'string') data.name = body.name if (typeof body.name === 'string') data.name = body.name
// Notes /*
* notes
*/
if (typeof body.notes === 'string') data.notes = body.notes if (typeof body.notes === 'string') data.notes = body.notes
// Public /*
* public
*/
if (body.public === true || body.public === false) data.public = body.public if (body.public === true || body.public === false) data.public = body.public
// Data /*
* data
*/
if (typeof body.data === 'object') data.data = body.data if (typeof body.data === 'object') data.data = body.data
// Settings /*
* settings
*/
if (typeof body.settings === 'object') data.settings = body.settings if (typeof body.settings === 'object') data.settings = body.settings
// Image (img) /*
* img
*/
if (typeof body.img === 'string') { if (typeof body.img === 'string') {
const img = await storeImage({ data.img = await storeImage(
id: `pattern-${this.record.id}`, {
metadata: { user: this.user.uid }, id: `pattern-${this.record.id}`,
b64: body.img, metadata: { user: this.user.uid },
}) b64: body.img,
data.img = img.url },
this.isTest(body)
)
} }
// Now update the record /*
await this.unguardedUpdate(this.cloak(data)) * Now update the record
*/
await this.update(data, { set: true, cset: true })
return this.setResponse(200, false, { pattern: this.asPattern() }) /*
} * Return 200 and the data
*/
/* return this.setResponse200({ pattern: this.asPattern() })
* Removes the pattern - No questions asked
*/
PatternModel.prototype.unguardedDelete = async function () {
await this.prisma.pattern.delete({ where: { id: this.record.id } })
this.record = null
this.clear = null
return this.setExists()
} }
/* /*
* Removes the pattern - Checks permissions * Removes the pattern - Checks permissions
*
* @param {params} object - The request (URL) parameters
* @param {user} object - The user data as provided by the auth middleware
* @returns {PatternModel} object - The PatternModel
*/ */
PatternModel.prototype.guardedDelete = async function ({ params, user }) { PatternModel.prototype.guardedDelete = async function ({ params, user }) {
/*
* Enforce RBAC
*/
if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel') if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
/*
* Check JWT
*/
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
/*
* Attempt to read record from database
*/
await this.read({ id: parseInt(params.id) }) await this.read({ id: parseInt(params.id) })
/*
* Only admins can delete other user's patterns
*/
if (this.record.userId !== user.uid && !this.rbac.admin(user)) { if (this.record.userId !== user.uid && !this.rbac.admin(user)) {
return this.setResponse(403, 'insufficientAccessLevel') return this.setResponse(403, 'insufficientAccessLevel')
} }
await this.unguardedDelete() /*
* Remove the record
*/
await this.delete()
/*
* Return 204
*/
return this.setResponse(204, false) return this.setResponse(204, false)
} }
@ -358,6 +397,9 @@ PatternModel.prototype.asPattern = function () {
/* /*
* Helper method to decrypt data from a non-instantiated pattern * Helper method to decrypt data from a non-instantiated pattern
*
* @param {pattern} object - The pattern data
* @returns {pattern} object - The unencrypted pattern data
*/ */
PatternModel.prototype.revealPattern = function (pattern) { PatternModel.prototype.revealPattern = function (pattern) {
const clear = {} const clear = {}
@ -374,49 +416,6 @@ PatternModel.prototype.revealPattern = function (pattern) {
return { ...pattern, ...clear } return { ...pattern, ...clear }
} }
/*
* Helper method to set the response code, result, and body
*
* Will be used by this.sendResponse()
*/
PatternModel.prototype.setResponse = function (
status = 200,
error = false,
data = {},
rawData = false
) {
this.response = {
status,
body: rawData
? data
: {
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
*/
PatternModel.prototype.sendResponse = async function (res) {
return res.status(this.response.status).send(this.response.body)
}
/*
* Helper method to send response as YAML
*/
PatternModel.prototype.sendYamlResponse = async function (res) {
return res.status(this.response.status).type('yaml').send(yaml.dump(this.response.body))
}
/* /*
* Returns record data fit for public publishing * Returns record data fit for public publishing
*/ */
@ -432,6 +431,12 @@ PatternModel.prototype.asPublicPattern = function () {
return data return data
} }
/*
*
* Everything below this comment is v2 => v3 migration code
* And can be removed after the migration
*/
const migratePattern = (v2, userId) => ({ const migratePattern = (v2, userId) => ({
createdAt: new Date(v2.created ? v2.created : v2.createdAt), createdAt: new Date(v2.created ? v2.created : v2.createdAt),
data: { version: v2.data.version, notes: ['Migrated from version 2'] }, data: { version: v2.data.version, notes: ['Migrated from version 2'] },

View file

@ -1,109 +1,124 @@
import { log } from '../utils/log.mjs' import { log } from '../utils/log.mjs'
import { replaceImage, storeImage, ensureImage, importImage } from '../utils/cloudflare-images.mjs' import { replaceImage, storeImage, ensureImage, importImage } from '../utils/cloudflare-images.mjs'
import yaml from 'js-yaml' import yaml from 'js-yaml'
import { decorateModel } from '../utils/model-decorator.mjs'
/*
* This model handles all flows (typically that involves sending out emails)
*/
export function SetModel(tools) { export function SetModel(tools) {
this.config = tools.config return decorateModel(this, tools, {
this.prisma = tools.prisma name: 'set',
this.decrypt = tools.decrypt encryptedFields: ['measies', 'img', 'name', 'notes'],
this.encrypt = tools.encrypt })
this.rbac = tools.rbac
this.encryptedFields = ['measies', 'img', 'name', 'notes']
this.clear = {} // For holding decrypted data
return this
}
SetModel.prototype.guardedCreate = async function ({ body, user }) {
if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
if (Object.keys(body).length < 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 }
// Name (will be encrypted, so always set _some_ value)
if (typeof body.name === 'string') data.name = body.name
else data.name = '--'
// Notes (will be encrypted, so always set _some_ value)
if (body.notes || typeof body.notes === 'string') data.notes = body.notes
else data.notes = '--'
if (body.public === true) data.public = true
if (body.measies) data.measies = this.sanitizeMeasurements(body.measies)
else data.measies = {}
data.imperial = body.imperial === true ? true : false
data.userId = user.uid
// Set this one initially as we need the ID to store the image on cloudflare
data.img = this.config.avatars.set
// Create record
await this.unguardedCreate(data)
// Update img? (now that we have the ID)
const img =
this.config.use.cloudflareImages &&
typeof body.img === 'string' &&
(!body.test || (body.test && this.config.use.tests?.cloudflareImages))
? await storeImage({
id: `set-${this.record.id}`,
metadata: {
user: user.uid,
name: this.clear.name,
},
b64: body.img,
requireSignedURLs: true,
})
: false
if (img) await this.unguardedUpdate(this.cloak({ img: img.url }))
else await this.read({ id: this.record.id })
return this.setResponse(201, 'created', { set: this.asSet() })
}
SetModel.prototype.unguardedCreate = async function (data) {
try {
this.record = await this.prisma.set.create({ data: this.cloak(data) })
} catch (err) {
log.warn(err, 'Could not create set')
return this.setResponse(500, 'createSetFailed')
}
return this
} }
/* /*
* Loads a measurements set from the database based on the where clause you pass it * Creates a set - Uses user input so we need to validate it
* *
* Stores result in this.record * @params {body} object - The request body
* @params {user} object - The user as provided by the auth middleware
* @returns {SetModel} object - The SetModel
*/ */
SetModel.prototype.read = async function (where) { SetModel.prototype.guardedCreate = async function ({ body, user }) {
try { /*
this.record = await this.prisma.set.findUnique({ where }) * Enforce RBAC
} catch (err) { */
log.warn({ err, where }, 'Could not read measurements set') if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
}
this.reveal() /*
* Do we have a POST body?
*/
if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing')
return this.setExists() /*
* Is name set?
*/
if (!body.name || typeof body.name !== 'string') return this.setResponse(400, 'nameMissing')
/*
* Create the initial record
*/
await this.createRecord({
name: typeof body.name === 'string' && body.name.length > 0 ? body.name : '--',
notes: typeof body.notes === 'string' && body.notes.length > 0 ? body.notes : '--',
public: body.public === true ? true : false,
measies: typeof body.measies === 'object' ? this.sanitizeMeasurements(body.measies) : {},
imperial: body.imperial === true ? true : false,
userId: user.uid,
img: this.config.avatars.set,
})
/*
* If an image was added, now update it since we have the record id now
*/
const img =
typeof body.img === 'string'
? await storeImage(
{
id: `set-${this.record.id}`,
metadata: {
user: user.uid,
name: this.clear.name,
},
b64: body.img,
requireSignedURLs: true,
},
this.isTest(body)
)
: false
/*
* Either update the image, or refresh the record
*/
if (img) await this.update({ img })
else await this.read({ id: this.record.id })
/*
* Now return 201 and the data
*/
return this.setResponse201({ set: this.asSet() })
} }
/* /*
* Loads a measurements set from the database based on the where clause you pass it * Loads a measurements set from the database based on the where clause you pass it
* In addition prepares it for returning the set data * In addition prepares it for returning the set data
* *
* Stores result in this.record * @params {params} object - The request (URL) parameters
* @params {user} object - The user as provided by the auth middleware
* @returns {SetModel} object - The SetModel
*/ */
SetModel.prototype.guardedRead = async function ({ params, user }) { SetModel.prototype.guardedRead = async function ({ params, user }) {
/*
* Enforce RBAC
*/
if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel') if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
/*
* Check JWT
*/
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
/*
* Attempt to read the record from the database
*/
await this.read({ id: parseInt(params.id) }) await this.read({ id: parseInt(params.id) })
/*
* If it does not exist, send a 404
*/
if (!this.record) return this.setResponse(404) if (!this.record) return this.setResponse(404)
/*
* You need to have at least the bughunter role to read other user's patterns
*/
if (this.record.userId !== user.uid && !this.rbac.bughunter(user)) { if (this.record.userId !== user.uid && !this.rbac.bughunter(user)) {
return this.setResponse(403, 'insufficientAccessLevel') return this.setResponse(403, 'insufficientAccessLevel')
} }
/*
* Return 200 and send the pattern data
*/
return this.setResponse(200, false, { return this.setResponse(200, false, {
result: 'success', result: 'success',
set: this.asSet(), set: this.asSet(),
@ -112,84 +127,85 @@ SetModel.prototype.guardedRead = async function ({ params, user }) {
/* /*
* Loads a measurements set from the database but only if it's public * Loads a measurements set from the database but only if it's public
* This is a public route (no authentication)
* *
* Stores result in this.record * @params {params} object - The request (URL) parameters
* @returns {SetModel} object - The SetModel
*/ */
SetModel.prototype.publicRead = async function ({ params }) { SetModel.prototype.publicRead = async function ({ params }) {
/*
* Attemp to read the record from the database
*/
await this.read({ id: parseInt(params.id) }) await this.read({ id: parseInt(params.id) })
if (this.record.public !== true) {
// Note that we return 404
// because we don't want to reveal that a non-public set exists.
return this.setResponse(404)
}
return this.setResponse(200, false, this.asPublicSet(), true) /*
* If it is not public, return 404 rather than
* reveal that a non-public set exists
*/
if (this.record.public !== true) return this.setResponse(404)
/*
* Return 200 and the set data
*/
return this.setResponse200(this.asPublicSet())
} }
/* /*
* Clones a measurements set * Clones a measurements set
* In addition prepares it for returning the set data * In addition prepares it for returning the set data
* *
* Stores result in this.record * @params {params} object - The request (URL) parameters
* @params {user} object - The user as provided by the auth middleware
* @returns {SetModel} object - The SetModel
*/ */
SetModel.prototype.guardedClone = async function ({ params, user }) { SetModel.prototype.guardedClone = async function ({ params, user }) {
/*
* Enforce RBAC
*/
if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel') if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
/*
* Check the JWT
*/
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
/*
* Attempt to read the record from the database
*/
await this.read({ id: parseInt(params.id) }) await this.read({ id: parseInt(params.id) })
/*
* You need at least the support role to clone another user's (non-public) set
*/
if (this.record.userId !== user.uid && !this.record.public && !this.rbac.support(user)) { if (this.record.userId !== user.uid && !this.record.public && !this.rbac.support(user)) {
return this.setResponse(403, 'insufficientAccessLevel') return this.setResponse(403, 'insufficientAccessLevel')
} }
// Clone set /*
* Now clone the set
*/
const data = this.asSet() const data = this.asSet()
delete data.id delete data.id
data.name += ` (cloned from #${this.record.id})` data.name += ` (cloned from #${this.record.id})`
data.notes += ` (Note: This measurements set was cloned from set #${this.record.id})` data.notes += ` (Note: This measurements set was cloned from set #${this.record.id})`
await this.unguardedCreate(data) await this.createRecord(data)
// Update unencrypted data /*
* Decrypt data at rest
*/
this.reveal() this.reveal()
return this.setResponse(200, false, { /*
result: 'success', * Return 200 and the cloned data
set: this.asSet(), */
}) return this.setResponse201({ set: this.asSet() })
}
/*
* Helper method to decrypt at-rest data
*/
SetModel.prototype.reveal = async function () {
this.clear = {}
if (this.record) {
for (const field of this.encryptedFields) {
try {
this.clear[field] = this.decrypt(this.record[field])
} catch (err) {
console.log(err)
}
}
}
return this
}
/*
* Helper method to encrypt at-rest data
*/
SetModel.prototype.cloak = function (data) {
for (const field of this.encryptedFields) {
if (typeof data[field] !== 'undefined') {
data[field] = this.encrypt(data[field])
}
}
return data
} }
/* /*
* Helper method to decrypt data from a non-instantiated set * Helper method to decrypt data from a non-instantiated set
*
* @param {mset} object - The set data
* @returns {mset} object - The unencrypted data
*/ */
SetModel.prototype.revealSet = function (mset) { SetModel.prototype.revealSet = function (mset) {
const clear = {} const clear = {}
@ -204,117 +220,133 @@ SetModel.prototype.revealSet = function (mset) {
return { ...mset, ...clear } return { ...mset, ...clear }
} }
/*
* Checks this.record and sets a boolean to indicate whether
* the user exists or not
*
* Stores result in this.exists
*/
SetModel.prototype.setExists = function () {
this.exists = this.record ? true : false
return this
}
/*
* Updates the set data - Used when we create the data ourselves
* so we know it's safe
*/
SetModel.prototype.unguardedUpdate = async function (data) {
try {
this.record = await this.prisma.set.update({
where: { id: this.record.id },
data,
})
} catch (err) {
log.warn(err, 'Could not update set record')
process.exit()
return this.setResponse(500, 'updateSetFailed')
}
await this.reveal()
return this.setResponse(200)
}
/* /*
* Updates the set data - Used when we pass through user-provided data * Updates the set data - Used when we pass through user-provided data
* so we can't be certain it's safe * so we can't be certain it's safe
*
* @params {params} object - The request (URL) parameters
* @params {body} object - The request body
* @returns {SetModel} object - The SetModel
*/ */
SetModel.prototype.guardedUpdate = async function ({ params, body, user }) { SetModel.prototype.guardedUpdate = async function ({ params, body, user }) {
/*
* Enforce RBAC
*/
if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel') if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
/*
* Check JWT
*/
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
/*
* Attempt to read record from database
*/
await this.read({ id: parseInt(params.id) }) await this.read({ id: parseInt(params.id) })
/*
* Only admins can update other user's sets
*/
if (this.record.userId !== user.uid && !this.rbac.admin(user)) { if (this.record.userId !== user.uid && !this.rbac.admin(user)) {
return this.setResponse(403, 'insufficientAccessLevel') return this.setResponse(403, 'insufficientAccessLevel')
} }
/*
* Prepare data to update the record
*/
const data = {} const data = {}
// Imperial
/*
* imperial
*/
if (body.imperial === true || body.imperial === false) data.imperial = body.imperial if (body.imperial === true || body.imperial === false) data.imperial = body.imperial
// Name
/*
* name
*/
if (typeof body.name === 'string') data.name = body.name if (typeof body.name === 'string') data.name = body.name
// Notes
/*
* notes
*/
if (typeof body.notes === 'string') data.notes = body.notes if (typeof body.notes === 'string') data.notes = body.notes
// Public
/*
* public
*/
if (body.public === true || body.public === false) data.public = body.public if (body.public === true || body.public === false) data.public = body.public
// Measurements
const measies = {}
if (typeof body.measies === 'object') {
const remove = []
for (const [key, val] of Object.entries(body.measies)) {
if (this.config.measies.includes(key)) {
if (val === null) remove.push(key)
else if (typeof val == 'number' && val > 0) measies[key] = val
}
}
data.measies = { ...this.clear.measies, ...measies }
for (const key of remove) delete data.measies[key]
}
// Image (img) /*
if (typeof body.img === 'string') { * measurements
const img = await replaceImage({ */
id: `set-${this.record.id}`, if (typeof body.measies === 'object') data.measies = this.sanitizeMeasurements(body.measies)
metadata: {
user: user.uid, /*
name: this.clear.name, * img
*/
if (typeof body.img === 'string')
data.img = await replaceImage(
{
id: `set-${this.record.id}`,
metadata: {
user: user.uid,
name: this.clear.name,
},
b64: body.img,
notPublic: true,
}, },
b64: body.img, this.isTest(body)
notPublic: true, )
})
data.img = img.url
}
// Now update the record /*
await this.unguardedUpdate(this.cloak(data)) * Now update the database record
*/
await this.update(data)
return this.setResponse(200, false, { set: this.asSet() }) /*
} * Return 200 and the record data
*/
/* return this.setResponse200({ set: this.asSet() })
* Removes the set - No questions asked
*/
SetModel.prototype.unguardedDelete = async function () {
await this.prisma.set.delete({ where: { id: this.record.id } })
this.record = null
this.clear = null
return this.setExists()
} }
/* /*
* Removes the set - Checks permissions * Removes the set - Checks permissions
*
* @params {params} object - The request (URL) parameters
* @params {user} object - The user as provided by the auth middleware
* @returns {SetModel} object - The SetModel
*/ */
SetModel.prototype.guardedDelete = async function ({ params, user }) { SetModel.prototype.guardedDelete = async function ({ params, user }) {
/*
* Enforce RBAC
*/
if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel') if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
/*
* Check the JWT
*/
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
/*
* Attempt to read the record from the database
*/
await this.read({ id: parseInt(params.id) }) await this.read({ id: parseInt(params.id) })
/*
* You need to be admin to remove another user's data
*/
if (this.record.userId !== user.uid && !this.rbac.admin(user)) { if (this.record.userId !== user.uid && !this.rbac.admin(user)) {
return this.setResponse(403, 'insufficientAccessLevel') return this.setResponse(403, 'insufficientAccessLevel')
} }
await this.unguardedDelete() /*
* Delete the record
*/
await this.delete()
/*
* Return 204
*/
return this.setResponse(204, false) return this.setResponse(204, false)
} }
@ -366,87 +398,10 @@ SetModel.prototype.asPublicSet = function () {
} }
/* /*
* Helper method to set the response code, result, and body
* *
* Will be used by this.sendResponse() * Everything below this comment is part of the v2 => v3 migration code
* and can be removed once that migration is complete
*/ */
SetModel.prototype.setResponse = function (
status = 200,
error = false,
data = {},
rawData = false
) {
this.response = {
status,
body: rawData
? data
: {
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 (as JSON)
*/
SetModel.prototype.sendResponse = async function (res) {
return res.status(this.response.status).send(this.response.body)
}
/*
* Helper method to send response as YAML
*/
SetModel.prototype.sendYamlResponse = async function (res) {
return res.status(this.response.status).type('yaml').send(yaml.dump(this.response.body))
}
/*
* Update method to determine whether this request is
* part of a test
*/
//UserModel.prototype.isTest = function (body) {
// if (!body.test) 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 */
SetModel.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
}
const migratePerson = (v2) => ({ const migratePerson = (v2) => ({
createdAt: new Date(v2.created ? v2.created : v2.createdAt), createdAt: new Date(v2.created ? v2.created : v2.createdAt),
@ -464,7 +419,7 @@ SetModel.prototype.import = async function (v2user, userId) {
const lut = {} // lookup tabel for v2 handle to v3 id const lut = {} // lookup tabel for v2 handle to v3 id
for (const [handle, person] of Object.entries(v2user.people)) { for (const [handle, person] of Object.entries(v2user.people)) {
const data = { ...migratePerson(person), userId } const data = { ...migratePerson(person), userId }
await this.unguardedCreate(data) await this.createRecord(data)
// Now that we have an ID, we can handle the image // Now that we have an ID, we can handle the image
if (person.picture && person.picture.slice(-4) !== '.svg') { if (person.picture && person.picture.slice(-4) !== '.svg') {
const imgId = `set-${this.record.id}` const imgId = `set-${this.record.id}`

View file

@ -1,59 +1,76 @@
import { hash } from '../utils/crypto.mjs' import { hash } from '../utils/crypto.mjs'
import { log } from '../utils/log.mjs' import { log } from '../utils/log.mjs'
import { clean, i18nUrl } from '../utils/index.mjs' import { clean, i18nUrl } from '../utils/index.mjs'
import { decorateModel } from '../utils/model-decorator.mjs'
/*
* This model handles all user updates
*/
export function SubscriberModel(tools) { export function SubscriberModel(tools) {
this.config = tools.config return decorateModel(this, tools, {
this.prisma = tools.prisma name: 'subscriber',
this.mailer = tools.email encryptedFields: ['email'],
this.decrypt = tools.decrypt })
this.encrypt = tools.encrypt
this.encryptedFields = ['email']
this.clear = {} // For holding decrypted data
return this
} }
/*
* Creates a new subscriber - Takes user input so we need to validate it
* This is an unauthenticated route
*
* @param {body} object - The request body
* @returns {SubscriberModal} object - The SubscriberModel
*/
SubscriberModel.prototype.guardedCreate = async function ({ body }) { SubscriberModel.prototype.guardedCreate = async function ({ body }) {
/*
* Is email set and a string?
*/
if (!body.email || typeof body.email !== 'string') return this.setResponse(400, 'emailMissing') if (!body.email || typeof body.email !== 'string') return this.setResponse(400, 'emailMissing')
/*
* Is language set and a known language?
*/
if (!body.language || !this.config.languages.includes(body.language.toLowerCase())) if (!body.language || !this.config.languages.includes(body.language.toLowerCase()))
return this.setResponse(400, 'languageMissing') return this.setResponse(400, 'languageMissing')
// Clean up email address and hash it /*
* Clean (lowercase + trim) the email address and hash it
*/
const email = clean(body.email) const email = clean(body.email)
const language = body.language.toLowerCase()
const ehash = hash(email) const ehash = hash(email)
log.info(`New newsletter subscriber: ${email}`) /*
* Lowecase the language
*/
const language = body.language.toLowerCase()
// Check whether this is a unit test /*
* Check whether this is a unit test
*/
const isTest = this.isTest(body) const isTest = this.isTest(body)
// Check to see if this email address is already subscribed. /*
* Attempt to read existing subscriber record for this email address
*/
let newSubscriber = false let newSubscriber = false
await this.read({ ehash }) await this.read({ ehash })
if (!this.record) { /*
// No record found. Create subscriber record. * If no record can be found, create a new subscriber record.
newSubscriber = true */
const data = await this.cloak({ ehash, email, language, active: false }) if (!this.record) await this.createRecord({ ehash, email, language, active: false })
try {
this.record = await this.prisma.subscriber.create({ data })
} catch (err) {
log.warn(err, 'Could not create subscriber record')
return this.setResponse(500, 'createSubscriberFailed')
}
}
// Construct the various URLs /*
* Construct the various URLs for the outgoing email
*/
const actionUrl = i18nUrl( const actionUrl = i18nUrl(
`/newsletter/${this.record.active ? 'un' : ''}subscribe/${this.record.id}/${ehash}` `/newsletter/${this.record.active ? 'un' : ''}subscribe/${this.record.id}/${ehash}`
) )
// Send out confirmation email unless it's a test and we don't want to send test emails /*
* Send out confirmation email unless it's a test and we don't want to send test emails
*/
if (!isTest || this.config.use.tests.email) { if (!isTest || this.config.use.tests.email) {
const template = newSubscriber ? 'nlsub' : this.record.active ? 'nlsubact' : 'nlsubinact' const template = newSubscriber ? 'nlsub' : this.record.active ? 'nlsubact' : 'nlsubinact'
await this.mailer.send({ await this.mailer.send({
template, template,
language, language,
@ -66,234 +83,119 @@ SubscriberModel.prototype.guardedCreate = async function ({ body }) {
}) })
} }
/*
* Prepare the return data
*/
const returnData = { language, email } const returnData = { language, email }
if (isTest) { if (isTest) {
returnData.id = this.record.id returnData.id = this.record.id
returnData.ehash = ehash returnData.ehash = ehash
} }
return this.setResponse(200, 'success', { data: returnData }) /*
* Return 200 and the data
*/
return this.setResponse200({ data: returnData })
} }
/*
* Confirms a pending subscription
* This is an unauthenticated route
*
* @param {body} object - The request body
* @returns {SubscriberModal} object - The SubscriberModel
*/
SubscriberModel.prototype.subscribeConfirm = async function ({ body }) { SubscriberModel.prototype.subscribeConfirm = async function ({ body }) {
const { id, ehash } = body /*
if (!id) return this.setResponse(400, 'idMissing') * Validate input and load subscription record
if (!ehash) return this.setResponse(400, 'ehashMissing') */
await this.verifySubscription(body)
// Find subscription /*
await this.read({ ehash }) * If a status code is already set, do not continue
*/
if (this.response?.status) return this
if (!this.record) { /*
// Subscriber not found * Update the status if the subscription is not active
return this.setResponse(404, 'subscriberNotFound') */
} if (this.record.active !== true) await this.update({ active: true })
if (this.record.status !== true) { /*
// Update username * Return 200
try { */
await this.unguardedUpdate({ active: true }) return this.setResponse200()
} catch (err) {
log.warn(err, 'Could not update active state after subscribe confirmation')
return this.setResponse(500, 'subscriberActivationFailed')
}
}
return this.setResponse(200, 'success')
} }
/*
* Confirms a pending unsubscription
* This is an unauthenticated route
*
* @param {body} object - The request body
* @returns {SubscriberModal} object - The SubscriberModel
*/
SubscriberModel.prototype.unsubscribeConfirm = async function ({ body }) { SubscriberModel.prototype.unsubscribeConfirm = async function ({ body }) {
/*
* Validate input and load subscription record
*/
await this.verifySubscription(body)
/*
* If a status code is already set, do not continue
*/
if (this.response?.status) return this
/*
* Remove the record
*/
await this.delete({ id: this.record.id })
/*
* Return 204
*/
return this.setResponse(204)
}
/*
* A helper method to validate input and load the subscription record
*
* @param {body} object - The request body
* @returns {SubscriberModal} object - The SubscriberModel
*/
SubscriberModel.prototype.verifySubscription = async function (body) {
/*
* Get the id and ehash from the body
*/
const { id, ehash } = body const { id, ehash } = body
/*
* Is id set?
*/
if (!id) return this.setResponse(400, 'idMissing') if (!id) return this.setResponse(400, 'idMissing')
/*
* Is ehash set?
*/
if (!ehash) return this.setResponse(400, 'ehashMissing') if (!ehash) return this.setResponse(400, 'ehashMissing')
// Find subscription /*
* Find the subscription record
*/
await this.read({ ehash }) await this.read({ ehash })
if (this.record) { /*
// Remove record * If it is not found, return 404
try { */
await this.unguardedDelete() if (!this.record) return this.setResponse(404, 'subscriberNotFound')
} catch (err) {
log.warn(err, 'Could not remove subscriber')
return this.setResponse(500, 'subscriberRemovalFailed')
}
}
return this.setResponse(200, 'success')
}
/*
* Updates the subscriber data
* Used when we create the data ourselves so we know it's safe
*/
SubscriberModel.prototype.unguardedUpdate = async function (data) {
try {
this.record = await this.prisma.subscriber.update({
where: { id: this.record.id },
data,
})
} catch (err) {
log.warn(err, 'Could not update subscriber record')
process.exit()
return this.setResponse(500, 'updateSubscriberFailed')
}
await this.reveal()
return this.setResponse(200)
}
/*
* Removes the subscriber record
* Used when we call for removal ourselves so we know it's safe
*/
SubscriberModel.prototype.unguardedDelete = async function () {
await this.prisma.subscriber.delete({ where: { id: this.record.id } })
this.record = null
this.clear = null
return this.subscriberExists()
}
/*
* Loads a subscriber from the database based on the where clause you pass it
*
* Stores result in this.record
*/
SubscriberModel.prototype.read = async function (where) {
try {
this.record = await this.prisma.subscriber.findUnique({ where })
} catch (err) {
log.warn({ err, where }, 'Could not read subscriber')
}
this.reveal()
return this.subscriberExists()
}
/*
* Checks this.record and sets a boolean to indicate whether
* the subscription exists or not
*
* Stores result in this.exists
*/
SubscriberModel.prototype.subscriberExists = function () {
this.exists = this.record ? true : false
return this return this
} }
/* /*
* Loads a measurements set from the database based on the where clause you pass it
* In addition prepares it for returning the set data
* *
* Stores result in this.record * Anything below this comment is migration code for the v2 => v3 migration
* and can be safely removed after the migration is done
*/ */
SubscriberModel.prototype.guardedRead = async function ({ params, user }) {
if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
await this.read({ id: parseInt(params.id) })
if (!this.record) return this.setResponse(404)
if (this.record.userId !== user.uid && !this.rbac.bughunter(user)) {
return this.setResponse(403, 'insufficientAccessLevel')
}
return this.setResponse(200, false, {
result: 'success',
set: this.asSet(),
})
}
/*
* Helper method to decrypt at-rest data
*/
SubscriberModel.prototype.reveal = async function () {
this.clear = {}
if (this.record) {
for (const field of this.encryptedFields) {
try {
this.clear[field] = this.decrypt(this.record[field])
} catch (err) {
console.log(err)
}
}
}
return this
}
/*
* Helper method to encrypt at-rest data
*/
SubscriberModel.prototype.cloak = function (data) {
for (const field of this.encryptedFields) {
if (typeof data[field] !== 'undefined') {
data[field] = this.encrypt(data[field])
}
}
return data
}
/*
* Removes the subscriber - No questions asked
*/
SubscriberModel.prototype.unguardedDelete = async function () {
await this.prisma.subscriber.delete({ where: { id: this.record.id } })
this.record = null
this.clear = null
return this.subscriberExists()
}
/*
* Helper method to set the response code, result, and body
*
* Will be used by this.sendResponse()
*/
SubscriberModel.prototype.setResponse = function (
status = 200,
error = false,
data = {},
rawData = false
) {
this.response = {
status,
body: rawData
? data
: {
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.subscriberExists()
}
/*
* Helper method to send response (as JSON)
*/
SubscriberModel.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
*/
SubscriberModel.prototype.isTest = function (body) {
// Disalowing tests in prodution is hard-coded to protect people from themselves
if (this.config.env === 'production' && !this.config.tests.production) return false
if (!body.test) return false
if (body.email && !body.email.split('@').pop() === this.config.tests.domain) return false
return true
}
/* /*
* This is a special route not available for API users * This is a special route not available for API users

File diff suppressed because it is too large Load diff

View file

@ -10,7 +10,9 @@ const headers = { Authorization: `Bearer ${config.token}` }
* Method that does the actual image upload to cloudflare * Method that does the actual image upload to cloudflare
* Use this for a new image that does not yet exist * Use this for a new image that does not yet exist
*/ */
export async function storeImage(props) { export async function storeImage(props, isTest = false) {
if (isTest) return props.id || false
const form = getFormData(props) const form = getFormData(props)
let result let result
try { try {
@ -41,7 +43,8 @@ export async function storeImage(props) {
* Method that does the actual image upload to cloudflare * Method that does the actual image upload to cloudflare
* Use this to replace an existing image * Use this to replace an existing image
*/ */
export async function replaceImage(props) { export async function replaceImage(props, isTest = false) {
if (isTest) return props.id || false
const form = getFormData(props) const form = getFormData(props)
// Ignore errors on delete, probably means the image does not exist // Ignore errors on delete, probably means the image does not exist
try { try {
@ -64,7 +67,8 @@ export async function replaceImage(props) {
* Method that uploads an image to cloudflare * Method that uploads an image to cloudflare
* Use this to merely ensure the image exists (will fail silently if it does) * Use this to merely ensure the image exists (will fail silently if it does)
*/ */
export async function ensureImage(props) { export async function ensureImage(props, isTest = false) {
if (isTest) return props.id || false
const form = getFormData(props) const form = getFormData(props)
let result let result
try { try {
@ -80,7 +84,8 @@ export async function ensureImage(props) {
/* /*
* Method that imports and image from URL and does not bother waiting for the answer * Method that imports and image from URL and does not bother waiting for the answer
*/ */
export async function importImage(props) { export async function importImage(props, isTest = false) {
if (isTest) return props.id || false
// Bypass slow ass upload when testing import // Bypass slow ass upload when testing import
if (!config.import) return `default-avatar` if (!config.import) return `default-avatar`

View file

@ -0,0 +1,29 @@
import fetch from 'node-fetch'
import { githubToken as token } from '../config.mjs'
const issueApi = `https://api.github.com/repos/freesewing/freesewing/issues`
export async function createIssue(body) {
let response
try {
response = await fetch(issueApi, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json',
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
if (response.status === 201) response = await response.json()
else {
console.log(response)
response = false
}
} catch (error) {
console.error('An error occurred while creating a GitHub issue:', error.message)
response = false
}
return response
}

View file

@ -0,0 +1,354 @@
import { log } from '../utils/log.mjs'
import yaml from 'js-yaml'
import { hashPassword } from '../utils/crypto.mjs'
import { asJson, capitalize } from '../utils/index.mjs'
/*
* Models will be attached on-demand
*/
import { ApikeyModel } from '../models/apikey.mjs'
import { ConfirmationModel } from '../models/confirmation.mjs'
import { CuratedSetModel } from '../models/curated-set.mjs'
import { FlowModel } from '../models/flow.mjs'
import { IssueModel } from '../models/issue.mjs'
import { PatternModel } from '../models/pattern.mjs'
import { SetModel } from '../models/set.mjs'
import { SubscriberModel } from '../models/subscriber.mjs'
import { UserModel } from '../models/user.mjs'
/*
* This adds a bunch of stuff to a model that is common
* between various models, and thus abstracted here
*/
export function decorateModel(Model, tools, modelConfig) {
/*
* Make config available
*/
Model.config = tools.config
/*
* Set the name
*/
Model.name = modelConfig.name
/*
* Attach all tooling
*/
Model.prisma = tools.prisma
Model.decrypt = tools.decrypt
Model.encrypt = tools.encrypt
Model.mfa = tools.mfa
Model.rbac = tools.rbac
Model.mailer = tools.email
/*
* Set encrypted fields based on config
*/
Model.encryptedFields = modelConfig.encryptedFields || []
/*
* Create object to hold decrypted data
*/
Model.clear = {}
/*
* Make various helper models available, as configured
*/
if (modelConfig.models && Array.isArray(modelConfig.models)) {
if (modelConfig.models.includes('apikey')) Model.Apikey = new ApikeyModel(tools)
if (modelConfig.models.includes('confirmation'))
Model.Confirmation = new ConfirmationModel(tools)
if (modelConfig.models.includes('cset')) Model.CuratedSet = new CuratedSetModel(tools)
if (modelConfig.models.includes('flow')) Model.Flow = new FlowModel(tools)
if (modelConfig.models.includes('issue')) Model.Issue = new IssueModel(tools)
if (modelConfig.models.includes('pattern')) Model.Pattern = new PatternModel(tools)
if (modelConfig.models.includes('set')) Model.Set = new SetModel(tools)
if (modelConfig.models.includes('subscriber')) Model.Subscriber = new SubscriberModel(tools)
if (modelConfig.models.includes('user')) Model.User = new UserModel(tools)
}
/*
* Loads a model instance from the database based on the where clause you pass it
*
* Stores result in this.record
*/
Model.read = async function (where, include = {}) {
if ((where.id && typeof where.id === 'number' && isNaN(where.id)) || where.id === null) {
return this.recordExists()
}
try {
this.record = this.unserialize(
await this.prisma[modelConfig.name].findUnique({ where, include })
)
} catch (err) {
log.warn({ err, where }, `Could not read ${modelConfig.name}`)
return this.recordExists()
}
await this.reveal()
return this.recordExists()
}
/*
* Helper method to decrypt at-rest data
*/
Model.reveal = async function () {
this.clear = {}
if (this.record) {
for (const field of this.encryptedFields) {
this.clear[field] = await this.decrypt(this.record[field])
}
}
/*
* Handle nested records with JSON fields
*/
if (this.record?.cset) {
for (const lang of this.config.languages) {
const key = `tags${capitalize(lang)}`
if (this.record.cset[key]) this.record.cset[key] = JSON.parse(this.record.cset[key])
}
if (this.record.cset.measies) this.record.cset.measies = JSON.parse(this.record.cset.measies)
}
if (this.record?.set) this.record.set = this.Set.revealSet(this.record.set)
return this
}
/*
* Helper method to encrypt at-rest data
*/
Model.cloak = function (data) {
for (const field of this.encryptedFields) {
if (typeof data[field] !== 'undefined') {
data[field] = this.encrypt(data[field])
}
}
/*
* Password needs to be hashed too
*/
if (data.password && typeof data.password === 'string') {
data.password = asJson(hashPassword(data.password))
}
return data
}
/*
* Checks this.record and sets a boolean to indicate whether
* the record exists or not
*
* Stores result in this.exists
*/
Model.recordExists = function () {
this.exists = this.record ? true : false
return this
}
/*
* A helper method to serialize data, making sure it's fit for writing to the database
*/
Model.serialize = function (data) {
if (this.name === 'curatedSet') {
/*
* Serialize to JSON
* See https://github.com/prisma/prisma/issues/3786
*/
if (data.measies && typeof data.measies === 'object')
data.measies = JSON.stringify(data.measies)
for (const lang of this.config.languages) {
const key = `tags${capitalize(lang)}`
if (data[key] && Array.isArray(data[key])) data[key] = JSON.stringify(data[key] || [])
}
}
return data
}
/*
* A helper method to unserialize data, making sure it's fit for sending to the client
*/
Model.unserialize = function (data) {
if (this.name === 'curatedSet') {
/*
* Unserialize from JSON
* See https://github.com/prisma/prisma/issues/3786
*/
if (data.measies && typeof data.measies === 'string') data.measies = JSON.parse(data.measies)
for (const lang of this.config.languages) {
const key = `tags${capitalize(lang)}`
if (data[key] && typeof data[key] === 'string') data[key] = JSON.parse(data[key])
}
}
return data
}
/*
* Creates a record based on the model data
* Used when we create the data ourselves so we know it's safe
*/
Model.createRecord = async function (data) {
try {
const cloaked = await this.cloak(this.serialize(data))
this.record = await this.prisma[modelConfig.name].create({ data: cloaked })
} catch (err) {
/*
* Some error occured. Log warning and return 500
*/
log.warn(err, 'Could not create set')
return this.setResponse(500, 'createSetFailed')
}
return this
}
/*
* Updates the model data
* Used when we create the data ourselves so we know it's safe
*/
Model.update = async function (data, include = {}) {
try {
const cloaked = await this.cloak(this.serialize(data))
this.record = this.unserialize(
await this.prisma[modelConfig.name].update({
where: { id: this.record.id },
include,
data: cloaked,
})
)
} catch (err) {
log.warn(err, `Could not update ${modelConfig.name} record`)
return this.setResponse(500, 'updateUserFailed')
}
await this.reveal()
return this.setResponse(200)
}
/*
* Deletes the model data
*/
Model.delete = async function () {
await this.prisma[modelConfig.name].delete({ where: { id: this.record.id } })
this.record = null
this.clear = null
return this.recordExists()
}
/*
* Helper method to set the response code, result, and body
*
* Will be used by this.sendResponse()
*/
Model.setResponse = function (status = 200, result = 'success', data = {}) {
this.response = {
status,
body: { result, ...data },
}
if (status > 201) {
this.response.body.error = result
this.response.body.result = 'error'
this.error = true
} else this.error = false
if (status === 404) this.response.body = null
return this.recordExists()
}
/*
* Helper method to set response code 200, as it's so common
*/
Model.setResponse200 = function (data = {}) {
return this.setResponse(200, 'success', data)
}
/*
* Helper method to set response code 201, as it's so common
*/
Model.setResponse201 = function (data = {}) {
return this.setResponse(201, 'created', data)
}
/*
* Helper method to send response
*/
Model.sendResponse = async function (res) {
return res.status(this.response.status).send(this.response.body)
}
/*
* Helper method to send response as YAML
*/
Model.sendYamlResponse = async function (res) {
let body
try {
body = yaml.dump(this.response.body)
} catch (err) {
console.log(err)
}
return res.status(this.response.status).type('yaml').send(body)
}
/*
* Helper method to sanitize measurments
*/
Model.sanitizeMeasurements = function (input) {
const measies = {}
if (typeof input !== 'object') return input
for (const [m, val] of Object.entries(input)) {
if (this.config.measies.includes(m) && typeof val === 'number' && val > 0) measies[m] = val
}
return measies
}
/*
* Helper method to determine whether this request is part of a (unit) test
*/
Model.isTest = function (body) {
/*
* Test in production need to be explicitly allowed
*/
if (this.config.env === 'production' && !this.config.tests.production) return false
/*
* If there's not test in the body, it's not a test
*/
if (!body.test) return false
/*
* If the authenticated user does not use the configured test domain for email, it's not a test
*/
if (this.clear?.email && !this.clear.email.split('@').pop() === this.config.tests.domain)
return false
/*
* If the email used in the POST body does not use the configured test domain for email, it's not a test
*/
if (body.email && !body.email.split('@').pop() === this.config.tests.domain) return false
/*
* Looks like it's a test
*/
return true
}
/*
* Helper method to troubleshoot requests by outputting timing data
*/
Model.time = function (key) {
if (this.timer)
log.info(`Timer split [${key ? key : modalConfig.name}] ${Date.now() - this.timer}ms`)
else {
this.timer = Date.now()
log.info(`Timer start [${key ? key : modalConfig.name}] 0ms`)
}
return this
}
return Model
}

View file

@ -4,6 +4,7 @@ import { capitalize } from '../src/utils/index.mjs'
export const curatedSetTests = async (chai, config, expect, store) => { export const curatedSetTests = async (chai, config, expect, store) => {
const data = { const data = {
jwt: { jwt: {
test: true,
nameDe: 'Beispielmessungen A', nameDe: 'Beispielmessungen A',
nameEn: 'Example measurements A', nameEn: 'Example measurements A',
nameEs: 'Medidas de ejemplo A', nameEs: 'Medidas de ejemplo A',
@ -28,6 +29,7 @@ export const curatedSetTests = async (chai, config, expect, store) => {
}, },
}, },
key: { key: {
test: true,
nameDe: 'Beispielmessungen B', nameDe: 'Beispielmessungen B',
nameEn: 'Example measurements B', nameEn: 'Example measurements B',
nameEs: 'Medidas de ejemplo B', nameEs: 'Medidas de ejemplo B',
@ -83,16 +85,16 @@ export const curatedSetTests = async (chai, config, expect, store) => {
.end((err, res) => { .end((err, res) => {
expect(err === null).to.equal(true) expect(err === null).to.equal(true)
expect(res.status).to.equal(201) expect(res.status).to.equal(201)
expect(res.body.result).to.equal(`success`) expect(res.body.result).to.equal(`created`)
for (const [key, val] of Object.entries(data[auth])) { for (const [key, val] of Object.entries(data[auth])) {
if (!['measies', 'img'].includes(key)) if (!['measies', 'img', 'test'].includes(key)) {
expect(JSON.stringify(res.body.curatedSet[key])).to.equal(JSON.stringify(val)) expect(JSON.stringify(res.body.curatedSet[key])).to.equal(JSON.stringify(val))
}
} }
store.curatedSet[auth] = res.body.curatedSet store.curatedSet[auth] = res.body.curatedSet
done() done()
}) })
}).timeout(5000) }).timeout(5000)
for (const field of ['name', 'notes']) { for (const field of ['name', 'notes']) {
for (const lang of config.languages) { for (const lang of config.languages) {
const langField = field + capitalize(lang) const langField = field + capitalize(lang)
@ -234,7 +236,7 @@ export const curatedSetTests = async (chai, config, expect, store) => {
.end((err, res) => { .end((err, res) => {
expect(err === null).to.equal(true) expect(err === null).to.equal(true)
expect(res.status).to.equal(201) expect(res.status).to.equal(201)
expect(res.body.result).to.equal(`success`) expect(res.body.result).to.equal(`created`)
expect(typeof res.body.error).to.equal(`undefined`) expect(typeof res.body.error).to.equal(`undefined`)
expect(typeof res.body.set.id).to.equal(`number`) expect(typeof res.body.set.id).to.equal(`number`)
expect(res.body.set.name).to.equal(store.curatedSet[auth].nameNl + '_updated') expect(res.body.set.name).to.equal(store.curatedSet[auth].nameNl + '_updated')

View file

@ -0,0 +1,58 @@
import { cat } from './cat.mjs'
import { capitalize } from '../src/utils/index.mjs'
export const flowTests = async (chai, config, expect, store) => {
const auths = ['jwt', 'key']
for (const auth of auths) {
describe(`${store.icon('flow', auth)} Flow tests (${auth})`, () => {
it(`${store.icon('set', auth)} Should request a translator invite (${auth})`, (done) => {
chai
.request(config.api)
.post(`/flows/translator-invite/${auth}`)
.set(
'Authorization',
auth === 'jwt'
? 'Bearer ' + store.account.token
: 'Basic ' +
new Buffer(`${store.account.apikey.key}:${store.account.apikey.secret}`).toString(
'base64'
)
)
.send({
language: 'nl',
})
.end((err, res) => {
expect(err === null).to.equal(true)
expect(res.status).to.equal(200)
expect(res.body.result).to.equal(`success`)
done()
})
})
it(`${store.icon('set', auth)} Should suggest a new language (${auth})`, (done) => {
chai
.request(config.api)
.post(`/flows/language-suggestion/${auth}`)
.set(
'Authorization',
auth === 'jwt'
? 'Bearer ' + store.account.token
: 'Basic ' +
new Buffer(`${store.account.apikey.key}:${store.account.apikey.secret}`).toString(
'base64'
)
)
.send({
language: 'cn',
})
.end((err, res) => {
expect(err === null).to.equal(true)
expect(res.status).to.equal(200)
expect(res.body.result).to.equal(`success`)
done()
})
})
})
}
}

View file

@ -6,6 +6,8 @@ import { setTests } from './set.mjs'
import { curatedSetTests } from './curated-set.mjs' import { curatedSetTests } from './curated-set.mjs'
import { patternTests } from './pattern.mjs' import { patternTests } from './pattern.mjs'
import { subscriberTests } from './subscriber.mjs' import { subscriberTests } from './subscriber.mjs'
import { flowTests } from './flow.mjs'
import { issueTests } from './issue.mjs'
import { setup } from './shared.mjs' import { setup } from './shared.mjs'
const runTests = async (...params) => { const runTests = async (...params) => {
@ -17,6 +19,8 @@ const runTests = async (...params) => {
await curatedSetTests(...params) await curatedSetTests(...params)
await patternTests(...params) await patternTests(...params)
await subscriberTests(...params) await subscriberTests(...params)
await flowTests(...params)
await issueTests(...params)
} }
// Load initial data required for tests // Load initial data required for tests

View file

@ -0,0 +1,22 @@
export const issueTests = async (chai, config, expect, store) => {
describe(`${store.icon('issue')} Issue tests`, () => {
it(`${store.icon('set')} Should create an issue`, (done) => {
chai
.request(config.api)
.post(`/issues`)
.send({
title: '[test] This issue was created by a unit test',
body: `This issue was created by a unit test. Feel free to close it or even delete it.`,
assignees: ['joostdecock'],
labels: [':test_tube: tests'],
})
.end((err, res) => {
expect(err === null).to.equal(true)
expect(res.status).to.equal(201)
expect(res.body.result).to.equal(`created`)
expect(res.body.issue.state).to.equal(`open`)
done()
})
})
})
}

View file

@ -6,7 +6,7 @@ export const mfaTests = async (chai, config, expect, store) => {
key: store.altaccount, key: store.altaccount,
} }
for (const auth in secret) { for (const auth of ['jwt']) {
describe(`${store.icon('mfa', auth)} Setup Multi-Factor Authentication (MFA) (${auth})`, () => { describe(`${store.icon('mfa', auth)} Setup Multi-Factor Authentication (MFA) (${auth})`, () => {
it(`${store.icon('mfa')} Should return 400 on MFA enable without proper value`, (done) => { it(`${store.icon('mfa')} Should return 400 on MFA enable without proper value`, (done) => {
chai chai
@ -21,7 +21,7 @@ export const mfaTests = async (chai, config, expect, store) => {
'base64' 'base64'
) )
) )
.send({ mfa: 'yes' }) .send({ mfa: 'yes', test: true })
.end((err, res) => { .end((err, res) => {
expect(err === null).to.equal(true) expect(err === null).to.equal(true)
expect(res.status).to.equal(400) expect(res.status).to.equal(400)
@ -44,7 +44,7 @@ export const mfaTests = async (chai, config, expect, store) => {
'base64' 'base64'
) )
) )
.send({ mfa: true }) .send({ mfa: true, test: true })
.end((err, res) => { .end((err, res) => {
expect(err === null).to.equal(true) expect(err === null).to.equal(true)
expect(res.status).to.equal(200) expect(res.status).to.equal(200)
@ -72,6 +72,7 @@ export const mfaTests = async (chai, config, expect, store) => {
) )
.send({ .send({
mfa: true, mfa: true,
test: true,
secret: secret[auth].mfaSecret, secret: secret[auth].mfaSecret,
token: authenticator.generate(secret[auth].mfaSecret), token: authenticator.generate(secret[auth].mfaSecret),
}) })
@ -96,7 +97,7 @@ export const mfaTests = async (chai, config, expect, store) => {
'base64' 'base64'
) )
) )
.send({ mfa: true }) .send({ mfa: true, test: true })
.end((err, res) => { .end((err, res) => {
expect(err === null).to.equal(true) expect(err === null).to.equal(true)
expect(res.status).to.equal(400) expect(res.status).to.equal(400)
@ -121,6 +122,7 @@ export const mfaTests = async (chai, config, expect, store) => {
) )
.send({ .send({
mfa: true, mfa: true,
test: true,
secret: secret[auth].mfaSecret, secret: secret[auth].mfaSecret,
token: authenticator.generate(secret[auth].mfaSecret), token: authenticator.generate(secret[auth].mfaSecret),
}) })
@ -138,6 +140,7 @@ export const mfaTests = async (chai, config, expect, store) => {
.request(config.api) .request(config.api)
.post('/signin') .post('/signin')
.send({ .send({
test: true,
username: secret[auth].username, username: secret[auth].username,
password: secret[auth].password, password: secret[auth].password,
}) })
@ -155,6 +158,7 @@ export const mfaTests = async (chai, config, expect, store) => {
.request(config.api) .request(config.api)
.post('/signin') .post('/signin')
.send({ .send({
test: true,
username: secret[auth].username, username: secret[auth].username,
password: secret[auth].password, password: secret[auth].password,
token: authenticator.generate(secret[auth].mfaSecret), token: authenticator.generate(secret[auth].mfaSecret),
@ -173,6 +177,7 @@ export const mfaTests = async (chai, config, expect, store) => {
.request(config.api) .request(config.api)
.post('/signin') .post('/signin')
.send({ .send({
test: true,
username: secret[auth].username, username: secret[auth].username,
password: secret[auth].password, password: secret[auth].password,
token: '1234', token: '1234',
@ -200,6 +205,7 @@ export const mfaTests = async (chai, config, expect, store) => {
) )
) )
.send({ .send({
test: true,
mfa: false, mfa: false,
password: secret[auth].password, password: secret[auth].password,
token: authenticator.generate(secret[auth].mfaSecret), token: authenticator.generate(secret[auth].mfaSecret),

View file

@ -18,9 +18,11 @@ export const patternTests = async (chai, config, expect, store) => {
) )
) )
.send({ .send({
test: true,
design: 'aaron', design: 'aaron',
settings: { settings: {
sa: 5, sa: 5,
measurements: store.account.sets.her.measurements,
}, },
name: 'Just a test', name: 'Just a test',
notes: 'These are my notes', notes: 'These are my notes',
@ -34,7 +36,7 @@ export const patternTests = async (chai, config, expect, store) => {
.end((err, res) => { .end((err, res) => {
expect(err === null).to.equal(true) expect(err === null).to.equal(true)
expect(res.status).to.equal(201) expect(res.status).to.equal(201)
expect(res.body.result).to.equal(`success`) expect(res.body.result).to.equal(`created`)
expect(typeof res.body.pattern?.id).to.equal('number') expect(typeof res.body.pattern?.id).to.equal('number')
expect(res.body.pattern.userId).to.equal(store.account.id) expect(res.body.pattern.userId).to.equal(store.account.id)
expect(res.body.pattern.setId).to.equal(store.account.sets.her.id) expect(res.body.pattern.setId).to.equal(store.account.sets.her.id)

View file

@ -54,7 +54,7 @@ export const setTests = async (chai, config, expect, store) => {
.end((err, res) => { .end((err, res) => {
expect(err === null).to.equal(true) expect(err === null).to.equal(true)
expect(res.status).to.equal(201) expect(res.status).to.equal(201)
expect(res.body.result).to.equal(`success`) expect(res.body.result).to.equal(`created`)
for (const [key, val] of Object.entries(data[auth])) { for (const [key, val] of Object.entries(data[auth])) {
if (!['measies', 'img', 'test'].includes(key)) expect(res.body.set[key]).to.equal(val) if (!['measies', 'img', 'test'].includes(key)) expect(res.body.set[key]).to.equal(val)
} }
@ -325,8 +325,8 @@ export const setTests = async (chai, config, expect, store) => {
) )
.end((err, res) => { .end((err, res) => {
expect(err === null).to.equal(true) expect(err === null).to.equal(true)
expect(res.status).to.equal(200) expect(res.status).to.equal(201)
expect(res.body.result).to.equal(`success`) expect(res.body.result).to.equal(`created`)
expect(typeof res.body.error).to.equal(`undefined`) expect(typeof res.body.error).to.equal(`undefined`)
expect(typeof res.body.set.id).to.equal(`number`) expect(typeof res.body.set.id).to.equal(`number`)
done() done()
@ -352,8 +352,8 @@ export const setTests = async (chai, config, expect, store) => {
.end((err, res) => { .end((err, res) => {
if (store.set[auth].public) { if (store.set[auth].public) {
expect(err === null).to.equal(true) expect(err === null).to.equal(true)
expect(res.status).to.equal(200) expect(res.status).to.equal(201)
expect(res.body.result).to.equal(`success`) expect(res.body.result).to.equal(`created`)
expect(typeof res.body.error).to.equal(`undefined`) expect(typeof res.body.error).to.equal(`undefined`)
expect(typeof res.body.set.id).to.equal(`number`) expect(typeof res.body.set.id).to.equal(`number`)
} else { } else {
@ -365,7 +365,6 @@ export const setTests = async (chai, config, expect, store) => {
done() done()
}) })
}) })
// TODO: // TODO:
// - Clone set // - Clone set
// - Clone set accross accounts of they are public // - Clone set accross accounts of they are public

View file

@ -41,7 +41,9 @@ export const setup = async () => {
key: '🎟️ ', key: '🎟️ ',
set: '🧕 ', set: '🧕 ',
pattern: '👕 ', pattern: '👕 ',
subscriber: '📬', subscriber: '📬 ',
flow: '🪁 ',
issue: '🚩 ',
}, },
randomString, randomString,
} }

View file

@ -52,7 +52,7 @@ export const userTests = async (chai, config, expect, store) => {
expect(res.status).to.equal(201) expect(res.status).to.equal(201)
expect(res.type).to.equal('application/json') expect(res.type).to.equal('application/json')
expect(res.charset).to.equal('utf-8') expect(res.charset).to.equal('utf-8')
expect(res.body.result).to.equal(`success`) expect(res.body.result).to.equal(`created`)
expect(res.body.email).to.equal(fields.email) expect(res.body.email).to.equal(fields.email)
done() done()
}) })