diff --git a/sites/backend/src/config.mjs b/sites/backend/src/config.mjs index 7667b0193a0..b1d0c1179f3 100644 --- a/sites/backend/src/config.mjs +++ b/sites/backend/src/config.mjs @@ -10,26 +10,41 @@ import { postConfig } from '../local-config.mjs' import { roles } from '../../../config/roles.mjs' 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 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 ? process.env.BACKEND_ENC_KEY : 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') => { if (['yes', '1', 'true'].includes(input.toLowerCase())) return true return false } -// Save ourselves some typing +/* + * Save ourselves some typing + */ const crowdinProject = 'https://translate.freesewing.org/project/freesewing/' -// Construct config object +/* + * Construct config object + */ const baseConfig = { // Environment env: process.env.NODE_ENV || 'development', @@ -69,6 +84,16 @@ const baseConfig = { encryption: { 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: { token: process.env.BACKEND_GITHUB_TOKEN, }, @@ -87,8 +112,8 @@ const baseConfig = { audience: process.env.BACKEND_JWT_ISSUER || 'freesewing.org', expiresIn: process.env.BACKEND_JWT_EXPIRY || '7d', }, - languages: ['en', 'de', 'es', 'fr', 'nl', 'uk'], - translations: ['de', 'es', 'fr', 'nl', 'uk'], + languages, + translations: languages.filter((lang) => lang !== 'en'), measies: measurements, mfa: { 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`, token: process.env.BACKEND_CLOUDFLARE_IMAGES_TOKEN || 'fixmeSetCloudflareToken', 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 export const cloudflareImages = config.cloudflareImages || {} export const website = config.website +export const githubToken = config.github.token const vars = { BACKEND_DB_URL: ['required', 'db.url'], @@ -235,10 +262,10 @@ if (envToBool(process.env.BACKEND_USE_CLOUDFLARE_IMAGES)) { // Vars for Github integration if (envToBool(process.env.BACKEND_ENABLE_GITHUB)) { vars.BACKEND_GITHUB_TOKEN = 'requiredSecret' - vars.BACKEND_GITHUB_USER = 'required' - vars.BACKEND_GITHUB_USER_NAME = 'required' - vars.BACKEND_GITHUB_USER_EMAIL = 'required' - vars.BACKEND_GITHUB_NOTIFY_DEFAULT_USER = 'required' + vars.BACKEND_GITHUB_USER = 'optional' + vars.BACKEND_GITHUB_USER_NAME = 'optional' + vars.BACKEND_GITHUB_USER_EMAIL = 'optional' + vars.BACKEND_GITHUB_NOTIFY_DEFAULT_USER = 'optional' } // Vars for Oauth via Github integration if (envToBool(process.env.BACKEND_ENABLE_OAUTH_GITHUB)) { diff --git a/sites/backend/src/controllers/apikeys.mjs b/sites/backend/src/controllers/apikeys.mjs index fc1ddeda157..d091fc53ed1 100644 --- a/sites/backend/src/controllers/apikeys.mjs +++ b/sites/backend/src/controllers/apikeys.mjs @@ -67,7 +67,7 @@ ApikeysController.prototype.whoami = async (req, res, tools) => { key: key[0].id, level: key[0].level, expiresAt: key[0].expiresAt, - name: key[0].name, + name: Apikey.decrypt(key[0].name), userId: key[0].userId, }, }) diff --git a/sites/backend/src/models/apikey.mjs b/sites/backend/src/models/apikey.mjs index 02fad48f6df..90e688b9cee 100644 --- a/sites/backend/src/models/apikey.mjs +++ b/sites/backend/src/models/apikey.mjs @@ -1,166 +1,289 @@ import { log } from '../utils/log.mjs' import { hashPassword, randomString, verifyPassword } from '../utils/crypto.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) { - this.config = tools.config - this.prisma = tools.prisma - this.rbac = tools.rbac - this.User = new UserModel(tools) - - return this -} - -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) + /* + * See utils/model-decorator.mjs for details + */ + return decorateModel(this, tools, { + name: 'apikey', + encryptedFields: ['name'], + models: ['user'], + }) } +/* + * 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) { - 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) + + /* + * Store result in the verified property + */ this.verified = valid 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 }) { + /* + * Enforece RBAC + */ 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') - 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.userId !== user.uid) { - // Not own key - only admin can do that - if (!this.rbac.admin(user)) return this.setResponse(403, 'insufficientAccessLevel') + /* + * Only admins can read other users + */ + 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: { key: this.record.id, level: this.record.level, createdAt: this.record.createdAt, expiresAt: this.record.expiresAt, - name: this.record.name, + name: this.clear.name, 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 }) { + /* + * Enforece RBAC + */ 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') - 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.userId !== user.uid) { - // Not own key - only admin can do that - if (!this.rbac.admin(user)) return this.setResponse(403, 'insufficientAccessLevel') + /* + * Only admins can delete other users + */ + 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) } -ApikeyModel.prototype.unguardedRead = async function (where) { - this.record = await this.prisma.apikey.findUnique({ where }) - - return this -} - -ApikeyModel.prototype.unguardedDelete = async function () { - await this.prisma.apikey.delete({ where: { id: this.record.id } }) - this.record = null - this.clear = null - - return this.setExists() -} - +/* + * Returns all API keys for a user with uid + * + * @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.userApikeys = async function (uid) { + /* + * Guard against missing input + */ if (!uid) return false + + /* + * Wrap async code with try ... catch + */ let keys try { + /* + * Attempt to read records from database + */ keys = await this.prisma.apikey.findMany({ where: { userId: uid } }) } catch (err) { + /* + * Something went wrong, log a warning and return 404 + */ 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) => { delete key.secret + key.name = this.decrypt(key.name) + 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 }) { + /* + * Do we have a POST body? + */ if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing') + + /* + * Is the name set? + */ if (!body.name) return this.setResponse(400, 'nameMissing') + + /* + * Is the level set? + */ if (!body.level) return this.setResponse(400, 'levelMissing') + + /* + * Is level numeric? + */ 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') + + /* + * Is expiresIn set? + */ if (!body.expiresIn) return this.setResponse(400, 'expiresInMissing') + + /* + * Is expiresIn numberic? + */ if (typeof body.expiresIn !== 'number') return this.setResponse(400, 'expiresIsNotNumeric') + + /* + * Is expiresIn above the maximum? + */ if (body.expiresIn > this.config.apikeys.maxExpirySeconds) return this.setResponse(400, 'expiresIsHigherThanMaximum') - // Load user making the call + /* + * Load authenticated user from the database + */ 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) + + /* + * Calculate expiry date + */ const expiresAt = new Date(Date.now() + body.expiresIn * 1000) + /* + * Attempt to create the record in the database + */ try { this.record = await this.prisma.apikey.create({ - data: { + data: this.cloak({ expiresAt, name: body.name, level: body.level, secret: asJson(hashPassword(secret)), userId: user.uid, - }, + }), }) } catch (err) { + /* + * That did not work. Log and error and return 500 + */ log.warn(err, 'Could not create apikey') return this.setResponse(500, 'createApikeyFailed') } - return this.setResponse(201, 'created', { + return this.setResponse201({ apikey: { key: this.record.id, secret, level: this.record.level, createdAt: this.record.createdAt, expiresAt: this.record.expiresAt, - name: this.record.name, + name: body.name, userId: this.record.userId, }, }) diff --git a/sites/backend/src/models/confirmation.mjs b/sites/backend/src/models/confirmation.mjs index ca402eb259f..e8a539c0a82 100644 --- a/sites/backend/src/models/confirmation.mjs +++ b/sites/backend/src/models/confirmation.mjs @@ -1,112 +1,58 @@ 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) { - this.config = tools.config - this.prisma = tools.prisma - this.decrypt = tools.decrypt - this.encrypt = tools.encrypt - this.encryptedFields = ['data'] - this.clear = {} // For holding decrypted 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, - }, + /* + * See utils/model-decorator.mjs for details + */ + return decorateModel(this, tools, { + name: 'confirmation', + encryptedFields: ['data'], }) - 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 () { - this.clear = {} - if (this.record) { - for (const field of this.encryptedFields) { - this.clear[field] = this.decrypt(this.record[field]) - } - } - return this +ConfirmationModel.prototype.guardedRead = async function ({ params }) { + /* + * Is the id set? + */ + if (typeof params.id === 'undefined') return this.setResponse(404) + + /* + * 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) } diff --git a/sites/backend/src/models/curated-set.mjs b/sites/backend/src/models/curated-set.mjs index 5d4cf46c37e..074501a8563 100644 --- a/sites/backend/src/models/curated-set.mjs +++ b/sites/backend/src/models/curated-set.mjs @@ -2,21 +2,43 @@ import { capitalize } from '../utils/index.mjs' import { log } from '../utils/log.mjs' import { storeImage } from '../utils/cloudflare-images.mjs' import yaml from 'js-yaml' +import { decorateModel } from '../utils/model-decorator.mjs' +/* + * This model handles all curated set updates + */ export function CuratedSetModel(tools) { - this.config = tools.config - this.prisma = tools.prisma - this.rbac = tools.rbac - - return this + return decorateModel(this, tools, { name: 'curatedSet' }) } +/* + * Creates a curated set + * + * @param {body} object - The request body + * @returns {CuratedSetModel} object - The CureatedSetModel + */ CuratedSetModel.prototype.guardedCreate = async function ({ body, user }) { + /* + * Enforce RBAC + */ 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') + + /* + * Is nameEn set? + */ 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 = {} for (const lang of this.config.languages) { for (const field of ['name', 'notes']) { @@ -26,105 +48,101 @@ CuratedSetModel.prototype.guardedCreate = async function ({ body, user }) { const key = 'tags' + capitalize(lang) 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) 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 - // Create record - await this.unguardedCreate(data) + /* + * Create the database record + */ + await this.createRecord(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: `cset-${this.record.id}`, - metadata: { user: user.uid }, - b64: body.img, - }) - : false + /* + * Now that we have a record and ID, we can update the image after uploading it to cloudflare + */ + const img = await storeImage( + { + id: `cset-${this.record.id}`, + metadata: { user: user.uid }, + b64: body.img, + }, + this.isTest(body) + ) - 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() }) -} - -CuratedSetModel.prototype.unguardedCreate = async function (data) { - // 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() + /* + * Record created, return data in the proper format + */ + return this.setResponse201({ curatedSet: this.asCuratedSet() }) } /* * 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 + * @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) { + /* + * Read record from database + */ await this.read({ id: parseInt(params.id) }) + /* + * If no format is specified, return as object + */ if (!format) - return this.setResponse(200, false, { + return this.setResponse200({ result: 'success', 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 {list} array - The list of curated sets */ CuratedSetModel.prototype.allCuratedSets = async function () { + /* + * Attempt to read all curates sets from the database + */ let curatedSets try { curatedSets = await this.prisma.curatedSet.findMany() } catch (err) { log.warn(`Failed to search curated sets: ${err}`) } + + /* + * Iterate over list to do some housekeeping and JSON wrangling + */ const list = [] for (const curatedSet of curatedSets) { - // FIXME: Convert object to JSON. See https://github.com/prisma/prisma/issues/3786 const asPojo = { ...curatedSet } + /* + * We need to parse this from JSON + * See https://github.com/prisma/prisma/issues/3786 + */ asPojo.measies = JSON.parse(asPojo.measies) for (const lang of this.config.languages) { const key = `tags${capitalize(lang)}` @@ -140,152 +158,174 @@ CuratedSetModel.prototype.allCuratedSets = async function () { * Clones a curated measurements set (into a regular set) * 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) { + /* + * Enforce RBAC + */ if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel') + + /* + * Verify JWT + */ if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') + + /* + * Is language set? + */ if (!body.language || !this.config.languages.includes(body.language)) return this.setResponse(403, 'languageMissing') + /* + * Read record from database + */ await this.read({ id: parseInt(params.id) }) - // Clone curated set - const data = {} + /* + * Create data for the cloned set + */ const lang = capitalize(body.language.toLowerCase()) - data.name = this.record[`name${lang}`] - data.notes = this.record[`notes${lang}`] - data.measies = this.record.measies + const data = { + lang, + 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 }) 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 * 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 }) { + /* + * Enforce RBAC + */ 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') + + /* + * Attempt to read database record + */ await this.read({ id: parseInt(params.id) }) - // Prepare data + /* + * Prepare data for updating the record + */ const data = {} + /* + * Unlike a regular set, curated set have notes and name in each language + */ for (const lang of this.config.languages) { for (const field of ['name', 'notes']) { const key = field + capitalize(lang) if (body[key] && typeof body[key] === 'string') data[key] = body[key] } } - // Measurements - const measies = {} + /* + * Handle the measurements + */ 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.record.measies, ...measies } - for (const key of remove) delete data.measies[key] + data.measies = this.sanitizeMeasurements({ + ...this.record.measies, + ...body.measies, + }) } - // Image (img) + /* + * Handle the image, if there is one + */ if (typeof body.img === 'string') { - const img = await storeImage({ - id: `cset-${this.record.id}`, - metadata: { user: this.user.uid }, - b64: body.img, - }) + const img = await storeImage( + { + id: `cset-${this.record.id}`, + metadata: { user: this.user.uid }, + b64: body.img, + }, + this.isTest(body) + ) 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 - */ -CuratedSetModel.prototype.unguardedDelete = async function () { - await this.prisma.curatedSet.delete({ where: { id: this.record.id } }) - this.record = null - this.clear = null - - return this.curatedSetExists() -} - -/* - * Removes the set - Checks permissions + * Removes the set - But checks permissions + * + * @param {params} object - The (URL) params from the request + * @param {user} string - The user object as loaded by auth middleware + * @returns {CuratedSetModel} object - The CureatedSetModel */ CuratedSetModel.prototype.guardedDelete = async function ({ params, user }) { + /* + * Enforce RBAC + */ 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') + /* + * Find the database record + */ await this.read({ id: parseInt(params.id) }) - await this.unguardedDelete() + /* + * Now delete it + */ + await this.delete() + + /* + * Return 204 + */ return this.setResponse(204, false) } /* * Returns record data fit for public publishing + * + * @returns {curatedSet} object - The Cureated Set as a plain object */ CuratedSetModel.prototype.asCuratedSet = function () { - return { ...this.record } + return { ...this.unserialize(this.record) } } /* * Returns record data fit for public publishing + * + * @returns {curatedSet} object - The Cureated Set as a plain object */ CuratedSetModel.prototype.asData = function () { const data = { @@ -299,57 +339,3 @@ CuratedSetModel.prototype.asData = function () { 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 -} diff --git a/sites/backend/src/models/flow.mjs b/sites/backend/src/models/flow.mjs index 04720164e8c..9c6b55ab186 100644 --- a/sites/backend/src/models/flow.mjs +++ b/sites/backend/src/models/flow.mjs @@ -1,29 +1,53 @@ -import { UserModel } from './user.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) { - this.config = tools.config - this.mailer = tools.email - this.rbac = tools.rbac - this.User = new UserModel(tools) - - return this + return decorateModel(this, tools, { + name: 'flow', + models: ['user'], + }) } /* * 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 }) { + /* + * Enforce RBAC + */ 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') + + /* + * Is language set? + */ if (!body.language) return this.setResponse(400, 'languageMissing') + + /* + * Is language a valid language? + */ if (!this.config.translations.includes(body.language)) return this.setResponse(400, 'languageInvalid') - // Load user making the call + /* + * Load user record from database + */ await this.User.revealAuthenticatedUser(user) - // Send the invite email + /* + * Send the invite email + */ await this.mailer.send({ template: 'transinvite', 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 + * + * @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 }) { + /* + * Enforce RBAC + */ 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') + + /* + * Is language set? + */ if (!body.language) return this.setResponse(400, 'languageMissing') - // Load user making the call + /* + * Load user making the call + */ await this.User.revealAuthenticatedUser(user) - // Send the invite email + /* + * Send the invite email + */ await this.mailer.send({ template: 'langsuggest', language: body.language, @@ -61,35 +107,8 @@ FlowModel.prototype.sendLanguageSuggestion = async function ({ body, user }) { }, }) - return this.setResponse(200, 'sent', {}) -} - -/* - * 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) + /* + * Return 200 + */ + return this.setResponse200({}) } diff --git a/sites/backend/src/models/issue.mjs b/sites/backend/src/models/issue.mjs index 84f49aae896..149323aaf71 100644 --- a/sites/backend/src/models/issue.mjs +++ b/sites/backend/src/models/issue.mjs @@ -1,73 +1,47 @@ -import fetch from 'node-fetch' -import { UserModel } from './user.mjs' +import { decorateModel } from '../utils/model-decorator.mjs' +import { createIssue } from '../utils/github.mjs' +/* + * This model handles all flows (typically that involves sending out emails) + */ export function IssueModel(tools) { - this.config = tools.config - this.prisma = tools.prisma - this.User = new UserModel(tools) - 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() + return decorateModel(this, tools, { + name: 'flow', + models: ['user'], + }) } +/* + * Create an issue + * + * @param {body} object - The request body + * @returns {IssueModel} object - The IssueModel + */ 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') + + /* + * Is title set? + */ if (!body.title) return this.setResponse(400, 'titleMissing') + + /* + * Is body set? + */ if (!body.body) return this.setResponse(400, 'bodyMissing') - const apiUrl = `https://api.github.com/repos/freesewing/freesewing/issues` - let response - try { - response = await fetch(apiUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${this.token}`, - Accept: 'application/vnd.github.v3+json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }) + /* + * Create the issue + */ + const issue = await createIssue(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 ? this.setResponse(201, 'created', { issue: response }) : this.setResponse(400) + return issue ? this.setResponse201({ issue }) : this.setResponse(400) } diff --git a/sites/backend/src/models/pattern.mjs b/sites/backend/src/models/pattern.mjs index 78291cfcda2..bcf8fc8f56b 100644 --- a/sites/backend/src/models/pattern.mjs +++ b/sites/backend/src/models/pattern.mjs @@ -2,26 +2,34 @@ import { log } from '../utils/log.mjs' import { capitalize } from '../utils/index.mjs' import { storeImage } from '../utils/cloudflare-images.mjs' 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) { - this.config = tools.config - this.prisma = tools.prisma - this.decrypt = tools.decrypt - this.encrypt = tools.encrypt - this.rbac = tools.rbac - this.encryptedFields = ['data', 'img', 'name', 'notes', 'settings'] - this.clear = {} // For holding decrypted data - this.Set = new SetModel(tools) - - return this + return decorateModel(this, tools, { + name: 'pattern', + encryptedFields: ['data', 'img', 'name', 'notes', 'settings'], + models: ['set'], + }) } /* - * 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) { + /* + * No uid no deal + */ if (!uid) return false + + /* + * Run query returning all patterns from the database + */ let patterns try { patterns = await this.prisma.pattern.findMany({ @@ -34,315 +42,346 @@ PatternModel.prototype.userPatterns = async function (uid) { } catch (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 } +/* + * 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 }) { + /* + * Enforce RBAC + */ 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 (!body.set && !body.cset) return this.setResponse(400, 'setOrCsetMissing') - if (typeof body.set !== 'undefined' && typeof body.set !== 'number') - return this.setResponse(400, 'setNotNumeric') - if (typeof body.cset !== 'undefined' && typeof body.cset !== 'number') - return this.setResponse(400, 'csetNotNumeric') + + /* + * Is settings set? + */ 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') + + /* + * Is design set? + */ 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') - // 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, - settings: body.settings, - } - if (data.settings.measurements) delete data.settings.measurements - if (body.set) data.setId = body.set - else if (body.cset) data.csetId = body.cset - else return this.setResponse(400, 'setOrCsetMissing') + img: this.config.avatars.pattern, + setId: body.set ? body.set : null, + settings: { + ...body.settings, + measurements: body.settings.measurements === 'object' ? body.settings.measurements : {}, + }, + 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 - else data.data = {} - // Name (will be encrypted, so always set _some_ value) - if (typeof body.name === 'string' && body.name.length > 0) data.name = body.name - else data.name = '--' - // Notes (will be encrypted, so always set _some_ value) - if (typeof body.notes === 'string' && body.notes.length > 0) data.notes = body.notes - else data.notes = '--' - // Public - 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 + /* + * Now that we have a record ID, we can update the image + */ + const img = await storeImage( + { + id: `pattern-${this.record.id}`, + metadata: { user: user.uid }, + b64: body.img, + }, + this.isTest(body) + ) - // 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 = - this.config.use.cloudflareImages && - typeof body.img === 'string' && - (!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() + /* + * Now return 201 and the record data + */ + return this.setResponse201({ pattern: this.asPattern() }) } /* * 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 }) { - 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 pattern exists. - return this.setResponse(404) - } + /* + * Attempt to read the database record + */ + await this.read({ id: parseInt(params.id) }, { set: true, cset: true }) - 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 * 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 }) { + /* + * Enforce RBAC + */ 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') + + /* + * Is the id set? + */ if (typeof params.id !== 'undefined' && !Number(params.id)) 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') + /* + * You need at least the bughunter role to read another user's pattern + */ if (this.record.userId !== user.uid && !this.rbac.bughunter(user)) { return this.setResponse(403, 'insufficientAccessLevel') } - return this.setResponse(200, false, { - result: 'success', - pattern: this.asPattern(), - }) + /* + * Return the loaded pattern + */ + return this.setResponse200({ pattern: this.asPattern() }) } /* * Clones a pattern * 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 }) { + /* + * Enforce RBAC + */ if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel') + + /* + * Check JWT + */ if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') + /* + * Attempt to read record from database + */ 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)) { return this.setResponse(403, 'insufficientAccessLevel') } - // Clone pattern + /* + * Now clone the pattern + */ const data = this.asPattern() delete data.id data.name += ` (cloned from #${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() - return this.setResponse(200, false, { - result: 'success', - 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) + /* + * And return the cloned pattern + */ + return this.setResponse200({ pattern: this.asPattern() }) } /* * Updates the pattern data - Used when we pass through user-provided data * 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 }) { + /* + * Enforce RBAC + */ if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel') + + /* + * Check JWT + */ 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)) { return this.setResponse(403, 'insufficientAccessLevel') } + + /* + * Prepare data for updating the record + */ const data = {} - // Name + /* + * name + */ if (typeof body.name === 'string') data.name = body.name - // Notes + /* + * notes + */ if (typeof body.notes === 'string') data.notes = body.notes - // Public + /* + * public + */ if (body.public === true || body.public === false) data.public = body.public - // Data + /* + * data + */ if (typeof body.data === 'object') data.data = body.data - // Settings + /* + * settings + */ if (typeof body.settings === 'object') data.settings = body.settings - // Image (img) + /* + * img + */ if (typeof body.img === 'string') { - const img = await storeImage({ - id: `pattern-${this.record.id}`, - metadata: { user: this.user.uid }, - b64: body.img, - }) - data.img = img.url + data.img = await storeImage( + { + id: `pattern-${this.record.id}`, + metadata: { user: this.user.uid }, + b64: body.img, + }, + 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() }) -} - -/* - * 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() + /* + * Return 200 and the data + */ + return this.setResponse200({ pattern: this.asPattern() }) } /* * 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 }) { + /* + * Enforce RBAC + */ if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel') + + /* + * Check JWT + */ if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') + /* + * Attempt to read record from database + */ 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)) { return this.setResponse(403, 'insufficientAccessLevel') } - await this.unguardedDelete() + /* + * Remove the record + */ + await this.delete() + /* + * Return 204 + */ return this.setResponse(204, false) } @@ -358,6 +397,9 @@ PatternModel.prototype.asPattern = function () { /* * 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) { const clear = {} @@ -374,49 +416,6 @@ PatternModel.prototype.revealPattern = function (pattern) { 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 */ @@ -432,6 +431,12 @@ PatternModel.prototype.asPublicPattern = function () { return data } +/* + * + * Everything below this comment is v2 => v3 migration code + * And can be removed after the migration + */ + const migratePattern = (v2, userId) => ({ createdAt: new Date(v2.created ? v2.created : v2.createdAt), data: { version: v2.data.version, notes: ['Migrated from version 2'] }, diff --git a/sites/backend/src/models/set.mjs b/sites/backend/src/models/set.mjs index f040e08ee96..b0c8754c3b6 100644 --- a/sites/backend/src/models/set.mjs +++ b/sites/backend/src/models/set.mjs @@ -1,109 +1,124 @@ import { log } from '../utils/log.mjs' import { replaceImage, storeImage, ensureImage, importImage } from '../utils/cloudflare-images.mjs' 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) { - this.config = tools.config - this.prisma = tools.prisma - this.decrypt = tools.decrypt - 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 + return decorateModel(this, tools, { + name: 'set', + encryptedFields: ['measies', 'img', 'name', 'notes'], + }) } /* - * 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) { - try { - this.record = await this.prisma.set.findUnique({ where }) - } catch (err) { - log.warn({ err, where }, 'Could not read measurements set') - } +SetModel.prototype.guardedCreate = async function ({ body, user }) { + /* + * Enforce RBAC + */ + 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 * 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 }) { + /* + * Enforce RBAC + */ if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel') + + /* + * Check JWT + */ 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) }) + + /* + * If it does not exist, send a 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)) { return this.setResponse(403, 'insufficientAccessLevel') } + /* + * Return 200 and send the pattern data + */ return this.setResponse(200, false, { result: 'success', 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 + * 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 }) { + /* + * Attemp to read the record from the database + */ 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 * 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 }) { + /* + * Enforce RBAC + */ if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel') + + /* + * Check the JWT + */ 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) }) + + /* + * 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)) { return this.setResponse(403, 'insufficientAccessLevel') } - // Clone set + /* + * Now clone the set + */ const data = this.asSet() delete data.id data.name += ` (cloned from #${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() - return this.setResponse(200, false, { - result: 'success', - 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 + /* + * Return 200 and the cloned data + */ + return this.setResponse201({ set: this.asSet() }) } /* * 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) { const clear = {} @@ -204,117 +220,133 @@ SetModel.prototype.revealSet = function (mset) { 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 * 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 }) { + /* + * Enforce RBAC + */ if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel') + + /* + * Check JWT + */ if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') + + /* + * Attempt to read record from database + */ 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)) { return this.setResponse(403, 'insufficientAccessLevel') } + + /* + * Prepare data to update the record + */ const data = {} - // Imperial + + /* + * imperial + */ if (body.imperial === true || body.imperial === false) data.imperial = body.imperial - // Name + + /* + * name + */ if (typeof body.name === 'string') data.name = body.name - // Notes + + /* + * notes + */ if (typeof body.notes === 'string') data.notes = body.notes - // Public + + /* + * 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') { - const img = await replaceImage({ - id: `set-${this.record.id}`, - metadata: { - user: user.uid, - name: this.clear.name, + /* + * measurements + */ + if (typeof body.measies === 'object') data.measies = this.sanitizeMeasurements(body.measies) + + /* + * 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, - notPublic: true, - }) - data.img = img.url - } + this.isTest(body) + ) - // 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() }) -} - -/* - * 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() + /* + * Return 200 and the record data + */ + return this.setResponse200({ set: this.asSet() }) } /* * 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 }) { + /* + * Enforce RBAC + */ if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel') + + /* + * Check the JWT + */ 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) }) + + /* + * You need to be admin to remove another user's data + */ if (this.record.userId !== user.uid && !this.rbac.admin(user)) { return this.setResponse(403, 'insufficientAccessLevel') } - await this.unguardedDelete() + /* + * Delete the record + */ + await this.delete() + /* + * Return 204 + */ 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) => ({ 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 for (const [handle, person] of Object.entries(v2user.people)) { const data = { ...migratePerson(person), userId } - await this.unguardedCreate(data) + await this.createRecord(data) // Now that we have an ID, we can handle the image if (person.picture && person.picture.slice(-4) !== '.svg') { const imgId = `set-${this.record.id}` diff --git a/sites/backend/src/models/subscriber.mjs b/sites/backend/src/models/subscriber.mjs index e549f4f9bb5..7bf4979d644 100644 --- a/sites/backend/src/models/subscriber.mjs +++ b/sites/backend/src/models/subscriber.mjs @@ -1,59 +1,76 @@ import { hash } from '../utils/crypto.mjs' import { log } from '../utils/log.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) { - this.config = tools.config - this.prisma = tools.prisma - this.mailer = tools.email - this.decrypt = tools.decrypt - this.encrypt = tools.encrypt - this.encryptedFields = ['email'] - this.clear = {} // For holding decrypted data - - return this + return decorateModel(this, tools, { + name: 'subscriber', + encryptedFields: ['email'], + }) } +/* + * 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 }) { + /* + * Is email set and a string? + */ 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())) 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 language = body.language.toLowerCase() 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) - // Check to see if this email address is already subscribed. + /* + * Attempt to read existing subscriber record for this email address + */ let newSubscriber = false await this.read({ ehash }) - if (!this.record) { - // No record found. Create subscriber record. - newSubscriber = true - const data = await this.cloak({ 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') - } - } + /* + * If no record can be found, create a new subscriber record. + */ + if (!this.record) await this.createRecord({ ehash, email, language, active: false }) - // Construct the various URLs + /* + * Construct the various URLs for the outgoing email + */ const actionUrl = i18nUrl( `/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) { const template = newSubscriber ? 'nlsub' : this.record.active ? 'nlsubact' : 'nlsubinact' - await this.mailer.send({ template, language, @@ -66,234 +83,119 @@ SubscriberModel.prototype.guardedCreate = async function ({ body }) { }) } + /* + * Prepare the return data + */ const returnData = { language, email } if (isTest) { returnData.id = this.record.id 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 }) { - const { id, ehash } = body - if (!id) return this.setResponse(400, 'idMissing') - if (!ehash) return this.setResponse(400, 'ehashMissing') + /* + * Validate input and load subscription record + */ + 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 - return this.setResponse(404, 'subscriberNotFound') - } + /* + * Update the status if the subscription is not active + */ + if (this.record.active !== true) await this.update({ active: true }) - if (this.record.status !== true) { - // Update username - try { - await this.unguardedUpdate({ active: true }) - } catch (err) { - log.warn(err, 'Could not update active state after subscribe confirmation') - return this.setResponse(500, 'subscriberActivationFailed') - } - } - - return this.setResponse(200, 'success') + /* + * Return 200 + */ + return this.setResponse200() } +/* + * 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 }) { + /* + * 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 + + /* + * Is id set? + */ if (!id) return this.setResponse(400, 'idMissing') + + /* + * Is ehash set? + */ if (!ehash) return this.setResponse(400, 'ehashMissing') - // Find subscription + /* + * Find the subscription record + */ await this.read({ ehash }) - if (this.record) { - // Remove record - try { - await this.unguardedDelete() - } 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 + /* + * If it is not found, return 404 + */ + if (!this.record) return this.setResponse(404, 'subscriberNotFound') 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 diff --git a/sites/backend/src/models/user.mjs b/sites/backend/src/models/user.mjs index 81aaec43ea5..7a7d6fcbc68 100644 --- a/sites/backend/src/models/user.mjs +++ b/sites/backend/src/models/user.mjs @@ -1,85 +1,46 @@ import jwt from 'jsonwebtoken' import { log } from '../utils/log.mjs' import { hash, hashPassword, randomString, verifyPassword } from '../utils/crypto.mjs' -import { replaceImage, ensureImage, importImage } from '../utils/cloudflare-images.mjs' +import { replaceImage, importImage } from '../utils/cloudflare-images.mjs' import { clean, asJson, i18nUrl } from '../utils/index.mjs' -import { ConfirmationModel } from './confirmation.mjs' -import { SetModel } from './set.mjs' -import { PatternModel } from './pattern.mjs' +import { decorateModel } from '../utils/model-decorator.mjs' +/* + * This model handles all user updates + */ export function UserModel(tools) { - this.config = tools.config - this.prisma = tools.prisma - this.decrypt = tools.decrypt - this.encrypt = tools.encrypt - this.mfa = tools.mfa - this.rbac = tools.rbac - this.mailer = tools.email - this.Confirmation = new ConfirmationModel(tools) - this.encryptedFields = ['bio', 'github', 'email', 'initial', 'img', 'mfaSecret'] - this.clear = {} // For holding decrypted data - // Only used for import, can be removed after v3 is released - this.Set = new SetModel(tools) - this.Pattern = new PatternModel(tools) - - return this -} - -/* - * Loads a user from the database based on the where clause you pass it - * - * Stores result in this.record - */ -UserModel.prototype.read = async function (where) { - try { - this.record = await this.prisma.user.findUnique({ where }) - } catch (err) { - log.warn({ err, where }, 'Could not read user') - } - - this.reveal() - - return this.setExists() -} - -/* - * Helper method to decrypt at-rest data - */ -UserModel.prototype.reveal = async function () { - this.clear = {} - if (this.record) { - for (const field of this.encryptedFields) { - this.clear[field] = this.decrypt(this.record[field]) - } - } - - return this -} - -/* - * Helper method to encrypt at-rest data - */ -UserModel.prototype.cloak = function (data) { - for (const field of this.encryptedFields) { - if (typeof data[field] !== 'undefined') data[field] = this.encrypt(data[field]) - } - if (typeof data.password === 'string') data.password = asJson(hashPassword(data.password)) - - return data + return decorateModel(this, tools, { + name: 'user', + encryptedFields: ['bio', 'github', 'email', 'initial', 'img', 'mfaSecret'], + models: ['confirmation', 'set', 'pattern'], + }) } /* * Loads a user from the database based on the where clause you pass it * In addition prepares it for returning the account data + * This is guarded so it enforces access control and validates input * - * Stores result in this.record + * @param {where} object - The where clasuse for the Prisma query + * @returns {UserModel} object - The UserModel */ UserModel.prototype.guardedRead = async function (where, { user }) { + /* + * Enforce RBAC + */ 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') + + /* + * Read record from database + */ await this.read(where) - return this.setResponse(200, false, { + return this.setResponse200({ result: 'success', account: this.asAccount(), }) @@ -91,9 +52,13 @@ UserModel.prototype.guardedRead = async function (where, { user }) { * - ehash * - id * - * Stores result in this.record + * @param {body} object - The request body + * @returns {UserModel} object - The UserModel */ UserModel.prototype.find = async function (body) { + /* + * Attempt to load record (one) from the database + */ try { this.record = await this.prisma.user.findFirst({ where: { @@ -105,75 +70,129 @@ UserModel.prototype.find = async function (body) { }, }) } catch (err) { + /* + * Failed to run database query. Log warning and return 404 + */ log.warn({ err, body }, `Error while trying to find user: ${body.username}`) + return setResponse(404) } - this.reveal() + /* + * Decrypt data that is encrypted at rest + */ + await this.reveal() - return this.setExists() + return this.recordExists() } /* * Loads the user that is making the API request * - * Stores result in this.authenticatedUser + * @param {user} object - The user as loaded by the authentication middleware + * @returns {UserModel} object - The UserModel */ UserModel.prototype.loadAuthenticatedUser = async function (user) { + /* + * Guard against missing input + */ if (!user) return this - this.authenticatedUser = await this.prisma.user.findUnique({ - where: { id: user.uid }, - include: { - apikeys: true, - }, - }) + + /* + * Now attempt to load the full user record from the database + */ + try { + this.authenticatedUser = await this.prisma.user.findUnique({ + where: { id: user.uid }, + include: { + apikeys: true, + }, + }) + } catch (err) { + /* + * Failed to run database query. Log warning and return 404 + */ + log.warn({ err, body }, `Error while trying to find user: ${user.uid}`) + return setResponse(404) + } return this } /* * Loads & reveals the user that is making the API request - e - * Stores result in this.record + * + * @param {user} object - The user as loaded by the authentication middleware + * @returns {UserModel} object - The UserModel */ UserModel.prototype.revealAuthenticatedUser = async function (user) { + /* + * Guard against missing input + */ if (!user) return this - this.record = await this.prisma.user.findUnique({ - where: { id: user.uid }, - include: { - apikeys: true, - }, - }) + + /* + * Now attempt to load the full user record from the database + */ + try { + this.record = await this.prisma.user.findUnique({ + where: { id: user.uid }, + include: { + apikeys: true, + }, + }) + } catch (err) { + /* + * Failed to run database query. Log warning and return 404 + */ + log.warn({ err, body }, `Error while trying to find and reveal user: ${user.uid}`) + return setResponse(404) + } return this.reveal() } /* - * Checks this.record and sets a boolean to indicate whether - * the user exists or not + * Creates a user+confirmation and sends out signup email - Anonymous route * - * Stores result in this.exists - */ -UserModel.prototype.setExists = function () { - this.exists = this.record ? true : false - - return this -} - -/* - * Creates a user+confirmation and sends out signup email + * @param {body} object - The request body + * @returns {UserModel} object - The UserModel */ UserModel.prototype.guardedCreate = async function ({ body }) { + /* + * Do we have a POST body? + */ if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing') + + /* + * Is email set? + */ if (!body.email) return this.setResponse(400, 'emailMissing') + + /* + * Is language set? + */ if (!body.language) return this.setResponse(400, 'languageMissing') + + /* + * Is language a supported language? + */ if (!this.config.languages.includes(body.language)) return this.setResponse(400, 'unsupportedLanguage') + /* + * Create ehash and check + */ const ehash = hash(clean(body.email)) const check = randomString() + + /* + * Check if we already have a user with this email address + */ await this.read({ ehash }) - // Check for unit tests only once + /* + * Check for unit tests only once + */ const isTest = this.isTest(body) if (this.exists) { @@ -190,14 +209,19 @@ UserModel.prototype.guardedCreate = async function ({ body }) { * - Account exists, but is inactive (regular signup) * - Account exists, but is disabled (aed) */ - // Set type of action based on the account status + + /* + * Set type of action based on the account status + */ let type = 'signup-aed' if (this.record.status === 0) type = 'signup' else if (this.record.status === 1) type = 'signup-aea' - // Create confirmation unless account is disabled + /* + * Create confirmation unless account is disabled + */ if (type !== 'signup-aed') { - this.confirmation = await this.Confirmation.create({ + this.confirmation = await this.Confirmation.createRecord({ type, data: { language: body.language, @@ -209,13 +233,19 @@ UserModel.prototype.guardedCreate = async function ({ body }) { userId: this.record.id, }) } - // Set th action url based on the account status + + /* + * Set the action url based on the account status + */ let actionUrl = false if (this.record.status === 0) actionUrl = i18nUrl(body.language, `/confirm/${type}/${this.Confirmation.record.id}/${check}`) else if (this.record.status === 1) actionUrl = i18nUrl(body.language, `/confirm/signin/${this.Confirmation.record.id}/${check}`) - // Send email unless it's a test and we don't want to send test emails + + /* + * Send email unless it's a test and we don't want to send test emails + */ if (!isTest || this.config.tests.sendEmail) await this.mailer.send({ template: type, @@ -228,19 +258,35 @@ UserModel.prototype.guardedCreate = async function ({ body }) { }, }) - // Now return as if everything is fine - return this.setResponse(201, false, { email: this.clear.email }) + /* + * Now return as if everything is fine + */ + return this.setResponse201({ email: this.clear.email }) } - // New signup + /* + * New signup, attempt to create database record + */ try { this.clear.email = clean(body.email) this.clear.initial = this.clear.email this.language = body.language const email = this.encrypt(this.clear.email) - const username = clean(randomString()) // Temporary username + /* + * Create a temporary username because we need one + */ + const username = clean(randomString()) const data = { ehash, + /* + * The ihash (initial email hash) is the hash of the email that was used to + * create the account. The initial email itself is stored in the intial field. + * Once an account created, the ihash and initial fields can never be changed + * by a user. + * We keep them because in the case somebody claims their account was taken + * over. We can check the original email address that was used to create it + * even if the email address on the account was changed. + */ ihash: ehash, email, initial: email, @@ -248,34 +294,57 @@ UserModel.prototype.guardedCreate = async function ({ body }) { lusername: username, language: body.language, mfaEnabled: false, - mfaSecret: this.encrypt(''), - password: asJson(hashPassword(randomString())), // We'll change this later + mfaSecret: '', + /* + * The user will change this later. Or not. They can juse get a magic link via email + */ + password: asJson(hashPassword(randomString())), + /* + * These are all placeholders, but fields that get encrypted need _some_ value + * because encrypting null will cause an error. + */ github: this.encrypt(''), bio: this.encrypt(''), - // Set this one initially as we need the ID to store an image on Cloudflare img: this.encrypt(this.config.avatars.user), } - // During tests, users can set their own permission level so you can test admin stuff + /* + * During tests, users can set their own permission level so you can test admin stuff + */ if (isTest && body.role) data.role = body.role + + /* + * Now attempt to create the record in the database + */ this.record = await this.prisma.user.create({ data }) } catch (err) { + /* + * Could not create record. Log warning and return 500 + */ log.warn(err, 'Could not create user record') return this.setResponse(500, 'createAccountFailed') } - // Update username + /* + * Update username now that we have the databse ID + */ try { - await this.unguardedUpdate({ + await this.update({ username: `user-${this.record.id}`, lusername: `user-${this.record.id}`, }) } catch (err) { - log.warn(err, 'Could not update username after user creation') - return this.setResponse(500, 'usernameUpdateAfterUserCreationFailed') + /* + * This is very unlikely, but it is possible that the username is taken + * Which is not really a problem, so we will swallow this error and + * continue with the random username + */ + log.info(`Username collision for user-${this.record.id}`) } - // Create confirmation - this.confirmation = await this.Confirmation.create({ + /* + * Now create the confirmation + */ + this.confirmation = await this.Confirmation.createRecord({ type: 'signup', data: { language: this.language, @@ -287,7 +356,9 @@ UserModel.prototype.guardedCreate = async function ({ body }) { userId: this.record.id, }) - // Send signup email + /* + * And send out the signup email + */ if (!this.isTest(body) || this.config.tests.sendEmail) await this.mailer.send({ template: 'signup', @@ -303,118 +374,216 @@ UserModel.prototype.guardedCreate = async function ({ body }) { }, }) + /* + * For unit tests, we return the confirmation code so no email is needed + * Obviously, that would defeat the point for production use. + */ return this.isTest(body) - ? this.setResponse(201, false, { + ? this.setResponse201({ email: this.clear.email, confirmation: this.confirmation.record.id, }) - : this.setResponse(201, false, { email: this.clear.email }) + : this.setResponse201({ email: this.clear.email }) } /* * Sign in based on username + password + * + * @param {req} object - The request object. + * We use the entire request object here because we log the IP of failed log attempts + * so we can detect if people are attempting to brute-force logins and block those IPs. + * @returns {UserModel} object - The UserModel */ UserModel.prototype.passwordSignIn = async function (req) { + /* + * Do we have a POST body? + */ if (Object.keys(req.body).length < 1) return this.setResponse(400, 'postBodyMissing') + + /* + * Is the username set? + */ if (!req.body.username) return this.setResponse(400, 'usernameMissing') + + /* + * Is the password set? + */ if (!req.body.password) return this.setResponse(400, 'passwordMissing') + /* + * Attempt to find the user + */ await this.find(req.body) + + /* + * If it does not exist, don't say so but just pretend the login failed. + * This stops people from figuring out whether someone has a FreeSewing + * account, which would be a privacy leak if we said 'not found' here' + */ if (!this.exists) { log.warn(`Sign-in attempt for non-existing user: ${req.body.username} from ${req.ip}`) return this.setResponse(401, 'signInFailed') } - // Account found, check password + /* + * Account found, check the password + */ const [valid, updatedPasswordField] = verifyPassword(req.body.password, this.record.password) + + /* + * If the password is incorrect, log a warning with IP and return 401 + */ if (!valid) { log.warn(`Wrong password for existing user: ${req.body.username} from ${req.ip}`) return this.setResponse(401, 'signInFailed') } - // Check for MFA + /* + * Check if the user has MFA enabled and if so handle the second factor + */ if (this.record.mfaEnabled) { + /* + * If there is no token, return 403 so the front-end can present the token + */ if (!req.body.token) return this.setResponse(403, 'mfaTokenRequired') - else if (!this.mfa.verify(req.body.token, this.clear.mfaSecret)) { + /* + * If there is a token, verify it and if it is not correct, return 401 + */ else if (!this.mfa.verify(req.body.token, this.clear.mfaSecret)) { return this.setResponse(401, 'signInFailed') } } - // Sign in success - log.info(`Sign-in by user ${this.record.id} (${this.record.username})`) - if (updatedPasswordField) { - // Update the password field with a v3 hash - await this.unguardedUpdate({ password: updatedPasswordField }) - } + /* + * At this point sign in is a success. We will update the lastLogin value + * + * However, the way passwords are handled in v2 and v3 is slightly different. + * So v2 users who have been migrated have a v2 hash. So now that we + * have their password and we know it's good, let's rehash it the v3 way + * if this happens to be a v2 user. + */ + const update = { lastSignIn: new Date() } + if (updatedPasswordField) update.password = updatedPasswordField + await this.update(update) + /* + * Final check for account status and other things before returning + */ return this.isOk() ? this.signInOk() : this.setResponse(401, 'signInFailed') } /* * Sign in based on a sign-in link + * + * @param {req} object - The request object. + * @returns {UserModel} object - The UserModel */ UserModel.prototype.linkSignIn = async function (req) { + /* + * Is the id set? + */ if (!req.params.id) return this.setResponse(400, 'signInIdMissing') + + /* + * Is the check set? + */ if (!req.params.check) return this.setResponse(400, 'signInCheckMissing') - // Retrieve confirmation record + /* + * Attempt to retrieve confirmation record + */ await this.Confirmation.read({ id: req.params.id }) - // Verify whether Confirmation exists - if (!this.Confirmation.exists) { - log.warn(`Could not find signin confirmation id ${req.params.id}`) - return this.setResponse(404) - } + /* + * If the confirmation does not exist, return 404 + */ + if (!this.Confirmation.exists) return this.setResponse(404) - // Verify whether Confirmation is of the right type + /* + * If the confirmation is not of of the right type, return 404 + */ if (!['signinlink', 'signup-aea'].includes(this.Confirmation.record.type)) { - log.warn(`Confirmation mismatch; ${req.params.id} is not a signin id`) return this.setResponse(404) } - // Verify Confirmation check + /* + * If the confirmation check is not valid, return 404 + */ if (this.Confirmation.clear.data.check !== req.params.check) { - log.warn(`Confirmation mismatch; ${req.params.check} did not match signin confirmation check`) return this.setResponse(404) } - // Looks good, load user + /* + * Looks like we're good, so attempt to read the user from the database + */ await this.read({ id: this.Confirmation.record.user.id }) + + /* + * if anything went wrong, this.error will be set + */ if (this.error) return this - // Check for MFA + /* + * Check if the user has MFA enabled and if so handle the second factor + */ if (this.record.mfaEnabled) { + /* + * If there is no token, return 403 so the front-end can present the token + */ if (!req.body.token) return this.setResponse(403, 'mfaTokenRequired') - else if (!this.mfa.verify(req.body.token, this.clear.mfaSecret)) { + /* + * If there is a token, verify it and if it is not correct, return 401 + */ else if (!this.mfa.verify(req.body.token, this.clear.mfaSecret)) { return this.setResponse(401, 'signInFailed') } } - // Before we return, remove the confirmation so it works only once - await this.Confirmation.unguardedDelete() - - // Sign in success - log.info(`Sign-in by user ${this.record.id} (${this.record.username}) (via signin link)`) + /* + * Before we return, remove the confirmation so it works only once + */ + await this.Confirmation.delete() + /* + * Sign in was a success, run a final check before returning + */ return this.isOk() ? this.signInOk() : this.setResponse(401, 'signInFailed') } /* * Send a magic link for user sign in + * + * @param {req} object - The request object. + * @returns {UserModel} object - The UserModel */ UserModel.prototype.sendSigninlink = async function (req) { + /* + * Do we have a POST body? + */ if (Object.keys(req.body).length < 1) return this.setResponse(400, 'postBodyMissing') + + /* + * Is username set? + */ if (!req.body.username) return this.setResponse(400, 'usernameMissing') + /* + * Attempt to find the user + */ await this.find(req.body) + + /* + * If we could not find it, log a warning but send a 401 + * to not reveal such a user does not exist. + */ if (!this.exists) { log.warn(`Magic link attempt for non-existing user: ${req.body.username} from ${req.ip}`) return this.setResponse(401, 'signInFailed') } - // Account found, create confirmation + /* + * Account found, generate random check and create the confirmation + */ const check = randomString() - this.confirmation = await this.Confirmation.create({ + this.confirmation = await this.Confirmation.createRecord({ type: 'signinlink', data: { language: this.record.language, @@ -422,9 +591,19 @@ UserModel.prototype.sendSigninlink = async function (req) { }, userId: this.record.id, }) + + /* + * Figure out whether this is part of a unit test + */ const isTest = this.isTest(req.body) + + /* + * Only send out this email if it is not a unit test + */ if (!isTest) { - // Send sign-in link email + /* + * Send sign-in link email + */ await this.mailer.send({ template: 'signinlink', language: this.record.language, @@ -440,102 +619,155 @@ UserModel.prototype.sendSigninlink = async function (req) { }) } - return this.setResponse(200, 'emailSent') + return this.setResponse200({ result: 'emailSent' }) } /* * Confirms a user account + * + * @param {body} object - The request body + * @param {params} object - The request (URL) params + * @returns {UserModel} object - The UserModel */ UserModel.prototype.confirm = async function ({ body, params }) { + /* + * Is the id set? + */ if (!params.id) return this.setResponse(404) + + /* + * Do we have a POST body? + */ if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing') + + /* + * Do we have consent from the user to process their data? + */ if (!body.consent || typeof body.consent !== 'number' || body.consent < 1) return this.setResponse(400, 'consentRequired') - // Retrieve confirmation record - await this.Confirmation.read({ id: params.id }) + /* + * Attempt to read the confirmation from the database + */ + await this.Confirmation.read({ id: params.id }, { user: true }) + /* + * If the confirmation does not exist, log a warning and return 404 + */ if (!this.Confirmation.exists) { log.warn(`Could not find confirmation id ${params.id}`) return this.setResponse(404) } + /* + * If the confirmation is of the wrong type, log a warning and return 404 + */ if (this.Confirmation.record.type !== 'signup') { log.warn(`Confirmation mismatch; ${params.id} is not a signup id`) return this.setResponse(404) } + /* + * If an error occured, it will be in this.error and we can return here + */ if (this.error) return this + + /* + * Get the unencrypted data from the confirmation + */ const data = this.Confirmation.clear.data + + /* + * If the ehash does not match, return 404 + */ if (data.ehash !== this.Confirmation.record.user.ehash) return this.setResponse(404) + + /* + * If the id does not match, return 404 + */ if (data.id !== this.Confirmation.record.user.id) return this.setResponse(404) - // Load user + /* + * Attempt to load the user from the database + */ await this.read({ id: this.Confirmation.record.user.id }) + + /* + * If an error occured, it will be in this.error and we can return here + */ if (this.error) return this - // Update user status, consent, and last sign in - await this.unguardedUpdate({ + /* + * Update user status, consent, and last sign in + */ + await this.update({ status: 1, consent: body.consent, lastSignIn: new Date(), }) + + /* + * If an error occured, it will be in this.error and we can return here + */ if (this.error) return this - // Before we return, remove the confirmation so it works only once - await this.Confirmation.unguardedDelete() + /* + * Before we return, remove the confirmation so it works only once + */ + await this.Confirmation.delete() - // Account is now active, let's return a passwordless sign in + /* + * Account is now active, return a passwordless sign in + */ return this.signInOk() } -/* - * Updates the user data - Used when we create the data ourselves - * so we know it's safe - */ -UserModel.prototype.unguardedUpdate = async function (data) { - try { - this.record = await this.prisma.user.update({ - where: { id: this.record.id }, - data, - }) - } catch (err) { - log.warn(err, 'Could not update user record') - process.exit() - return this.setResponse(500, 'updateUserFailed') - } - await this.reveal() - - return this.setResponse(200) -} - /* * Updates the user data - Used when we pass through user-provided data * so we can't be certain it's safe + * + * @param {body} object - The request body + * @param {user} object - The user as loaded by auth middleware + * @returns {UserModel} object - The UserModel */ UserModel.prototype.guardedUpdate = async function ({ body, user }) { + /* + * Enforce RBAC + */ if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel') + + /* + * Make sure the account is in a state where it's allowed to do this + */ if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') + + /* + * Create data to update the record + */ const data = {} - // Bio - if (typeof body.bio === 'string') data.bio = body.bio - // Compare - if ([true, false].includes(body.compare)) data.compare = body.compare - // Consent - if ([0, 1, 2, 3].includes(body.consent)) data.consent = body.consent - // Control - if ([1, 2, 3, 4, 5].includes(body.control)) data.control = body.control - // Github - if (typeof body.github === 'string') data.github = body.github.split('@').pop() - // Imperial - if ([true, false].includes(body.imperial)) data.imperial = body.imperial - // Language - if (this.config.languages.includes(body.language)) data.language = body.language - // Newsletter - if ([true, false].includes(body.newsletter)) data.newsletter = body.newsletter - // Password - if (typeof body.password === 'string') data.password = body.password // Will be cloaked below - // Username + + /* + * String fields + */ + for (const field of ['bio', 'github']) { + if (typeof body[field] === 'string') data[field] = body[field] + } + + /* + * Enum fields + */ + for (const [field, values] of Object.entries(this.config.enums.user)) { + if (values.includes(body[field])) data[field] = body[field] + } + + /* + * Password + */ + if (typeof body.password === 'string') data.password = body.password + + /* + * Username + */ if (typeof body.username === 'string') { const available = await this.isLusernameAvailable(body.username) if (available) { @@ -545,7 +777,10 @@ UserModel.prototype.guardedUpdate = async function ({ body, user }) { log.info(`Rejected user name change from ${data.username} to ${body.username.trim()}`) } } - // Image (img) + + /* + * Image (img) + */ if (typeof body.img === 'string') data.img = await replaceImage({ id: `user-${this.record.ihash}`, @@ -556,14 +791,29 @@ UserModel.prototype.guardedUpdate = async function ({ body, user }) { b64: body.img, }) - // Now update the record - await this.unguardedUpdate(this.cloak(data)) + /* + * Now update the database record + */ + await this.update(data) + /* + * Figure out whether this is a unit test + */ const isTest = this.isTest(body) + + /* + * If there's an email change, we need to trigger confirmation + */ if (typeof body.email === 'string' && this.clear.email !== clean(body.email)) { - // Email change (requires confirmation) + /* + * Generate the check + */ const check = randomString() - this.confirmation = await this.Confirmation.create({ + + /* + * Generate the confirmation record + */ + this.confirmation = await this.Confirmation.createRecord({ type: 'emailchange', data: { language: this.record.language, @@ -575,12 +825,18 @@ UserModel.prototype.guardedUpdate = async function ({ body, user }) { }, userId: this.record.id, }) + + /* + * Send out confirmation email (unless it's a test) + */ if (!isTest || this.config.tests.sendEmail) { - // Send confirmation email await this.mailer.send({ template: 'emailchange', language: this.record.language, to: body.email, + /* + * CC the old address to guard against account take-over + */ cc: this.clear.email, replacements: { actionUrl: i18nUrl( @@ -593,129 +849,243 @@ UserModel.prototype.guardedUpdate = async function ({ body, user }) { }) } } else if ( + /* + * Could be an email change confirmation + */ typeof body.confirmation === 'string' && body.confirm === 'emailchange' && typeof body.check === 'string' ) { - // Handle email change confirmation + /* + * Attemt to read the confirmation record from the database + */ await this.Confirmation.read({ id: body.confirmation }) + /* + * If it does not exist, log a warning and return 404 + */ if (!this.Confirmation.exists) { log.warn(`Could not find confirmation id ${body.confirmation}`) return this.setResponse(404) } + /* + * If it is the wrong confirmation type, log a warning and return 404 + */ if (this.Confirmation.record.type !== 'emailchange') { log.warn(`Confirmation mismatch; ${body.confirmation} is not an emailchange id`) return this.setResponse(404) } + /* + * Load unencrypted data + */ const data = this.Confirmation.clear.data + + /* + * Verify confirmation ID and check. Update email if it checks out. + */ if ( data.check === body.check && data.email.current === this.clear.email && typeof data.email.new === 'string' ) { - await this.unguardedUpdate({ + /* + * Update the email address and ehash + */ + await this.update({ email: this.encrypt(data.email.new), ehash: hash(clean(data.email.new)), }) } } + /* + * Construct data to return + */ const returnData = { result: 'success', account: this.asAccount(), } + + /* + * If it is a unit test, include the confirmation id + */ if (isTest && this.Confirmation.record?.id) returnData.confirmation = this.Confirmation.record.id - return this.setResponse(200, false, returnData) + /* + * Return data + */ + return this.setResponse200(returnData) } /* * Enables/Disables MFA on the account - Used when we pass through * user-provided data so we can't be certain it's safe + * + * @param {body} object - The request body + * @param {user} object - The user as loaded by auth middleware + * @param {ip} object - The user as loaded by auth middleware + * @returns {UserModel} object - The UserModel */ UserModel.prototype.guardedMfaUpdate = async function ({ body, user, ip }) { + /* + * Enforce RBAC + */ if (!this.rbac.user(user)) return this.setResponse(403, 'insufficientAccessLevel') + + /* + * Ensure account is in the proper state to do this + */ if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') + + /* + * If MFA is active and it is an attempt to active it, return 400 + */ if (body.mfa === true && this.record.mfaEnabled === true) return this.setResponse(400, 'mfaActive') - // Disable + /* + * Option 1/3: Is this an attempt to disable MFA? + */ if (body.mfa === false) { + /* + * Is token set in the POST body? + */ if (!body.token) return this.setResponse(400, 'mfaTokenMissing') + + /* + * Is password set in the POST body? + */ if (!body.password) return this.setResponse(400, 'passwordMissing') - // Check password + + /* + * Verify the password + */ const [valid] = verifyPassword(body.password, this.record.password) + + /* + * If the password is not correct, log a warning including the IP and reutrn 401 + */ if (!valid) { log.warn(`Wrong password for existing user while disabling MFA: ${user.uid} from ${ip}`) return this.setResponse(401, 'authenticationFailed') } - // Check MFA token + + /* + * Verify the MFA token + */ if (this.mfa.verify(body.token, this.clear.mfaSecret)) { - // Looks good. Disable MFA + /* + * Token is valid. Update user record to disable MFA + */ try { - await this.unguardedUpdate({ mfaEnabled: false }) + await this.update({ mfaEnabled: false }) } catch (err) { + /* + * Problem occured while updating the record. Log warning and reurn 500 + */ log.warn(err, 'Could not disable MFA after token check') return this.setResponse(500, 'mfaDeactivationFailed') } - return this.setResponse(200, false, { + + /* + * All done here. Return account data + */ + return this.setResponse200({ result: 'success', account: this.asAccount(), }) } else { + /* + * MFA token not valid. Return 401 + */ return this.setResponse(401, 'authenticationFailed') } - } - // Confirm - else if (body.mfa === true && body.token && body.secret) { + } else if (body.mfa === true && body.token && body.secret) { + /* + * Option 2/3: Is this is a confirmation after enabling MFA? + */ + /* + * Verify secret and token + */ if (body.secret === this.clear.mfaSecret && this.mfa.verify(body.token, this.clear.mfaSecret)) { - // Looks good. Enable MFA + /* + * Looks good. Update the user record to enable MFA + */ try { - await this.unguardedUpdate({ - mfaEnabled: true, - }) + await this.update({ mfaEnabled: true }) } catch (err) { + /* + * Problem occured while updating the record. Log warning and reurn 500 + */ log.warn(err, 'Could not enable MFA after token check') return this.setResponse(500, 'mfaActivationFailed') } - return this.setResponse(200, false, { + + /* + * All done here. Return account data + */ + return this.setResponse200({ result: 'success', account: this.asAccount(), }) } else return this.setResponse(403, 'mfaTokenInvalid') - } - // Enroll - else if (body.mfa === true && this.record.mfaEnabled === false) { + /* + * Secret and/or token don't match. Return 403 + */ + } else if (body.mfa === true && this.record.mfaEnabled === false) { + /* + * Option 3/3: Is this an initial request to enable MFA? + */ + /* + * Setup MFA + */ let mfa try { mfa = await this.mfa.enroll(this.record.username) } catch (err) { - log.warn(err, 'Failed to enroll MFA') + /* + * Problem occured while creating MFA setup. Return 500. + */ + log.warn(err, 'Failed to setup MFA') + return this.setResponse(500, 'mfaSetupFailed') } - // Update mfaSecret + /* + * Update record with the MFA secret + */ try { - await this.unguardedUpdate({ - mfaSecret: this.encrypt(mfa.secret), - }) + await this.update({ mfaSecret: mfa.secret }) } catch (err) { - log.warn(err, 'Could not update username after user creation') - return this.setResponse(500, 'usernameUpdateAfterUserCreationFailed') + /* + * Problem occured while updating record. Return 500. + */ + log.warn(err, 'Could not update MFA secret after setup') + return this.setResponse(500, 'mfaUpdateAfterSetupFailed') } - return this.setResponse(200, false, { mfa }) + /* + * Return the MFA data so the user can add them to their MFA app + */ + return this.setResponse200({ mfa }) } + /* + * We should not ever arrive here, so return 400 at this point + */ return this.setResponse(400, 'invalidMfaSetting') } /* - * Returns account data + * Returns the database record as account data for for consumption + * + * @return {account} object - The account data as a plain object */ UserModel.prototype.asAccount = function () { + /* + * Nothing to do here but construct the object to return + */ return { id: this.record.id, bio: this.clear.bio, @@ -725,6 +1095,7 @@ UserModel.prototype.asAccount = function () { createdAt: this.record.createdAt, email: this.clear.email, github: this.clear.github, + ihash: this.ihash, img: this.clear.img, imperial: this.record.imperial, initial: this.clear.initial, @@ -742,9 +1113,14 @@ UserModel.prototype.asAccount = function () { } /* - * Returns a JSON Web Token (jwt) + * Creates and returns a JSON Web Token (jwt) + * + * @return {jwt} string - The JWT */ UserModel.prototype.getToken = function () { + /* + * Call the jwt library with the correct config + */ return jwt.sign( { _id: this.record.id, @@ -759,55 +1135,13 @@ UserModel.prototype.getToken = function () { ) } -/* - * Helper method to set the response code, result, and body - * - * Will be used by this.sendResponse() - */ -UserModel.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.setExists() -} - -/* - * Helper method to send response - */ -UserModel.prototype.sendResponse = async function (res) { - return res.status(this.response.status).send(this.response.body) -} - -/* - * Update method to determine whether this request is - * part of a (unit) test - */ -UserModel.prototype.isTest = function (body) { - // Disalowing tests in prodution is hard-coded to protect people from - if (this.config.env === 'production' && !this.config.tests.production) return false - if (!body.test) return false - if (this.clear?.email && !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 () { + /* + * These are all the checks we run to see if an account is 'ok' + */ if ( this.exists && this.record && @@ -822,10 +1156,12 @@ UserModel.prototype.isOk = function () { } /* - * Helper method to return from successful sign in + * Helper method to handle the return after a successful sign in + * + * @returns {UserModel} object - The UserModel */ UserModel.prototype.signInOk = function () { - return this.setResponse(200, false, { + return this.setResponse200({ result: 'success', token: this.getToken(), account: this.asAccount(), @@ -833,24 +1169,47 @@ UserModel.prototype.signInOk = function () { } /* - * Check to see if a (lowercase) username is available + * Helper method to see if a (lowercase) username is available * as well as making sure username is not something we * do not allow + * + * @param {lusername} string - The lowercased username + * @returns {isTest} boolean - True if it's a test. False if not. */ UserModel.prototype.isLusernameAvailable = async function (lusername) { + /* + * We do not allow usernames shorter than 2 characters + */ if (lusername.length < 2) return false + + /* + * Attempt to find a user with the provided lusername + */ let user try { user = await this.prisma.user.findUnique({ where: { lusername } }) } catch (err) { + /* + * An error means it's not good. Return false + */ log.warn({ err, lusername }, 'Could not search for free username') return false } + /* + * If a user is found, the lusername is not available, so return false + */ if (user) return false + /* + * If we get here, the lusername is available, so return true + */ return true } +/* + * Everything below this comment is migration code. + * This can all be removed after v3 is in production and all users have been migrated. + */ const migrateUser = (v2) => { const email = clean(v2.email) const initial = clean(v2.initial) @@ -965,7 +1324,7 @@ UserModel.prototype.import = async function (list) { } else skipped.push(sub.email) } - return this.setResponse(200, 'success', { + return this.setResponse200({ skipped, total: list.length, imported: created, diff --git a/sites/backend/src/utils/cloudflare-images.mjs b/sites/backend/src/utils/cloudflare-images.mjs index d62e8d0aefc..963437939e5 100644 --- a/sites/backend/src/utils/cloudflare-images.mjs +++ b/sites/backend/src/utils/cloudflare-images.mjs @@ -10,7 +10,9 @@ const headers = { Authorization: `Bearer ${config.token}` } * Method that does the actual image upload to cloudflare * 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) let result try { @@ -41,7 +43,8 @@ export async function storeImage(props) { * Method that does the actual image upload to cloudflare * 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) // Ignore errors on delete, probably means the image does not exist try { @@ -64,7 +67,8 @@ export async function replaceImage(props) { * Method that uploads an image to cloudflare * 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) let result 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 */ -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 if (!config.import) return `default-avatar` diff --git a/sites/backend/src/utils/github.mjs b/sites/backend/src/utils/github.mjs new file mode 100644 index 00000000000..5ebf4c8b3e9 --- /dev/null +++ b/sites/backend/src/utils/github.mjs @@ -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 +} diff --git a/sites/backend/src/utils/model-decorator.mjs b/sites/backend/src/utils/model-decorator.mjs new file mode 100644 index 00000000000..a4ccc5794ed --- /dev/null +++ b/sites/backend/src/utils/model-decorator.mjs @@ -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 +} diff --git a/sites/backend/tests/curated-set.mjs b/sites/backend/tests/curated-set.mjs index 6d6d92c1e8f..e15edf9ddc6 100644 --- a/sites/backend/tests/curated-set.mjs +++ b/sites/backend/tests/curated-set.mjs @@ -4,6 +4,7 @@ import { capitalize } from '../src/utils/index.mjs' export const curatedSetTests = async (chai, config, expect, store) => { const data = { jwt: { + test: true, nameDe: 'Beispielmessungen A', nameEn: 'Example measurements A', nameEs: 'Medidas de ejemplo A', @@ -28,6 +29,7 @@ export const curatedSetTests = async (chai, config, expect, store) => { }, }, key: { + test: true, nameDe: 'Beispielmessungen B', nameEn: 'Example measurements B', nameEs: 'Medidas de ejemplo B', @@ -83,16 +85,16 @@ export const curatedSetTests = async (chai, config, expect, store) => { .end((err, res) => { expect(err === null).to.equal(true) 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])) { - if (!['measies', 'img'].includes(key)) + if (!['measies', 'img', 'test'].includes(key)) { expect(JSON.stringify(res.body.curatedSet[key])).to.equal(JSON.stringify(val)) + } } store.curatedSet[auth] = res.body.curatedSet done() }) }).timeout(5000) - for (const field of ['name', 'notes']) { for (const lang of config.languages) { const langField = field + capitalize(lang) @@ -234,7 +236,7 @@ export const curatedSetTests = async (chai, config, expect, store) => { .end((err, res) => { expect(err === null).to.equal(true) 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.set.id).to.equal(`number`) expect(res.body.set.name).to.equal(store.curatedSet[auth].nameNl + '_updated') diff --git a/sites/backend/tests/flow.mjs b/sites/backend/tests/flow.mjs new file mode 100644 index 00000000000..ab944469e1c --- /dev/null +++ b/sites/backend/tests/flow.mjs @@ -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() + }) + }) + }) + } +} diff --git a/sites/backend/tests/index.mjs b/sites/backend/tests/index.mjs index 6ad70c185e0..e5e2a177554 100644 --- a/sites/backend/tests/index.mjs +++ b/sites/backend/tests/index.mjs @@ -6,6 +6,8 @@ import { setTests } from './set.mjs' import { curatedSetTests } from './curated-set.mjs' import { patternTests } from './pattern.mjs' import { subscriberTests } from './subscriber.mjs' +import { flowTests } from './flow.mjs' +import { issueTests } from './issue.mjs' import { setup } from './shared.mjs' const runTests = async (...params) => { @@ -17,6 +19,8 @@ const runTests = async (...params) => { await curatedSetTests(...params) await patternTests(...params) await subscriberTests(...params) + await flowTests(...params) + await issueTests(...params) } // Load initial data required for tests diff --git a/sites/backend/tests/issue.mjs b/sites/backend/tests/issue.mjs new file mode 100644 index 00000000000..6fd37425f1e --- /dev/null +++ b/sites/backend/tests/issue.mjs @@ -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() + }) + }) + }) +} diff --git a/sites/backend/tests/mfa.mjs b/sites/backend/tests/mfa.mjs index 52278093eda..6f6eaddf5e3 100644 --- a/sites/backend/tests/mfa.mjs +++ b/sites/backend/tests/mfa.mjs @@ -6,7 +6,7 @@ export const mfaTests = async (chai, config, expect, store) => { key: store.altaccount, } - for (const auth in secret) { + for (const auth of ['jwt']) { 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) => { chai @@ -21,7 +21,7 @@ export const mfaTests = async (chai, config, expect, store) => { 'base64' ) ) - .send({ mfa: 'yes' }) + .send({ mfa: 'yes', test: true }) .end((err, res) => { expect(err === null).to.equal(true) expect(res.status).to.equal(400) @@ -44,7 +44,7 @@ export const mfaTests = async (chai, config, expect, store) => { 'base64' ) ) - .send({ mfa: true }) + .send({ mfa: true, test: true }) .end((err, res) => { expect(err === null).to.equal(true) expect(res.status).to.equal(200) @@ -72,6 +72,7 @@ export const mfaTests = async (chai, config, expect, store) => { ) .send({ mfa: true, + test: true, secret: secret[auth].mfaSecret, token: authenticator.generate(secret[auth].mfaSecret), }) @@ -96,7 +97,7 @@ export const mfaTests = async (chai, config, expect, store) => { 'base64' ) ) - .send({ mfa: true }) + .send({ mfa: true, test: true }) .end((err, res) => { expect(err === null).to.equal(true) expect(res.status).to.equal(400) @@ -121,6 +122,7 @@ export const mfaTests = async (chai, config, expect, store) => { ) .send({ mfa: true, + test: true, secret: secret[auth].mfaSecret, token: authenticator.generate(secret[auth].mfaSecret), }) @@ -138,6 +140,7 @@ export const mfaTests = async (chai, config, expect, store) => { .request(config.api) .post('/signin') .send({ + test: true, username: secret[auth].username, password: secret[auth].password, }) @@ -155,6 +158,7 @@ export const mfaTests = async (chai, config, expect, store) => { .request(config.api) .post('/signin') .send({ + test: true, username: secret[auth].username, password: secret[auth].password, token: authenticator.generate(secret[auth].mfaSecret), @@ -173,6 +177,7 @@ export const mfaTests = async (chai, config, expect, store) => { .request(config.api) .post('/signin') .send({ + test: true, username: secret[auth].username, password: secret[auth].password, token: '1234', @@ -200,6 +205,7 @@ export const mfaTests = async (chai, config, expect, store) => { ) ) .send({ + test: true, mfa: false, password: secret[auth].password, token: authenticator.generate(secret[auth].mfaSecret), diff --git a/sites/backend/tests/pattern.mjs b/sites/backend/tests/pattern.mjs index 02bed97dddf..abf85368856 100644 --- a/sites/backend/tests/pattern.mjs +++ b/sites/backend/tests/pattern.mjs @@ -18,9 +18,11 @@ export const patternTests = async (chai, config, expect, store) => { ) ) .send({ + test: true, design: 'aaron', settings: { sa: 5, + measurements: store.account.sets.her.measurements, }, name: 'Just a test', notes: 'These are my notes', @@ -34,7 +36,7 @@ export const patternTests = async (chai, config, expect, store) => { .end((err, res) => { expect(err === null).to.equal(true) 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(res.body.pattern.userId).to.equal(store.account.id) expect(res.body.pattern.setId).to.equal(store.account.sets.her.id) diff --git a/sites/backend/tests/set.mjs b/sites/backend/tests/set.mjs index 23c98e0b1c1..c85c882d342 100644 --- a/sites/backend/tests/set.mjs +++ b/sites/backend/tests/set.mjs @@ -54,7 +54,7 @@ export const setTests = async (chai, config, expect, store) => { .end((err, res) => { expect(err === null).to.equal(true) 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])) { 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) => { expect(err === null).to.equal(true) - expect(res.status).to.equal(200) - expect(res.body.result).to.equal(`success`) + expect(res.status).to.equal(201) + expect(res.body.result).to.equal(`created`) expect(typeof res.body.error).to.equal(`undefined`) expect(typeof res.body.set.id).to.equal(`number`) done() @@ -352,8 +352,8 @@ export const setTests = async (chai, config, expect, store) => { .end((err, res) => { if (store.set[auth].public) { expect(err === null).to.equal(true) - expect(res.status).to.equal(200) - expect(res.body.result).to.equal(`success`) + expect(res.status).to.equal(201) + expect(res.body.result).to.equal(`created`) expect(typeof res.body.error).to.equal(`undefined`) expect(typeof res.body.set.id).to.equal(`number`) } else { @@ -365,7 +365,6 @@ export const setTests = async (chai, config, expect, store) => { done() }) }) - // TODO: // - Clone set // - Clone set accross accounts of they are public diff --git a/sites/backend/tests/shared.mjs b/sites/backend/tests/shared.mjs index b3e08756675..3e4749460c4 100644 --- a/sites/backend/tests/shared.mjs +++ b/sites/backend/tests/shared.mjs @@ -41,7 +41,9 @@ export const setup = async () => { key: '🎟️ ', set: '🧕 ', pattern: '👕 ', - subscriber: '📬', + subscriber: '📬 ', + flow: '🪁 ', + issue: '🚩 ', }, randomString, } diff --git a/sites/backend/tests/user.mjs b/sites/backend/tests/user.mjs index 77685601994..b50d5fb1133 100644 --- a/sites/backend/tests/user.mjs +++ b/sites/backend/tests/user.mjs @@ -52,7 +52,7 @@ export const userTests = async (chai, config, expect, store) => { expect(res.status).to.equal(201) expect(res.type).to.equal('application/json') 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) done() })