feat(backend): Refactor and optimize
This commit is contained in:
parent
30d48f1c07
commit
b05e1e20cf
23 changed files with 2345 additions and 1566 deletions
|
@ -10,26 +10,41 @@ import { postConfig } from '../local-config.mjs'
|
||||||
import { roles } from '../../../config/roles.mjs'
|
import { roles } from '../../../config/roles.mjs'
|
||||||
dotenv.config()
|
dotenv.config()
|
||||||
|
|
||||||
// Allow these 2 to be imported
|
/*
|
||||||
|
* Make this easy to update
|
||||||
|
*/
|
||||||
|
const languages = ['en', 'de', 'es', 'fr', 'nl', 'uk']
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Allow these 2 to be imported
|
||||||
|
*/
|
||||||
export const port = process.env.BACKEND_PORT || 3000
|
export const port = process.env.BACKEND_PORT || 3000
|
||||||
export const api = process.env.BACKEND_URL || `http://localhost:${port}`
|
export const api = process.env.BACKEND_URL || `http://localhost:${port}`
|
||||||
|
|
||||||
// Generate/Check encryption key only once
|
/*
|
||||||
|
* Generate/Check encryption key only once
|
||||||
|
*/
|
||||||
const encryptionKey = process.env.BACKEND_ENC_KEY
|
const encryptionKey = process.env.BACKEND_ENC_KEY
|
||||||
? process.env.BACKEND_ENC_KEY
|
? process.env.BACKEND_ENC_KEY
|
||||||
: randomEncryptionKey()
|
: randomEncryptionKey()
|
||||||
|
|
||||||
// All environment variables are strings
|
/*
|
||||||
// This is a helper method to turn them into a boolean
|
* All environment variables are strings
|
||||||
|
* This is a helper method to turn them into a boolean
|
||||||
|
*/
|
||||||
const envToBool = (input = 'no') => {
|
const envToBool = (input = 'no') => {
|
||||||
if (['yes', '1', 'true'].includes(input.toLowerCase())) return true
|
if (['yes', '1', 'true'].includes(input.toLowerCase())) return true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save ourselves some typing
|
/*
|
||||||
|
* Save ourselves some typing
|
||||||
|
*/
|
||||||
const crowdinProject = 'https://translate.freesewing.org/project/freesewing/'
|
const crowdinProject = 'https://translate.freesewing.org/project/freesewing/'
|
||||||
|
|
||||||
// Construct config object
|
/*
|
||||||
|
* Construct config object
|
||||||
|
*/
|
||||||
const baseConfig = {
|
const baseConfig = {
|
||||||
// Environment
|
// Environment
|
||||||
env: process.env.NODE_ENV || 'development',
|
env: process.env.NODE_ENV || 'development',
|
||||||
|
@ -69,6 +84,16 @@ const baseConfig = {
|
||||||
encryption: {
|
encryption: {
|
||||||
key: encryptionKey,
|
key: encryptionKey,
|
||||||
},
|
},
|
||||||
|
enums: {
|
||||||
|
user: {
|
||||||
|
consent: [0, 1, 2, 3],
|
||||||
|
control: [1, 2, 3, 4, 5],
|
||||||
|
language: languages,
|
||||||
|
compare: [true, false],
|
||||||
|
imperial: [true, false],
|
||||||
|
newsletter: [true, false],
|
||||||
|
},
|
||||||
|
},
|
||||||
github: {
|
github: {
|
||||||
token: process.env.BACKEND_GITHUB_TOKEN,
|
token: process.env.BACKEND_GITHUB_TOKEN,
|
||||||
},
|
},
|
||||||
|
@ -87,8 +112,8 @@ const baseConfig = {
|
||||||
audience: process.env.BACKEND_JWT_ISSUER || 'freesewing.org',
|
audience: process.env.BACKEND_JWT_ISSUER || 'freesewing.org',
|
||||||
expiresIn: process.env.BACKEND_JWT_EXPIRY || '7d',
|
expiresIn: process.env.BACKEND_JWT_EXPIRY || '7d',
|
||||||
},
|
},
|
||||||
languages: ['en', 'de', 'es', 'fr', 'nl', 'uk'],
|
languages,
|
||||||
translations: ['de', 'es', 'fr', 'nl', 'uk'],
|
translations: languages.filter((lang) => lang !== 'en'),
|
||||||
measies: measurements,
|
measies: measurements,
|
||||||
mfa: {
|
mfa: {
|
||||||
service: process.env.BACKEND_MFA_SERVICE || 'FreeSewing',
|
service: process.env.BACKEND_MFA_SERVICE || 'FreeSewing',
|
||||||
|
@ -150,6 +175,7 @@ if (baseConfig.use.cloudflareImages) {
|
||||||
api: `https://api.cloudflare.com/client/v4/accounts/${account}/images/v1`,
|
api: `https://api.cloudflare.com/client/v4/accounts/${account}/images/v1`,
|
||||||
token: process.env.BACKEND_CLOUDFLARE_IMAGES_TOKEN || 'fixmeSetCloudflareToken',
|
token: process.env.BACKEND_CLOUDFLARE_IMAGES_TOKEN || 'fixmeSetCloudflareToken',
|
||||||
import: envToBool(process.env.BACKEND_IMPORT_CLOUDFLARE_IMAGES),
|
import: envToBool(process.env.BACKEND_IMPORT_CLOUDFLARE_IMAGES),
|
||||||
|
useInTests: baseConfig.use.tests.cloudflareImages,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -195,6 +221,7 @@ const config = postConfig(baseConfig)
|
||||||
// Exporting this stand-alone config
|
// Exporting this stand-alone config
|
||||||
export const cloudflareImages = config.cloudflareImages || {}
|
export const cloudflareImages = config.cloudflareImages || {}
|
||||||
export const website = config.website
|
export const website = config.website
|
||||||
|
export const githubToken = config.github.token
|
||||||
|
|
||||||
const vars = {
|
const vars = {
|
||||||
BACKEND_DB_URL: ['required', 'db.url'],
|
BACKEND_DB_URL: ['required', 'db.url'],
|
||||||
|
@ -235,10 +262,10 @@ if (envToBool(process.env.BACKEND_USE_CLOUDFLARE_IMAGES)) {
|
||||||
// Vars for Github integration
|
// Vars for Github integration
|
||||||
if (envToBool(process.env.BACKEND_ENABLE_GITHUB)) {
|
if (envToBool(process.env.BACKEND_ENABLE_GITHUB)) {
|
||||||
vars.BACKEND_GITHUB_TOKEN = 'requiredSecret'
|
vars.BACKEND_GITHUB_TOKEN = 'requiredSecret'
|
||||||
vars.BACKEND_GITHUB_USER = 'required'
|
vars.BACKEND_GITHUB_USER = 'optional'
|
||||||
vars.BACKEND_GITHUB_USER_NAME = 'required'
|
vars.BACKEND_GITHUB_USER_NAME = 'optional'
|
||||||
vars.BACKEND_GITHUB_USER_EMAIL = 'required'
|
vars.BACKEND_GITHUB_USER_EMAIL = 'optional'
|
||||||
vars.BACKEND_GITHUB_NOTIFY_DEFAULT_USER = 'required'
|
vars.BACKEND_GITHUB_NOTIFY_DEFAULT_USER = 'optional'
|
||||||
}
|
}
|
||||||
// Vars for Oauth via Github integration
|
// Vars for Oauth via Github integration
|
||||||
if (envToBool(process.env.BACKEND_ENABLE_OAUTH_GITHUB)) {
|
if (envToBool(process.env.BACKEND_ENABLE_OAUTH_GITHUB)) {
|
||||||
|
|
|
@ -67,7 +67,7 @@ ApikeysController.prototype.whoami = async (req, res, tools) => {
|
||||||
key: key[0].id,
|
key: key[0].id,
|
||||||
level: key[0].level,
|
level: key[0].level,
|
||||||
expiresAt: key[0].expiresAt,
|
expiresAt: key[0].expiresAt,
|
||||||
name: key[0].name,
|
name: Apikey.decrypt(key[0].name),
|
||||||
userId: key[0].userId,
|
userId: key[0].userId,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,166 +1,289 @@
|
||||||
import { log } from '../utils/log.mjs'
|
import { log } from '../utils/log.mjs'
|
||||||
import { hashPassword, randomString, verifyPassword } from '../utils/crypto.mjs'
|
import { hashPassword, randomString, verifyPassword } from '../utils/crypto.mjs'
|
||||||
import { asJson } from '../utils/index.mjs'
|
import { asJson } from '../utils/index.mjs'
|
||||||
import { UserModel } from './user.mjs'
|
import { decorateModel } from '../utils/model-decorator.mjs'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This model handles all apikey updates
|
||||||
|
*
|
||||||
|
* @param {tools} object - A set of tools loaded in src/index.js
|
||||||
|
* @returns {ApikeyModel} object - The ApikeyModel
|
||||||
|
*/
|
||||||
export function ApikeyModel(tools) {
|
export function ApikeyModel(tools) {
|
||||||
this.config = tools.config
|
/*
|
||||||
this.prisma = tools.prisma
|
* See utils/model-decorator.mjs for details
|
||||||
this.rbac = tools.rbac
|
*/
|
||||||
this.User = new UserModel(tools)
|
return decorateModel(this, tools, {
|
||||||
|
name: 'apikey',
|
||||||
return this
|
encryptedFields: ['name'],
|
||||||
}
|
models: ['user'],
|
||||||
|
})
|
||||||
ApikeyModel.prototype.setExists = function () {
|
|
||||||
this.exists = this.record ? true : false
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
ApikeyModel.prototype.setResponse = function (status = 200, error = false, data = {}) {
|
|
||||||
this.response = {
|
|
||||||
status,
|
|
||||||
body: {
|
|
||||||
result: 'success',
|
|
||||||
...data,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if (status === 201) this.response.body.result = 'created'
|
|
||||||
else if (status > 204) {
|
|
||||||
this.response.body.error = error
|
|
||||||
this.response.body.result = 'error'
|
|
||||||
this.error = true
|
|
||||||
} else this.error = false
|
|
||||||
|
|
||||||
return this.setExists()
|
|
||||||
}
|
|
||||||
|
|
||||||
ApikeyModel.prototype.sendResponse = async function (res) {
|
|
||||||
return res.status(this.response.status).send(this.response.body)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Verifies an API key and secret
|
||||||
|
* Will set this.verified to true or false before returning the model
|
||||||
|
*
|
||||||
|
* @param {key} string - The API key
|
||||||
|
* @param {secret} string - The API secret
|
||||||
|
* @returns {ApikeyModel} object - The ApikeyModel
|
||||||
|
*/
|
||||||
ApikeyModel.prototype.verify = async function (key, secret) {
|
ApikeyModel.prototype.verify = async function (key, secret) {
|
||||||
await this.unguardedRead({ id: key })
|
/*
|
||||||
|
* Attempt to read the record from the database
|
||||||
|
*/
|
||||||
|
await this.read({ id: key })
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Apikey secret is just like a password, and we verify it the same way
|
||||||
|
*/
|
||||||
const [valid] = verifyPassword(secret, this.record.secret)
|
const [valid] = verifyPassword(secret, this.record.secret)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Store result in the verified property
|
||||||
|
*/
|
||||||
this.verified = valid
|
this.verified = valid
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Reads an API key
|
||||||
|
* This is guarded so it enforces access control and validates input
|
||||||
|
*
|
||||||
|
* @param {params} object - The request (url) parameters
|
||||||
|
* @param {user} object - The user as loaded by the authentication middleware
|
||||||
|
* @returns {ApikeyModel} object - The ApikeyModel
|
||||||
|
*/
|
||||||
ApikeyModel.prototype.guardedRead = async function ({ params, user }) {
|
ApikeyModel.prototype.guardedRead = async function ({ params, user }) {
|
||||||
|
/*
|
||||||
|
* Enforece RBAC
|
||||||
|
*/
|
||||||
if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Ensure the account is active
|
||||||
|
*/
|
||||||
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
|
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
|
||||||
|
|
||||||
await this.unguardedRead({ id: params.id })
|
/*
|
||||||
|
* Attempt to read record from database
|
||||||
|
*/
|
||||||
|
await this.read({ id: params.id })
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If it's not found, return 404
|
||||||
|
*/
|
||||||
if (!this.record) return this.setResponse(404)
|
if (!this.record) return this.setResponse(404)
|
||||||
|
|
||||||
if (this.record.userId !== user.uid) {
|
/*
|
||||||
// Not own key - only admin can do that
|
* Only admins can read other users
|
||||||
if (!this.rbac.admin(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
*/
|
||||||
|
if (this.record.userId !== user.uid && !this.rbac.admin(user)) {
|
||||||
|
return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.setResponse(200, 'success', {
|
/*
|
||||||
|
* Decrypt data that is encrypted at rest
|
||||||
|
*/
|
||||||
|
await this.reveal()
|
||||||
|
|
||||||
|
return this.setResponse200({
|
||||||
apikey: {
|
apikey: {
|
||||||
key: this.record.id,
|
key: this.record.id,
|
||||||
level: this.record.level,
|
level: this.record.level,
|
||||||
createdAt: this.record.createdAt,
|
createdAt: this.record.createdAt,
|
||||||
expiresAt: this.record.expiresAt,
|
expiresAt: this.record.expiresAt,
|
||||||
name: this.record.name,
|
name: this.clear.name,
|
||||||
userId: this.record.userId,
|
userId: this.record.userId,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Deletes an API key
|
||||||
|
* This is guarded so it enforces access control and validates input
|
||||||
|
*
|
||||||
|
* @param {params} object - The request (url) parameters
|
||||||
|
* @param {user} object - The user as loaded by the authentication middleware
|
||||||
|
* @returns {ApikeyModel} object - The ApikeyModel
|
||||||
|
*/
|
||||||
ApikeyModel.prototype.guardedDelete = async function ({ params, user }) {
|
ApikeyModel.prototype.guardedDelete = async function ({ params, user }) {
|
||||||
|
/*
|
||||||
|
* Enforece RBAC
|
||||||
|
*/
|
||||||
if (!this.rbac.user(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
if (!this.rbac.user(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
|
/*
|
||||||
|
* Ensure the account is active
|
||||||
|
*/
|
||||||
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
|
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
|
||||||
|
|
||||||
await this.unguardedRead({ id: params.id })
|
/*
|
||||||
|
* Attempt to read record from database
|
||||||
|
*/
|
||||||
|
await this.read({ id: params.id })
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If it's not found, return 404
|
||||||
|
*/
|
||||||
if (!this.record) return this.setResponse(404)
|
if (!this.record) return this.setResponse(404)
|
||||||
|
|
||||||
if (this.record.userId !== user.uid) {
|
/*
|
||||||
// Not own key - only admin can do that
|
* Only admins can delete other users
|
||||||
if (!this.rbac.admin(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
*/
|
||||||
|
if (this.record.userId !== user.uid && !this.rbac.admin(user)) {
|
||||||
|
return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.unguardedDelete()
|
/*
|
||||||
|
* Delete record from the database
|
||||||
|
*/
|
||||||
|
await this.delete()
|
||||||
|
|
||||||
return this.setResponse(204)
|
return this.setResponse(204)
|
||||||
}
|
}
|
||||||
|
|
||||||
ApikeyModel.prototype.unguardedRead = async function (where) {
|
/*
|
||||||
this.record = await this.prisma.apikey.findUnique({ where })
|
* Returns all API keys for a user with uid
|
||||||
|
*
|
||||||
return this
|
* @param {uid} string - The uid of the user
|
||||||
}
|
* Note that the uid is the ID, but we user uid when it comes from middleware
|
||||||
|
* @returns {keys} array - An array of Apikeys
|
||||||
ApikeyModel.prototype.unguardedDelete = async function () {
|
*/
|
||||||
await this.prisma.apikey.delete({ where: { id: this.record.id } })
|
|
||||||
this.record = null
|
|
||||||
this.clear = null
|
|
||||||
|
|
||||||
return this.setExists()
|
|
||||||
}
|
|
||||||
|
|
||||||
ApikeyModel.prototype.userApikeys = async function (uid) {
|
ApikeyModel.prototype.userApikeys = async function (uid) {
|
||||||
|
/*
|
||||||
|
* Guard against missing input
|
||||||
|
*/
|
||||||
if (!uid) return false
|
if (!uid) return false
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Wrap async code with try ... catch
|
||||||
|
*/
|
||||||
let keys
|
let keys
|
||||||
try {
|
try {
|
||||||
|
/*
|
||||||
|
* Attempt to read records from database
|
||||||
|
*/
|
||||||
keys = await this.prisma.apikey.findMany({ where: { userId: uid } })
|
keys = await this.prisma.apikey.findMany({ where: { userId: uid } })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
/*
|
||||||
|
* Something went wrong, log a warning and return 404
|
||||||
|
*/
|
||||||
log.warn(`Failed to search apikeys for user ${uid}: ${err}`)
|
log.warn(`Failed to search apikeys for user ${uid}: ${err}`)
|
||||||
|
return this.setResponse(404)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Keys are an array, remove sercrets with map() and decrypt prior to returning
|
||||||
|
*/
|
||||||
return keys.map((key) => {
|
return keys.map((key) => {
|
||||||
delete key.secret
|
delete key.secret
|
||||||
|
key.name = this.decrypt(key.name)
|
||||||
|
|
||||||
return key
|
return key
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Creates an Apikey
|
||||||
|
*
|
||||||
|
* @param {body} object - The request body
|
||||||
|
* @param {user} object - The user as loaded by the authentication middleware
|
||||||
|
* @returns {ApikeyModel} object - The ApikeyModel
|
||||||
|
*/
|
||||||
ApikeyModel.prototype.create = async function ({ body, user }) {
|
ApikeyModel.prototype.create = async function ({ body, user }) {
|
||||||
|
/*
|
||||||
|
* Do we have a POST body?
|
||||||
|
*/
|
||||||
if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing')
|
if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is the name set?
|
||||||
|
*/
|
||||||
if (!body.name) return this.setResponse(400, 'nameMissing')
|
if (!body.name) return this.setResponse(400, 'nameMissing')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is the level set?
|
||||||
|
*/
|
||||||
if (!body.level) return this.setResponse(400, 'levelMissing')
|
if (!body.level) return this.setResponse(400, 'levelMissing')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is level numeric?
|
||||||
|
*/
|
||||||
if (typeof body.level !== 'number') return this.setResponse(400, 'levelNotNumeric')
|
if (typeof body.level !== 'number') return this.setResponse(400, 'levelNotNumeric')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is level a known/valid level?
|
||||||
|
*/
|
||||||
if (!this.config.apikeys.levels.includes(body.level)) return this.setResponse(400, 'invalidLevel')
|
if (!this.config.apikeys.levels.includes(body.level)) return this.setResponse(400, 'invalidLevel')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is expiresIn set?
|
||||||
|
*/
|
||||||
if (!body.expiresIn) return this.setResponse(400, 'expiresInMissing')
|
if (!body.expiresIn) return this.setResponse(400, 'expiresInMissing')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is expiresIn numberic?
|
||||||
|
*/
|
||||||
if (typeof body.expiresIn !== 'number') return this.setResponse(400, 'expiresIsNotNumeric')
|
if (typeof body.expiresIn !== 'number') return this.setResponse(400, 'expiresIsNotNumeric')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is expiresIn above the maximum?
|
||||||
|
*/
|
||||||
if (body.expiresIn > this.config.apikeys.maxExpirySeconds)
|
if (body.expiresIn > this.config.apikeys.maxExpirySeconds)
|
||||||
return this.setResponse(400, 'expiresIsHigherThanMaximum')
|
return this.setResponse(400, 'expiresIsHigherThanMaximum')
|
||||||
|
|
||||||
// Load user making the call
|
/*
|
||||||
|
* Load authenticated user from the database
|
||||||
|
*/
|
||||||
await this.User.loadAuthenticatedUser(user)
|
await this.User.loadAuthenticatedUser(user)
|
||||||
if (body.level > this.config.roles.levels[this.User.authenticatedUser.role])
|
|
||||||
return this.setResponse(400, 'keyLevelExceedsRoleLevel')
|
|
||||||
|
|
||||||
// Generate api secret
|
/*
|
||||||
|
* Is the user allowed to create a key of this level?
|
||||||
|
*/
|
||||||
|
if (body.level > this.config.roles.levels[this.User.authenticatedUser.role]) {
|
||||||
|
return this.setResponse(400, 'keyLevelExceedsRoleLevel')
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Generate the apikey secret
|
||||||
|
*/
|
||||||
const secret = randomString(32)
|
const secret = randomString(32)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Calculate expiry date
|
||||||
|
*/
|
||||||
const expiresAt = new Date(Date.now() + body.expiresIn * 1000)
|
const expiresAt = new Date(Date.now() + body.expiresIn * 1000)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Attempt to create the record in the database
|
||||||
|
*/
|
||||||
try {
|
try {
|
||||||
this.record = await this.prisma.apikey.create({
|
this.record = await this.prisma.apikey.create({
|
||||||
data: {
|
data: this.cloak({
|
||||||
expiresAt,
|
expiresAt,
|
||||||
name: body.name,
|
name: body.name,
|
||||||
level: body.level,
|
level: body.level,
|
||||||
secret: asJson(hashPassword(secret)),
|
secret: asJson(hashPassword(secret)),
|
||||||
userId: user.uid,
|
userId: user.uid,
|
||||||
},
|
}),
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
/*
|
||||||
|
* That did not work. Log and error and return 500
|
||||||
|
*/
|
||||||
log.warn(err, 'Could not create apikey')
|
log.warn(err, 'Could not create apikey')
|
||||||
return this.setResponse(500, 'createApikeyFailed')
|
return this.setResponse(500, 'createApikeyFailed')
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.setResponse(201, 'created', {
|
return this.setResponse201({
|
||||||
apikey: {
|
apikey: {
|
||||||
key: this.record.id,
|
key: this.record.id,
|
||||||
secret,
|
secret,
|
||||||
level: this.record.level,
|
level: this.record.level,
|
||||||
createdAt: this.record.createdAt,
|
createdAt: this.record.createdAt,
|
||||||
expiresAt: this.record.expiresAt,
|
expiresAt: this.record.expiresAt,
|
||||||
name: this.record.name,
|
name: body.name,
|
||||||
userId: this.record.userId,
|
userId: this.record.userId,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,112 +1,58 @@
|
||||||
import { log } from '../utils/log.mjs'
|
import { log } from '../utils/log.mjs'
|
||||||
|
import { decorateModel } from '../utils/model-decorator.mjs'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This model handles all confirmation updates
|
||||||
|
*
|
||||||
|
* @param {tools} object - A set of tools loaded in src/index.js
|
||||||
|
* @returns {ConfirmationModel} object - The ConfirmationModel
|
||||||
|
*/
|
||||||
export function ConfirmationModel(tools) {
|
export function ConfirmationModel(tools) {
|
||||||
this.config = tools.config
|
/*
|
||||||
this.prisma = tools.prisma
|
* See utils/model-decorator.mjs for details
|
||||||
this.decrypt = tools.decrypt
|
*/
|
||||||
this.encrypt = tools.encrypt
|
return decorateModel(this, tools, {
|
||||||
this.encryptedFields = ['data']
|
name: 'confirmation',
|
||||||
this.clear = {} // For holding decrypted data
|
encryptedFields: ['data'],
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
ConfirmationModel.prototype.unguardedRead = async function (where) {
|
|
||||||
this.record = await this.prisma.confirmation.findUnique({ where })
|
|
||||||
await this.reveal()
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
ConfirmationModel.prototype.read = async function (where) {
|
|
||||||
this.record = await this.prisma.confirmation.findUnique({
|
|
||||||
where,
|
|
||||||
include: {
|
|
||||||
user: true,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
this.clear.data = this.record?.data ? this.decrypt(this.record.data) : {}
|
|
||||||
|
|
||||||
return this.setExists()
|
|
||||||
}
|
|
||||||
|
|
||||||
ConfirmationModel.prototype.setExists = function () {
|
|
||||||
this.exists = this.record ? true : false
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
ConfirmationModel.prototype.setResponse = function (status = 200, error = false, data = {}) {
|
|
||||||
this.response = {
|
|
||||||
status,
|
|
||||||
body: {
|
|
||||||
result: 'success',
|
|
||||||
...data,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if (status === 201) this.response.body.result = 'created'
|
|
||||||
else if (status > 204) {
|
|
||||||
this.response.body.error = error
|
|
||||||
this.response.body.result = 'error'
|
|
||||||
this.error = true
|
|
||||||
} else this.error = false
|
|
||||||
|
|
||||||
return this.setExists()
|
|
||||||
}
|
|
||||||
|
|
||||||
ConfirmationModel.prototype.sendResponse = async function (res) {
|
|
||||||
return res.status(this.response.status).send(this.response.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
ConfirmationModel.prototype.guardedRead = async function ({ params }) {
|
|
||||||
if (typeof params.id === 'undefined') return this.setResponse(404)
|
|
||||||
if (typeof params.check === 'undefined') return this.setResponse(404)
|
|
||||||
|
|
||||||
await this.unguardedRead({ id: params.id })
|
|
||||||
if (!this.record) return this.setResponse(404)
|
|
||||||
|
|
||||||
if (this.clear.data.check === params.check)
|
|
||||||
return this.setResponse(200, 'success', {
|
|
||||||
confirmation: {
|
|
||||||
id: this.record.id,
|
|
||||||
check: this.clear.data.check,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return this.setResponse(404)
|
|
||||||
}
|
|
||||||
|
|
||||||
ConfirmationModel.prototype.create = async function (data = {}) {
|
|
||||||
try {
|
|
||||||
this.record = await this.prisma.confirmation.create({
|
|
||||||
data: { ...data, data: this.encrypt(data.data) },
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
log.warn(err, 'Could not create confirmation record')
|
|
||||||
return this.setResponse(500, 'createConfirmationFailed')
|
|
||||||
}
|
|
||||||
log.info({ confirmation: this.record.id }, 'Confirmation created')
|
|
||||||
|
|
||||||
return this.setResponse(201)
|
|
||||||
}
|
|
||||||
|
|
||||||
ConfirmationModel.prototype.unguardedDelete = async function () {
|
|
||||||
await this.prisma.confirmation.delete({ where: { id: this.record.id } })
|
|
||||||
this.record = null
|
|
||||||
this.clear = null
|
|
||||||
|
|
||||||
return this.setExists()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Helper method to decrypt at-rest data
|
* Reads a confirmation - Anonymous route
|
||||||
|
*
|
||||||
|
* @param {params} object - The request (url) parameters
|
||||||
|
* @returns {ConfirmationModel} object - The ConfirmationModel
|
||||||
*/
|
*/
|
||||||
ConfirmationModel.prototype.reveal = async function () {
|
ConfirmationModel.prototype.guardedRead = async function ({ params }) {
|
||||||
this.clear = {}
|
/*
|
||||||
if (this.record) {
|
* Is the id set?
|
||||||
for (const field of this.encryptedFields) {
|
*/
|
||||||
this.clear[field] = this.decrypt(this.record[field])
|
if (typeof params.id === 'undefined') return this.setResponse(404)
|
||||||
}
|
|
||||||
}
|
/*
|
||||||
return this
|
* Is the check set?
|
||||||
|
*/
|
||||||
|
if (typeof params.check === 'undefined') return this.setResponse(404)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Attempt to read record from the database
|
||||||
|
*/
|
||||||
|
await this.read({ id: params.id })
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Does it exist?
|
||||||
|
*/
|
||||||
|
if (!this.record) return this.setResponse(404)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return data only if the check matches
|
||||||
|
*/
|
||||||
|
return this.clear.data.check === params.check
|
||||||
|
? this.setResponse200({
|
||||||
|
confirmation: {
|
||||||
|
id: this.record.id,
|
||||||
|
check: this.clear.data.check,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: this.setResponse(404)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,21 +2,43 @@ import { capitalize } from '../utils/index.mjs'
|
||||||
import { log } from '../utils/log.mjs'
|
import { log } from '../utils/log.mjs'
|
||||||
import { storeImage } from '../utils/cloudflare-images.mjs'
|
import { storeImage } from '../utils/cloudflare-images.mjs'
|
||||||
import yaml from 'js-yaml'
|
import yaml from 'js-yaml'
|
||||||
|
import { decorateModel } from '../utils/model-decorator.mjs'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This model handles all curated set updates
|
||||||
|
*/
|
||||||
export function CuratedSetModel(tools) {
|
export function CuratedSetModel(tools) {
|
||||||
this.config = tools.config
|
return decorateModel(this, tools, { name: 'curatedSet' })
|
||||||
this.prisma = tools.prisma
|
|
||||||
this.rbac = tools.rbac
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Creates a curated set
|
||||||
|
*
|
||||||
|
* @param {body} object - The request body
|
||||||
|
* @returns {CuratedSetModel} object - The CureatedSetModel
|
||||||
|
*/
|
||||||
CuratedSetModel.prototype.guardedCreate = async function ({ body, user }) {
|
CuratedSetModel.prototype.guardedCreate = async function ({ body, user }) {
|
||||||
|
/*
|
||||||
|
* Enforce RBAC
|
||||||
|
*/
|
||||||
if (!this.rbac.curator(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
if (!this.rbac.curator(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Do we have a POST body?
|
||||||
|
*/
|
||||||
if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing')
|
if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is nameEn set?
|
||||||
|
*/
|
||||||
if (!body.nameEn || typeof body.nameEn !== 'string') return this.setResponse(400, 'nameEnMissing')
|
if (!body.nameEn || typeof body.nameEn !== 'string') return this.setResponse(400, 'nameEnMissing')
|
||||||
|
|
||||||
// Prepare data
|
/*
|
||||||
|
* Prepare data to create the record
|
||||||
|
* A curated set is special in that it has a name and notes like a regular set, but it
|
||||||
|
* has those in every supported languages (eg nameEn and notesEn)
|
||||||
|
* So we need to iterated over languages + fields
|
||||||
|
*/
|
||||||
const data = {}
|
const data = {}
|
||||||
for (const lang of this.config.languages) {
|
for (const lang of this.config.languages) {
|
||||||
for (const field of ['name', 'notes']) {
|
for (const field of ['name', 'notes']) {
|
||||||
|
@ -26,105 +48,101 @@ CuratedSetModel.prototype.guardedCreate = async function ({ body, user }) {
|
||||||
const key = 'tags' + capitalize(lang)
|
const key = 'tags' + capitalize(lang)
|
||||||
if (body[key] && Array.isArray(body[key])) data[key] = body[key]
|
if (body[key] && Array.isArray(body[key])) data[key] = body[key]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Add the (sanitized) measurements if there are any
|
||||||
|
*/
|
||||||
if (body.measies) data.measies = this.sanitizeMeasurements(body.measies)
|
if (body.measies) data.measies = this.sanitizeMeasurements(body.measies)
|
||||||
else data.measies = {}
|
else data.measies = {}
|
||||||
// Set this one initially as we need the ID to store an image on cloudflare
|
|
||||||
|
/*
|
||||||
|
* Set an initial img as we need the record ID to store an image on cloudflare
|
||||||
|
*/
|
||||||
data.img = this.config.avatars.set
|
data.img = this.config.avatars.set
|
||||||
|
|
||||||
// Create record
|
/*
|
||||||
await this.unguardedCreate(data)
|
* Create the database record
|
||||||
|
*/
|
||||||
|
await this.createRecord(data)
|
||||||
|
|
||||||
// Update img? (now that we have the ID)
|
/*
|
||||||
const img =
|
* Now that we have a record and ID, we can update the image after uploading it to cloudflare
|
||||||
this.config.use.cloudflareImages &&
|
*/
|
||||||
typeof body.img === 'string' &&
|
const img = await storeImage(
|
||||||
(!body.test || (body.test && this.config.use.tests?.cloudflareImages))
|
{
|
||||||
? await storeImage({
|
id: `cset-${this.record.id}`,
|
||||||
id: `cset-${this.record.id}`,
|
metadata: { user: user.uid },
|
||||||
metadata: { user: user.uid },
|
b64: body.img,
|
||||||
b64: body.img,
|
},
|
||||||
})
|
this.isTest(body)
|
||||||
: false
|
)
|
||||||
|
|
||||||
if (img) await this.unguardedUpdate({ img: img.url })
|
/*
|
||||||
else await this.read({ id: this.record.id })
|
* If an image was uploaded, update the record with the image ID
|
||||||
|
*/
|
||||||
|
if (img) await this.update({ img: img.url })
|
||||||
|
/*
|
||||||
|
* If not, just read the record from the datbasa
|
||||||
|
*/ else await this.read({ id: this.record.id })
|
||||||
|
|
||||||
return this.setResponse(201, 'created', { curatedSet: this.asCuratedSet() })
|
/*
|
||||||
}
|
* Record created, return data in the proper format
|
||||||
|
*/
|
||||||
CuratedSetModel.prototype.unguardedCreate = async function (data) {
|
return this.setResponse201({ curatedSet: this.asCuratedSet() })
|
||||||
// FIXME: See https://github.com/prisma/prisma/issues/3786
|
|
||||||
if (data.measies && typeof data.measies === 'object') data.measies = JSON.stringify(data.measies)
|
|
||||||
for (const lang of this.config.languages) {
|
|
||||||
const key = `tags${capitalize(lang)}`
|
|
||||||
if (Array.isArray(data[key])) data[key] = JSON.stringify(data[key] || [])
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
this.record = await this.prisma.curatedSet.create({ data })
|
|
||||||
} catch (err) {
|
|
||||||
log.warn(err, 'Could not create set')
|
|
||||||
return this.setResponse(500, 'createSetFailed')
|
|
||||||
}
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Loads a measurements set from the database based on the where clause you pass it
|
|
||||||
*
|
|
||||||
* Stores result in this.record
|
|
||||||
*/
|
|
||||||
CuratedSetModel.prototype.read = async function (where) {
|
|
||||||
try {
|
|
||||||
this.record = await this.prisma.curatedSet.findUnique({ where })
|
|
||||||
} catch (err) {
|
|
||||||
log.warn({ err, where }, 'Could not read curated measurements set')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.record) {
|
|
||||||
// FIXME: Convert JSON to object. See https://github.com/prisma/prisma/issues/3786
|
|
||||||
this.record.measies = JSON.parse(this.record.measies)
|
|
||||||
for (const lang of this.config.languages) {
|
|
||||||
const key = `tags${capitalize(lang)}`
|
|
||||||
this.record[key] = JSON.parse(this.record[key] || '[]')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.curatedSetExists()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Loads a measurements set from the database based on the where clause you pass it
|
* Loads a measurements set from the database based on the where clause you pass it
|
||||||
* In addition prepares it for returning the set data
|
* In addition prepares it for returning the set data
|
||||||
*
|
*
|
||||||
* Stores result in this.record
|
* @param {params} object - The (URL) params from the request
|
||||||
|
* @param {format} string - The format to use (json or yaml)
|
||||||
|
* @returns {CuratedSetModel} object - The CureatedSetModel
|
||||||
*/
|
*/
|
||||||
CuratedSetModel.prototype.guardedRead = async function ({ params }, format = false) {
|
CuratedSetModel.prototype.guardedRead = async function ({ params }, format = false) {
|
||||||
|
/*
|
||||||
|
* Read record from database
|
||||||
|
*/
|
||||||
await this.read({ id: parseInt(params.id) })
|
await this.read({ id: parseInt(params.id) })
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If no format is specified, return as object
|
||||||
|
*/
|
||||||
if (!format)
|
if (!format)
|
||||||
return this.setResponse(200, false, {
|
return this.setResponse200({
|
||||||
result: 'success',
|
result: 'success',
|
||||||
curatedSet: this.asCuratedSet(),
|
curatedSet: this.asCuratedSet(),
|
||||||
})
|
})
|
||||||
|
|
||||||
return this.setResponse(200, false, this.asData(), true)
|
return this.setResponse200(this.asData(), true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Returns a list of all curated sets
|
* Returns a list of all curated sets
|
||||||
|
*
|
||||||
|
* @returns {list} array - The list of curated sets
|
||||||
*/
|
*/
|
||||||
CuratedSetModel.prototype.allCuratedSets = async function () {
|
CuratedSetModel.prototype.allCuratedSets = async function () {
|
||||||
|
/*
|
||||||
|
* Attempt to read all curates sets from the database
|
||||||
|
*/
|
||||||
let curatedSets
|
let curatedSets
|
||||||
try {
|
try {
|
||||||
curatedSets = await this.prisma.curatedSet.findMany()
|
curatedSets = await this.prisma.curatedSet.findMany()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn(`Failed to search curated sets: ${err}`)
|
log.warn(`Failed to search curated sets: ${err}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Iterate over list to do some housekeeping and JSON wrangling
|
||||||
|
*/
|
||||||
const list = []
|
const list = []
|
||||||
for (const curatedSet of curatedSets) {
|
for (const curatedSet of curatedSets) {
|
||||||
// FIXME: Convert object to JSON. See https://github.com/prisma/prisma/issues/3786
|
|
||||||
const asPojo = { ...curatedSet }
|
const asPojo = { ...curatedSet }
|
||||||
|
/*
|
||||||
|
* We need to parse this from JSON
|
||||||
|
* See https://github.com/prisma/prisma/issues/3786
|
||||||
|
*/
|
||||||
asPojo.measies = JSON.parse(asPojo.measies)
|
asPojo.measies = JSON.parse(asPojo.measies)
|
||||||
for (const lang of this.config.languages) {
|
for (const lang of this.config.languages) {
|
||||||
const key = `tags${capitalize(lang)}`
|
const key = `tags${capitalize(lang)}`
|
||||||
|
@ -140,152 +158,174 @@ CuratedSetModel.prototype.allCuratedSets = async function () {
|
||||||
* Clones a curated measurements set (into a regular set)
|
* Clones a curated measurements set (into a regular set)
|
||||||
* In addition prepares it for returning the set data
|
* In addition prepares it for returning the set data
|
||||||
*
|
*
|
||||||
* Stores result in this.record
|
* @param {params} object - The (URL) params from the request
|
||||||
|
* @param {user} string - The user object as loaded by auth middleware
|
||||||
|
* @param {body} string - The request body
|
||||||
|
* @param {Set} SetModel - The Set to clone into
|
||||||
|
* @returns {CuratedSetModel} object - The CureatedSetModel
|
||||||
*/
|
*/
|
||||||
CuratedSetModel.prototype.guardedClone = async function ({ params, user, body }, Set) {
|
CuratedSetModel.prototype.guardedClone = async function ({ params, user, body }, Set) {
|
||||||
|
/*
|
||||||
|
* Enforce RBAC
|
||||||
|
*/
|
||||||
if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Verify JWT
|
||||||
|
*/
|
||||||
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
|
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is language set?
|
||||||
|
*/
|
||||||
if (!body.language || !this.config.languages.includes(body.language))
|
if (!body.language || !this.config.languages.includes(body.language))
|
||||||
return this.setResponse(403, 'languageMissing')
|
return this.setResponse(403, 'languageMissing')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Read record from database
|
||||||
|
*/
|
||||||
await this.read({ id: parseInt(params.id) })
|
await this.read({ id: parseInt(params.id) })
|
||||||
|
|
||||||
// Clone curated set
|
/*
|
||||||
const data = {}
|
* Create data for the cloned set
|
||||||
|
*/
|
||||||
const lang = capitalize(body.language.toLowerCase())
|
const lang = capitalize(body.language.toLowerCase())
|
||||||
data.name = this.record[`name${lang}`]
|
const data = {
|
||||||
data.notes = this.record[`notes${lang}`]
|
lang,
|
||||||
data.measies = this.record.measies
|
name: this.record[`name${lang}`],
|
||||||
|
notes: this.record[`notes${lang}`],
|
||||||
|
measies: this.record.measies,
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Clone curated set into regular set
|
||||||
|
*/
|
||||||
await Set.guardedCreate({ params, user, body: data })
|
await Set.guardedCreate({ params, user, body: data })
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* Checks this.record and sets a boolean to indicate whether
|
|
||||||
* the curated set exists or not
|
|
||||||
*
|
|
||||||
* Stores result in this.exists
|
|
||||||
*/
|
|
||||||
CuratedSetModel.prototype.curatedSetExists = function () {
|
|
||||||
this.exists = this.record ? true : false
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Updates the set data - Used when we create the data ourselves
|
|
||||||
* so we know it's safe
|
|
||||||
*/
|
|
||||||
CuratedSetModel.prototype.unguardedUpdate = async function (data) {
|
|
||||||
// FIXME: Convert object to JSON. See https://github.com/prisma/prisma/issues/3786
|
|
||||||
if (data.measies && typeof data.measies === 'object') data.measies = JSON.stringify(data.measies)
|
|
||||||
for (const lang of this.config.languages) {
|
|
||||||
const key = `tags${capitalize(lang)}`
|
|
||||||
if (data[key] && Array.isArray(data[key])) data[key] = JSON.stringify(data[key] || [])
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.record = await this.prisma.curatedSet.update({
|
|
||||||
where: { id: this.record.id },
|
|
||||||
data,
|
|
||||||
})
|
|
||||||
// FIXME: Convert JSON to object. See https://github.com/prisma/prisma/issues/3786
|
|
||||||
this.record.measies = JSON.parse(this.record.measies)
|
|
||||||
for (const lang of this.config.languages) {
|
|
||||||
const key = `tags${capitalize(lang)}`
|
|
||||||
this.record[key] = JSON.parse(this.record[key])
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
log.warn(err, 'Could not update set record')
|
|
||||||
process.exit()
|
|
||||||
return this.setResponse(500, 'updateSetFailed')
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.setResponse(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Updates the set data - Used when we pass through user-provided data
|
* Updates the set data - Used when we pass through user-provided data
|
||||||
* so we can't be certain it's safe
|
* so we can't be certain it's safe
|
||||||
|
*
|
||||||
|
* @param {params} object - The (URL) params from the request
|
||||||
|
* @param {body} string - The request body
|
||||||
|
* @param {user} string - The user object as loaded by auth middleware
|
||||||
|
* @returns {CuratedSetModel} object - The CureatedSetModel
|
||||||
*/
|
*/
|
||||||
CuratedSetModel.prototype.guardedUpdate = async function ({ params, body, user }) {
|
CuratedSetModel.prototype.guardedUpdate = async function ({ params, body, user }) {
|
||||||
|
/*
|
||||||
|
* Enforce RBAC
|
||||||
|
*/
|
||||||
if (!this.rbac.curator(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
if (!this.rbac.curator(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Verify JWT token for user status
|
||||||
|
*/
|
||||||
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
|
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Attempt to read database record
|
||||||
|
*/
|
||||||
await this.read({ id: parseInt(params.id) })
|
await this.read({ id: parseInt(params.id) })
|
||||||
|
|
||||||
// Prepare data
|
/*
|
||||||
|
* Prepare data for updating the record
|
||||||
|
*/
|
||||||
const data = {}
|
const data = {}
|
||||||
|
/*
|
||||||
|
* Unlike a regular set, curated set have notes and name in each language
|
||||||
|
*/
|
||||||
for (const lang of this.config.languages) {
|
for (const lang of this.config.languages) {
|
||||||
for (const field of ['name', 'notes']) {
|
for (const field of ['name', 'notes']) {
|
||||||
const key = field + capitalize(lang)
|
const key = field + capitalize(lang)
|
||||||
if (body[key] && typeof body[key] === 'string') data[key] = body[key]
|
if (body[key] && typeof body[key] === 'string') data[key] = body[key]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Measurements
|
/*
|
||||||
const measies = {}
|
* Handle the measurements
|
||||||
|
*/
|
||||||
if (typeof body.measies === 'object') {
|
if (typeof body.measies === 'object') {
|
||||||
const remove = []
|
data.measies = this.sanitizeMeasurements({
|
||||||
for (const [key, val] of Object.entries(body.measies)) {
|
...this.record.measies,
|
||||||
if (this.config.measies.includes(key)) {
|
...body.measies,
|
||||||
if (val === null) remove.push(key)
|
})
|
||||||
else if (typeof val == 'number' && val > 0) measies[key] = val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
data.measies = { ...this.record.measies, ...measies }
|
|
||||||
for (const key of remove) delete data.measies[key]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Image (img)
|
/*
|
||||||
|
* Handle the image, if there is one
|
||||||
|
*/
|
||||||
if (typeof body.img === 'string') {
|
if (typeof body.img === 'string') {
|
||||||
const img = await storeImage({
|
const img = await storeImage(
|
||||||
id: `cset-${this.record.id}`,
|
{
|
||||||
metadata: { user: this.user.uid },
|
id: `cset-${this.record.id}`,
|
||||||
b64: body.img,
|
metadata: { user: this.user.uid },
|
||||||
})
|
b64: body.img,
|
||||||
|
},
|
||||||
|
this.isTest(body)
|
||||||
|
)
|
||||||
data.img = img.url
|
data.img = img.url
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now update the record
|
/*
|
||||||
await this.unguardedUpdate(data)
|
* Now update the record
|
||||||
|
*/
|
||||||
|
await this.update(data)
|
||||||
|
|
||||||
return this.setResponse(200, false, { set: this.asCuratedSet() })
|
/*
|
||||||
|
* Return 200 with updated data
|
||||||
|
*/
|
||||||
|
return this.setResponse200({ set: this.asCuratedSet() })
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Removes the set - No questions asked
|
* Removes the set - But checks permissions
|
||||||
*/
|
*
|
||||||
CuratedSetModel.prototype.unguardedDelete = async function () {
|
* @param {params} object - The (URL) params from the request
|
||||||
await this.prisma.curatedSet.delete({ where: { id: this.record.id } })
|
* @param {user} string - The user object as loaded by auth middleware
|
||||||
this.record = null
|
* @returns {CuratedSetModel} object - The CureatedSetModel
|
||||||
this.clear = null
|
|
||||||
|
|
||||||
return this.curatedSetExists()
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Removes the set - Checks permissions
|
|
||||||
*/
|
*/
|
||||||
CuratedSetModel.prototype.guardedDelete = async function ({ params, user }) {
|
CuratedSetModel.prototype.guardedDelete = async function ({ params, user }) {
|
||||||
|
/*
|
||||||
|
* Enforce RBAC
|
||||||
|
*/
|
||||||
if (!this.rbac.curator(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
if (!this.rbac.curator(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Make sure the account is ok by checking the JWT
|
||||||
|
*/
|
||||||
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
|
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Find the database record
|
||||||
|
*/
|
||||||
await this.read({ id: parseInt(params.id) })
|
await this.read({ id: parseInt(params.id) })
|
||||||
await this.unguardedDelete()
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Now delete it
|
||||||
|
*/
|
||||||
|
await this.delete()
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return 204
|
||||||
|
*/
|
||||||
return this.setResponse(204, false)
|
return this.setResponse(204, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Returns record data fit for public publishing
|
* Returns record data fit for public publishing
|
||||||
|
*
|
||||||
|
* @returns {curatedSet} object - The Cureated Set as a plain object
|
||||||
*/
|
*/
|
||||||
CuratedSetModel.prototype.asCuratedSet = function () {
|
CuratedSetModel.prototype.asCuratedSet = function () {
|
||||||
return { ...this.record }
|
return { ...this.unserialize(this.record) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Returns record data fit for public publishing
|
* Returns record data fit for public publishing
|
||||||
|
*
|
||||||
|
* @returns {curatedSet} object - The Cureated Set as a plain object
|
||||||
*/
|
*/
|
||||||
CuratedSetModel.prototype.asData = function () {
|
CuratedSetModel.prototype.asData = function () {
|
||||||
const data = {
|
const data = {
|
||||||
|
@ -299,57 +339,3 @@ CuratedSetModel.prototype.asData = function () {
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* Helper method to set the response code, result, and body
|
|
||||||
*
|
|
||||||
* Will be used by this.sendResponse()
|
|
||||||
*/
|
|
||||||
CuratedSetModel.prototype.setResponse = function (
|
|
||||||
status = 200,
|
|
||||||
error = false,
|
|
||||||
data = {},
|
|
||||||
rawData = false
|
|
||||||
) {
|
|
||||||
this.response = {
|
|
||||||
status,
|
|
||||||
body: rawData
|
|
||||||
? data
|
|
||||||
: {
|
|
||||||
result: 'success',
|
|
||||||
...data,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if (status > 201) {
|
|
||||||
this.response.body.error = error
|
|
||||||
this.response.body.result = 'error'
|
|
||||||
this.error = true
|
|
||||||
} else this.error = false
|
|
||||||
|
|
||||||
return this.curatedSetExists()
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Helper method to send response (as JSON)
|
|
||||||
*/
|
|
||||||
CuratedSetModel.prototype.sendResponse = async function (res) {
|
|
||||||
return res.status(this.response.status).send(this.response.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Helper method to send response as YAML
|
|
||||||
*/
|
|
||||||
CuratedSetModel.prototype.sendYamlResponse = async function (res) {
|
|
||||||
return res.status(this.response.status).type('yaml').send(yaml.dump(this.response.body))
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Helper method to parse user-supplied measurements */
|
|
||||||
CuratedSetModel.prototype.sanitizeMeasurements = function (input) {
|
|
||||||
const measies = {}
|
|
||||||
if (typeof input !== 'object') return measies
|
|
||||||
for (const [m, val] of Object.entries(input)) {
|
|
||||||
if (this.config.measies.includes(m) && typeof val === 'number' && val > 0) measies[m] = val
|
|
||||||
}
|
|
||||||
|
|
||||||
return measies
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,29 +1,53 @@
|
||||||
import { UserModel } from './user.mjs'
|
|
||||||
import { i18nUrl } from '../utils/index.mjs'
|
import { i18nUrl } from '../utils/index.mjs'
|
||||||
|
import { decorateModel } from '../utils/model-decorator.mjs'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This model handles all flows (typically that involves sending out emails)
|
||||||
|
*/
|
||||||
export function FlowModel(tools) {
|
export function FlowModel(tools) {
|
||||||
this.config = tools.config
|
return decorateModel(this, tools, {
|
||||||
this.mailer = tools.email
|
name: 'flow',
|
||||||
this.rbac = tools.rbac
|
models: ['user'],
|
||||||
this.User = new UserModel(tools)
|
})
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Send a translator invite
|
* Send a translator invite
|
||||||
|
*
|
||||||
|
* @param {body} object - The request body
|
||||||
|
* @param {user} object - The user as loaded by auth middleware
|
||||||
|
* @returns {FlowModel} object - The FlowModel
|
||||||
*/
|
*/
|
||||||
FlowModel.prototype.sendTranslatorInvite = async function ({ body, user }) {
|
FlowModel.prototype.sendTranslatorInvite = async function ({ body, user }) {
|
||||||
|
/*
|
||||||
|
* Enforce RBAC
|
||||||
|
*/
|
||||||
if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Do we have a POST body?
|
||||||
|
*/
|
||||||
if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing')
|
if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is language set?
|
||||||
|
*/
|
||||||
if (!body.language) return this.setResponse(400, 'languageMissing')
|
if (!body.language) return this.setResponse(400, 'languageMissing')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is language a valid language?
|
||||||
|
*/
|
||||||
if (!this.config.translations.includes(body.language))
|
if (!this.config.translations.includes(body.language))
|
||||||
return this.setResponse(400, 'languageInvalid')
|
return this.setResponse(400, 'languageInvalid')
|
||||||
|
|
||||||
// Load user making the call
|
/*
|
||||||
|
* Load user record from database
|
||||||
|
*/
|
||||||
await this.User.revealAuthenticatedUser(user)
|
await this.User.revealAuthenticatedUser(user)
|
||||||
|
|
||||||
// Send the invite email
|
/*
|
||||||
|
* Send the invite email
|
||||||
|
*/
|
||||||
await this.mailer.send({
|
await this.mailer.send({
|
||||||
template: 'transinvite',
|
template: 'transinvite',
|
||||||
language: body.language,
|
language: body.language,
|
||||||
|
@ -35,21 +59,43 @@ FlowModel.prototype.sendTranslatorInvite = async function ({ body, user }) {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return this.setResponse(200, 'sent', {})
|
/*
|
||||||
|
* Return 200
|
||||||
|
*/
|
||||||
|
return this.setResponse200({})
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Send a language suggestion to the maintainer
|
* Send a language suggestion to the maintainer
|
||||||
|
*
|
||||||
|
* @param {body} object - The request body
|
||||||
|
* @param {user} object - The user as loaded by auth middleware
|
||||||
|
* @returns {FlowModel} object - The FlowModel
|
||||||
*/
|
*/
|
||||||
FlowModel.prototype.sendLanguageSuggestion = async function ({ body, user }) {
|
FlowModel.prototype.sendLanguageSuggestion = async function ({ body, user }) {
|
||||||
|
/*
|
||||||
|
* Enforce RBAC
|
||||||
|
*/
|
||||||
if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Do we have a POST body?
|
||||||
|
*/
|
||||||
if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing')
|
if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is language set?
|
||||||
|
*/
|
||||||
if (!body.language) return this.setResponse(400, 'languageMissing')
|
if (!body.language) return this.setResponse(400, 'languageMissing')
|
||||||
|
|
||||||
// Load user making the call
|
/*
|
||||||
|
* Load user making the call
|
||||||
|
*/
|
||||||
await this.User.revealAuthenticatedUser(user)
|
await this.User.revealAuthenticatedUser(user)
|
||||||
|
|
||||||
// Send the invite email
|
/*
|
||||||
|
* Send the invite email
|
||||||
|
*/
|
||||||
await this.mailer.send({
|
await this.mailer.send({
|
||||||
template: 'langsuggest',
|
template: 'langsuggest',
|
||||||
language: body.language,
|
language: body.language,
|
||||||
|
@ -61,35 +107,8 @@ FlowModel.prototype.sendLanguageSuggestion = async function ({ body, user }) {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return this.setResponse(200, 'sent', {})
|
/*
|
||||||
}
|
* Return 200
|
||||||
|
*/
|
||||||
/*
|
return this.setResponse200({})
|
||||||
* Helper method to set the response code, result, and body
|
|
||||||
*
|
|
||||||
* Will be used by this.sendResponse()
|
|
||||||
*/
|
|
||||||
FlowModel.prototype.setResponse = function (status = 200, error = false, data = {}) {
|
|
||||||
this.response = {
|
|
||||||
status,
|
|
||||||
body: {
|
|
||||||
result: 'success',
|
|
||||||
...data,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if (status > 201) {
|
|
||||||
this.response.body.error = error
|
|
||||||
this.response.body.result = 'error'
|
|
||||||
this.error = true
|
|
||||||
} else this.error = false
|
|
||||||
if (status === 404) this.response.body = null
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Helper method to send response
|
|
||||||
*/
|
|
||||||
FlowModel.prototype.sendResponse = async function (res) {
|
|
||||||
return res.status(this.response.status).send(this.response.body)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,73 +1,47 @@
|
||||||
import fetch from 'node-fetch'
|
import { decorateModel } from '../utils/model-decorator.mjs'
|
||||||
import { UserModel } from './user.mjs'
|
import { createIssue } from '../utils/github.mjs'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This model handles all flows (typically that involves sending out emails)
|
||||||
|
*/
|
||||||
export function IssueModel(tools) {
|
export function IssueModel(tools) {
|
||||||
this.config = tools.config
|
return decorateModel(this, tools, {
|
||||||
this.prisma = tools.prisma
|
name: 'flow',
|
||||||
this.User = new UserModel(tools)
|
models: ['user'],
|
||||||
this.token = tools.config.github.token
|
})
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
IssueModel.prototype.setResponse = function (status = 200, error = false, data = {}) {
|
|
||||||
this.response = {
|
|
||||||
status,
|
|
||||||
body: {
|
|
||||||
result: 'success',
|
|
||||||
...data,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if (status === 201) this.response.body.result = 'created'
|
|
||||||
else if (status > 204) {
|
|
||||||
this.response.body.error = error
|
|
||||||
this.response.body.result = 'error'
|
|
||||||
this.error = true
|
|
||||||
} else this.error = false
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
IssueModel.prototype.sendResponse = async function (res) {
|
|
||||||
return res.status(this.response.status).send(this.response.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
IssueModel.prototype.unguardedDelete = async function () {
|
|
||||||
await this.prisma.apikey.delete({ where: { id: this.record.id } })
|
|
||||||
this.record = null
|
|
||||||
this.clear = null
|
|
||||||
|
|
||||||
return this.setExists()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Create an issue
|
||||||
|
*
|
||||||
|
* @param {body} object - The request body
|
||||||
|
* @returns {IssueModel} object - The IssueModel
|
||||||
|
*/
|
||||||
IssueModel.prototype.create = async function ({ body }) {
|
IssueModel.prototype.create = async function ({ body }) {
|
||||||
if (!this.token) return this.setResponse(400, 'notEnabled')
|
/*
|
||||||
|
* Is issue creation enabled
|
||||||
|
*/
|
||||||
|
if (!this.config.use.github) return this.setResponse(400, 'notEnabled')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Do we have a POST body?
|
||||||
|
*/
|
||||||
if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing')
|
if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is title set?
|
||||||
|
*/
|
||||||
if (!body.title) return this.setResponse(400, 'titleMissing')
|
if (!body.title) return this.setResponse(400, 'titleMissing')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is body set?
|
||||||
|
*/
|
||||||
if (!body.body) return this.setResponse(400, 'bodyMissing')
|
if (!body.body) return this.setResponse(400, 'bodyMissing')
|
||||||
|
|
||||||
const apiUrl = `https://api.github.com/repos/freesewing/freesewing/issues`
|
/*
|
||||||
let response
|
* Create the issue
|
||||||
try {
|
*/
|
||||||
response = await fetch(apiUrl, {
|
const issue = await createIssue(body)
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${this.token}`,
|
|
||||||
Accept: 'application/vnd.github.v3+json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.status === 201) response = await response.json()
|
return issue ? this.setResponse201({ issue }) : this.setResponse(400)
|
||||||
else {
|
|
||||||
console.log(response)
|
|
||||||
response = false
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('An error occurred while creating a GitHub issue:', error.message)
|
|
||||||
response = false
|
|
||||||
}
|
|
||||||
|
|
||||||
return response ? this.setResponse(201, 'created', { issue: response }) : this.setResponse(400)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,26 +2,34 @@ import { log } from '../utils/log.mjs'
|
||||||
import { capitalize } from '../utils/index.mjs'
|
import { capitalize } from '../utils/index.mjs'
|
||||||
import { storeImage } from '../utils/cloudflare-images.mjs'
|
import { storeImage } from '../utils/cloudflare-images.mjs'
|
||||||
import yaml from 'js-yaml'
|
import yaml from 'js-yaml'
|
||||||
import { SetModel } from './set.mjs'
|
import { decorateModel } from '../utils/model-decorator.mjs'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This model handles all flows (typically that involves sending out emails)
|
||||||
|
*/
|
||||||
export function PatternModel(tools) {
|
export function PatternModel(tools) {
|
||||||
this.config = tools.config
|
return decorateModel(this, tools, {
|
||||||
this.prisma = tools.prisma
|
name: 'pattern',
|
||||||
this.decrypt = tools.decrypt
|
encryptedFields: ['data', 'img', 'name', 'notes', 'settings'],
|
||||||
this.encrypt = tools.encrypt
|
models: ['set'],
|
||||||
this.rbac = tools.rbac
|
})
|
||||||
this.encryptedFields = ['data', 'img', 'name', 'notes', 'settings']
|
|
||||||
this.clear = {} // For holding decrypted data
|
|
||||||
this.Set = new SetModel(tools)
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Returns a list of sets for the user making the API call
|
* Returns a list of patterns for the user making the API call
|
||||||
|
*
|
||||||
|
* @param {uid} string - uid of the user, as provided by the auth middleware
|
||||||
|
* @returns {patterns} array - The list of patterns
|
||||||
*/
|
*/
|
||||||
PatternModel.prototype.userPatterns = async function (uid) {
|
PatternModel.prototype.userPatterns = async function (uid) {
|
||||||
|
/*
|
||||||
|
* No uid no deal
|
||||||
|
*/
|
||||||
if (!uid) return false
|
if (!uid) return false
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Run query returning all patterns from the database
|
||||||
|
*/
|
||||||
let patterns
|
let patterns
|
||||||
try {
|
try {
|
||||||
patterns = await this.prisma.pattern.findMany({
|
patterns = await this.prisma.pattern.findMany({
|
||||||
|
@ -34,315 +42,346 @@ PatternModel.prototype.userPatterns = async function (uid) {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn(`Failed to search patterns for user ${uid}: ${err}`)
|
log.warn(`Failed to search patterns for user ${uid}: ${err}`)
|
||||||
}
|
}
|
||||||
const list = []
|
|
||||||
for (const pattern of patterns) list.push(this.revealPattern(pattern))
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Decrypt data for all patterns found
|
||||||
|
*/
|
||||||
|
const list = patterns.map((pattern) => this.revealPattern(pattern))
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return the list of patterns
|
||||||
|
*/
|
||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Creates a new pattern - Takes user input so we validate data and access
|
||||||
|
*
|
||||||
|
* @param {body} object - The request body
|
||||||
|
* @param {user} object - The user data as provided by the auth middleware
|
||||||
|
* @returns {PatternModel} object - The PatternModel
|
||||||
|
*/
|
||||||
PatternModel.prototype.guardedCreate = async function ({ body, user }) {
|
PatternModel.prototype.guardedCreate = async function ({ body, user }) {
|
||||||
|
/*
|
||||||
|
* Enforce RBAC
|
||||||
|
*/
|
||||||
if (!this.rbac.user(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
if (!this.rbac.user(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Do we have a POST body?
|
||||||
|
*/
|
||||||
if (Object.keys(body).length < 2) return this.setResponse(400, 'postBodyMissing')
|
if (Object.keys(body).length < 2) return this.setResponse(400, 'postBodyMissing')
|
||||||
if (!body.set && !body.cset) return this.setResponse(400, 'setOrCsetMissing')
|
|
||||||
if (typeof body.set !== 'undefined' && typeof body.set !== 'number')
|
/*
|
||||||
return this.setResponse(400, 'setNotNumeric')
|
* Is settings set?
|
||||||
if (typeof body.cset !== 'undefined' && typeof body.cset !== 'number')
|
*/
|
||||||
return this.setResponse(400, 'csetNotNumeric')
|
|
||||||
if (typeof body.settings !== 'object') return this.setResponse(400, 'settingsNotAnObject')
|
if (typeof body.settings !== 'object') return this.setResponse(400, 'settingsNotAnObject')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is data set?
|
||||||
|
*/
|
||||||
if (body.data && typeof body.data !== 'object') return this.setResponse(400, 'dataNotAnObject')
|
if (body.data && typeof body.data !== 'object') return this.setResponse(400, 'dataNotAnObject')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is design set?
|
||||||
|
*/
|
||||||
if (!body.design && !body.data?.design) return this.setResponse(400, 'designMissing')
|
if (!body.design && !body.data?.design) return this.setResponse(400, 'designMissing')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is design a string?
|
||||||
|
*/
|
||||||
if (typeof body.design !== 'string') return this.setResponse(400, 'designNotStringy')
|
if (typeof body.design !== 'string') return this.setResponse(400, 'designNotStringy')
|
||||||
|
|
||||||
// Prepare data
|
/*
|
||||||
const data = {
|
* Create initial record
|
||||||
|
*/
|
||||||
|
await this.createRecord({
|
||||||
|
csetId: body.cset ? body.cset : null,
|
||||||
|
data: typeof body.data === 'object' ? body.data : {},
|
||||||
design: body.design,
|
design: body.design,
|
||||||
settings: body.settings,
|
img: this.config.avatars.pattern,
|
||||||
}
|
setId: body.set ? body.set : null,
|
||||||
if (data.settings.measurements) delete data.settings.measurements
|
settings: {
|
||||||
if (body.set) data.setId = body.set
|
...body.settings,
|
||||||
else if (body.cset) data.csetId = body.cset
|
measurements: body.settings.measurements === 'object' ? body.settings.measurements : {},
|
||||||
else return this.setResponse(400, 'setOrCsetMissing')
|
},
|
||||||
|
userId: user.uid,
|
||||||
|
name: typeof body.name === 'string' && body.name.length > 0 ? body.name : '--',
|
||||||
|
notes: typeof body.notes === 'string' && body.notes.length > 0 ? body.notes : '--',
|
||||||
|
public: body.public === true ? true : false,
|
||||||
|
})
|
||||||
|
|
||||||
// Data (will be encrypted, so always set _some_ value)
|
/*
|
||||||
if (typeof body.data === 'object') data.data = body.data
|
* Now that we have a record ID, we can update the image
|
||||||
else data.data = {}
|
*/
|
||||||
// Name (will be encrypted, so always set _some_ value)
|
const img = await storeImage(
|
||||||
if (typeof body.name === 'string' && body.name.length > 0) data.name = body.name
|
{
|
||||||
else data.name = '--'
|
id: `pattern-${this.record.id}`,
|
||||||
// Notes (will be encrypted, so always set _some_ value)
|
metadata: { user: user.uid },
|
||||||
if (typeof body.notes === 'string' && body.notes.length > 0) data.notes = body.notes
|
b64: body.img,
|
||||||
else data.notes = '--'
|
},
|
||||||
// Public
|
this.isTest(body)
|
||||||
if (body.public === true) data.public = true
|
)
|
||||||
data.userId = user.uid
|
|
||||||
// Set this one initially as we need the ID to store an image on cloudflare
|
|
||||||
data.img = this.config.avatars.pattern
|
|
||||||
|
|
||||||
// Create record
|
/*
|
||||||
await this.unguardedCreate(data)
|
* If an image was created, update the record with its ID
|
||||||
|
* If not, just update the record from the database
|
||||||
|
*/
|
||||||
|
if (img) await this.update(this.cloak({ img: img.url }))
|
||||||
|
else await this.read({ id: this.record.id }, { set: true, cset: true })
|
||||||
|
|
||||||
// Update img? (now that we have the ID)
|
/*
|
||||||
const img =
|
* Now return 201 and the record data
|
||||||
this.config.use.cloudflareImages &&
|
*/
|
||||||
typeof body.img === 'string' &&
|
return this.setResponse201({ pattern: this.asPattern() })
|
||||||
(!body.test || (body.test && this.config.use.tests?.cloudflareImages))
|
|
||||||
? await storeImage({
|
|
||||||
id: `pattern-${this.record.id}`,
|
|
||||||
metadata: { user: user.uid },
|
|
||||||
b64: body.img,
|
|
||||||
})
|
|
||||||
: false
|
|
||||||
|
|
||||||
if (img) await this.unguardedUpdate(this.cloak({ img: img.url }))
|
|
||||||
else await this.read({ id: this.record.id })
|
|
||||||
|
|
||||||
return this.setResponse(201, 'created', { pattern: this.asPattern() })
|
|
||||||
}
|
|
||||||
|
|
||||||
PatternModel.prototype.unguardedCreate = async function (data) {
|
|
||||||
try {
|
|
||||||
this.record = await this.prisma.pattern.create({ data: this.cloak(data) })
|
|
||||||
} catch (err) {
|
|
||||||
log.warn(err, 'Could not create pattern')
|
|
||||||
return this.setResponse(500, 'createPatternFailed')
|
|
||||||
}
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Loads a pattern from the database based on the where clause you pass it
|
|
||||||
*
|
|
||||||
* Stores result in this.record
|
|
||||||
*/
|
|
||||||
PatternModel.prototype.read = async function (where) {
|
|
||||||
try {
|
|
||||||
this.record = await this.prisma.pattern.findUnique({
|
|
||||||
where,
|
|
||||||
include: {
|
|
||||||
set: true,
|
|
||||||
cset: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
log.warn({ err, where }, 'Could not read pattern')
|
|
||||||
}
|
|
||||||
|
|
||||||
this.reveal()
|
|
||||||
|
|
||||||
return this.setExists()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Loads a pattern from the database but only if it's public
|
* Loads a pattern from the database but only if it's public
|
||||||
*
|
*
|
||||||
* Stores result in this.record
|
* @param {params} object - The request (URL) parameters
|
||||||
|
* @returns {PatternModel} object - The PatternModel
|
||||||
*/
|
*/
|
||||||
PatternModel.prototype.publicRead = async function ({ params }) {
|
PatternModel.prototype.publicRead = async function ({ params }) {
|
||||||
await this.read({ id: parseInt(params.id) })
|
/*
|
||||||
if (this.record.public !== true) {
|
* Attempt to read the database record
|
||||||
// Note that we return 404
|
*/
|
||||||
// because we don't want to reveal that a non-public pattern exists.
|
await this.read({ id: parseInt(params.id) }, { set: true, cset: true })
|
||||||
return this.setResponse(404)
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.setResponse(200, false, this.asPublicPattern(), true)
|
/*
|
||||||
|
* Ensure it is public and if it is not public, return 404
|
||||||
|
* rather than reveal that a non-public pattern exists
|
||||||
|
*/
|
||||||
|
if (this.record.public !== true) return this.setResponse(404)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return pattern
|
||||||
|
*/
|
||||||
|
return this.setResponse200(this.asPublicPattern())
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Loads a pattern from the database based on the where clause you pass it
|
* Loads a pattern from the database based on the where clause you pass it
|
||||||
* In addition prepares it for returning the pattern data
|
* In addition prepares it for returning the pattern data
|
||||||
*
|
*
|
||||||
* Stores result in this.record
|
* @param {params} object - The request (URL) parameters
|
||||||
|
* @param {user} object - The user data as provided by the auth middleware
|
||||||
|
* @returns {PatternModel} object - The PatternModel
|
||||||
*/
|
*/
|
||||||
PatternModel.prototype.guardedRead = async function ({ params, user }) {
|
PatternModel.prototype.guardedRead = async function ({ params, user }) {
|
||||||
|
/*
|
||||||
|
* Enforce RBAC
|
||||||
|
*/
|
||||||
if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check JWT for status
|
||||||
|
*/
|
||||||
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
|
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is the id set?
|
||||||
|
*/
|
||||||
if (typeof params.id !== 'undefined' && !Number(params.id))
|
if (typeof params.id !== 'undefined' && !Number(params.id))
|
||||||
return this.setResponse(403, 'idNotNumeric')
|
return this.setResponse(403, 'idNotNumeric')
|
||||||
|
|
||||||
await this.read({ id: parseInt(params.id) })
|
/*
|
||||||
|
* Attempt to read record from database
|
||||||
|
*/
|
||||||
|
await this.read({ id: parseInt(params.id) }, { set: true, cset: true })
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return 404 if it cannot be found
|
||||||
|
*/
|
||||||
if (!this.record) return this.setResponse(404, 'notFound')
|
if (!this.record) return this.setResponse(404, 'notFound')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* You need at least the bughunter role to read another user's pattern
|
||||||
|
*/
|
||||||
if (this.record.userId !== user.uid && !this.rbac.bughunter(user)) {
|
if (this.record.userId !== user.uid && !this.rbac.bughunter(user)) {
|
||||||
return this.setResponse(403, 'insufficientAccessLevel')
|
return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.setResponse(200, false, {
|
/*
|
||||||
result: 'success',
|
* Return the loaded pattern
|
||||||
pattern: this.asPattern(),
|
*/
|
||||||
})
|
return this.setResponse200({ pattern: this.asPattern() })
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Clones a pattern
|
* Clones a pattern
|
||||||
* In addition prepares it for returning the pattern data
|
* In addition prepares it for returning the pattern data
|
||||||
*
|
*
|
||||||
* Stores result in this.record
|
* @param {params} object - The request (URL) parameters
|
||||||
|
* @param {user} object - The user data as provided by the auth middleware
|
||||||
|
* @returns {PatternModel} object - The PatternModel
|
||||||
*/
|
*/
|
||||||
PatternModel.prototype.guardedClone = async function ({ params, user }) {
|
PatternModel.prototype.guardedClone = async function ({ params, user }) {
|
||||||
|
/*
|
||||||
|
* Enforce RBAC
|
||||||
|
*/
|
||||||
if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check JWT
|
||||||
|
*/
|
||||||
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
|
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Attempt to read record from database
|
||||||
|
*/
|
||||||
await this.read({ id: parseInt(params.id) })
|
await this.read({ id: parseInt(params.id) })
|
||||||
|
|
||||||
|
/*
|
||||||
|
* You need the support role to clone another user's pattern that is not public
|
||||||
|
*/
|
||||||
if (this.record.userId !== user.uid && !this.record.public && !this.rbac.support(user)) {
|
if (this.record.userId !== user.uid && !this.record.public && !this.rbac.support(user)) {
|
||||||
return this.setResponse(403, 'insufficientAccessLevel')
|
return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clone pattern
|
/*
|
||||||
|
* Now clone the pattern
|
||||||
|
*/
|
||||||
const data = this.asPattern()
|
const data = this.asPattern()
|
||||||
delete data.id
|
delete data.id
|
||||||
data.name += ` (cloned from #${this.record.id})`
|
data.name += ` (cloned from #${this.record.id})`
|
||||||
data.notes += ` (Note: This pattern was cloned from pattern #${this.record.id})`
|
data.notes += ` (Note: This pattern was cloned from pattern #${this.record.id})`
|
||||||
await this.unguardedCreate(data)
|
|
||||||
|
|
||||||
// Update unencrypted data
|
/*
|
||||||
|
* Write it to the database
|
||||||
|
*/
|
||||||
|
await this.createRecord(data)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Update record with unencrypted data
|
||||||
|
*/
|
||||||
this.reveal()
|
this.reveal()
|
||||||
|
|
||||||
return this.setResponse(200, false, {
|
/*
|
||||||
result: 'success',
|
* And return the cloned pattern
|
||||||
pattern: this.asPattern(),
|
*/
|
||||||
})
|
return this.setResponse200({ pattern: this.asPattern() })
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Helper method to decrypt at-rest data
|
|
||||||
*/
|
|
||||||
PatternModel.prototype.reveal = async function () {
|
|
||||||
this.clear = {}
|
|
||||||
if (this.record) {
|
|
||||||
for (const field of this.encryptedFields) {
|
|
||||||
this.clear[field] = this.decrypt(this.record[field])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse JSON content
|
|
||||||
if (this.record?.cset) {
|
|
||||||
for (const lang of this.config.languages) {
|
|
||||||
const key = `tags${capitalize(lang)}`
|
|
||||||
if (this.record.cset[key]) this.record.cset[key] = JSON.parse(this.record.cset[key])
|
|
||||||
}
|
|
||||||
if (this.record.cset.measies) this.record.cset.measies = JSON.parse(this.record.cset.measies)
|
|
||||||
}
|
|
||||||
if (this.record?.set) {
|
|
||||||
this.record.set = this.Set.revealSet(this.record.set)
|
|
||||||
}
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Helper method to encrypt at-rest data
|
|
||||||
*/
|
|
||||||
PatternModel.prototype.cloak = function (data) {
|
|
||||||
for (const field of this.encryptedFields) {
|
|
||||||
if (typeof data[field] !== 'undefined') {
|
|
||||||
data[field] = this.encrypt(data[field])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Checks this.record and sets a boolean to indicate whether
|
|
||||||
* the pattern exists or not
|
|
||||||
*
|
|
||||||
* Stores result in this.exists
|
|
||||||
*/
|
|
||||||
PatternModel.prototype.setExists = function () {
|
|
||||||
this.exists = this.record ? true : false
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Updates the pattern data - Used when we create the data ourselves
|
|
||||||
* so we know it's safe
|
|
||||||
*/
|
|
||||||
PatternModel.prototype.unguardedUpdate = async function (data) {
|
|
||||||
try {
|
|
||||||
this.record = await this.prisma.pattern.update({
|
|
||||||
where: { id: this.record.id },
|
|
||||||
data,
|
|
||||||
include: {
|
|
||||||
set: true,
|
|
||||||
cset: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
log.warn(err, 'Could not update pattern record')
|
|
||||||
process.exit()
|
|
||||||
return this.setResponse(500, 'updatePatternFailed')
|
|
||||||
}
|
|
||||||
await this.reveal()
|
|
||||||
|
|
||||||
return this.setResponse(200)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Updates the pattern data - Used when we pass through user-provided data
|
* Updates the pattern data - Used when we pass through user-provided data
|
||||||
* so we can't be certain it's safe
|
* so we can't be certain it's safe
|
||||||
|
*
|
||||||
|
* @param {params} object - The request (URL) parameters
|
||||||
|
* @param {body} object - The request body
|
||||||
|
* @param {user} object - The user data as provided by the auth middleware
|
||||||
|
* @returns {PatternModel} object - The PatternModel
|
||||||
*/
|
*/
|
||||||
PatternModel.prototype.guardedUpdate = async function ({ params, body, user }) {
|
PatternModel.prototype.guardedUpdate = async function ({ params, body, user }) {
|
||||||
|
/*
|
||||||
|
* Enforce RBAC
|
||||||
|
*/
|
||||||
if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check JWT
|
||||||
|
*/
|
||||||
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
|
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
|
||||||
await this.read({ id: parseInt(params.id) })
|
|
||||||
|
/*
|
||||||
|
* Attempt to read record from the database
|
||||||
|
*/
|
||||||
|
await this.read({ id: parseInt(params.id) }, { set: true, cset: true })
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Only admins can update other people's patterns
|
||||||
|
*/
|
||||||
if (this.record.userId !== user.uid && !this.rbac.admin(user)) {
|
if (this.record.userId !== user.uid && !this.rbac.admin(user)) {
|
||||||
return this.setResponse(403, 'insufficientAccessLevel')
|
return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Prepare data for updating the record
|
||||||
|
*/
|
||||||
const data = {}
|
const data = {}
|
||||||
// Name
|
/*
|
||||||
|
* name
|
||||||
|
*/
|
||||||
if (typeof body.name === 'string') data.name = body.name
|
if (typeof body.name === 'string') data.name = body.name
|
||||||
// Notes
|
/*
|
||||||
|
* notes
|
||||||
|
*/
|
||||||
if (typeof body.notes === 'string') data.notes = body.notes
|
if (typeof body.notes === 'string') data.notes = body.notes
|
||||||
// Public
|
/*
|
||||||
|
* public
|
||||||
|
*/
|
||||||
if (body.public === true || body.public === false) data.public = body.public
|
if (body.public === true || body.public === false) data.public = body.public
|
||||||
// Data
|
/*
|
||||||
|
* data
|
||||||
|
*/
|
||||||
if (typeof body.data === 'object') data.data = body.data
|
if (typeof body.data === 'object') data.data = body.data
|
||||||
// Settings
|
/*
|
||||||
|
* settings
|
||||||
|
*/
|
||||||
if (typeof body.settings === 'object') data.settings = body.settings
|
if (typeof body.settings === 'object') data.settings = body.settings
|
||||||
// Image (img)
|
/*
|
||||||
|
* img
|
||||||
|
*/
|
||||||
if (typeof body.img === 'string') {
|
if (typeof body.img === 'string') {
|
||||||
const img = await storeImage({
|
data.img = await storeImage(
|
||||||
id: `pattern-${this.record.id}`,
|
{
|
||||||
metadata: { user: this.user.uid },
|
id: `pattern-${this.record.id}`,
|
||||||
b64: body.img,
|
metadata: { user: this.user.uid },
|
||||||
})
|
b64: body.img,
|
||||||
data.img = img.url
|
},
|
||||||
|
this.isTest(body)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now update the record
|
/*
|
||||||
await this.unguardedUpdate(this.cloak(data))
|
* Now update the record
|
||||||
|
*/
|
||||||
|
await this.update(data, { set: true, cset: true })
|
||||||
|
|
||||||
return this.setResponse(200, false, { pattern: this.asPattern() })
|
/*
|
||||||
}
|
* Return 200 and the data
|
||||||
|
*/
|
||||||
/*
|
return this.setResponse200({ pattern: this.asPattern() })
|
||||||
* Removes the pattern - No questions asked
|
|
||||||
*/
|
|
||||||
PatternModel.prototype.unguardedDelete = async function () {
|
|
||||||
await this.prisma.pattern.delete({ where: { id: this.record.id } })
|
|
||||||
this.record = null
|
|
||||||
this.clear = null
|
|
||||||
|
|
||||||
return this.setExists()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Removes the pattern - Checks permissions
|
* Removes the pattern - Checks permissions
|
||||||
|
*
|
||||||
|
* @param {params} object - The request (URL) parameters
|
||||||
|
* @param {user} object - The user data as provided by the auth middleware
|
||||||
|
* @returns {PatternModel} object - The PatternModel
|
||||||
*/
|
*/
|
||||||
PatternModel.prototype.guardedDelete = async function ({ params, user }) {
|
PatternModel.prototype.guardedDelete = async function ({ params, user }) {
|
||||||
|
/*
|
||||||
|
* Enforce RBAC
|
||||||
|
*/
|
||||||
if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check JWT
|
||||||
|
*/
|
||||||
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
|
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Attempt to read record from database
|
||||||
|
*/
|
||||||
await this.read({ id: parseInt(params.id) })
|
await this.read({ id: parseInt(params.id) })
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Only admins can delete other user's patterns
|
||||||
|
*/
|
||||||
if (this.record.userId !== user.uid && !this.rbac.admin(user)) {
|
if (this.record.userId !== user.uid && !this.rbac.admin(user)) {
|
||||||
return this.setResponse(403, 'insufficientAccessLevel')
|
return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.unguardedDelete()
|
/*
|
||||||
|
* Remove the record
|
||||||
|
*/
|
||||||
|
await this.delete()
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return 204
|
||||||
|
*/
|
||||||
return this.setResponse(204, false)
|
return this.setResponse(204, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -358,6 +397,9 @@ PatternModel.prototype.asPattern = function () {
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Helper method to decrypt data from a non-instantiated pattern
|
* Helper method to decrypt data from a non-instantiated pattern
|
||||||
|
*
|
||||||
|
* @param {pattern} object - The pattern data
|
||||||
|
* @returns {pattern} object - The unencrypted pattern data
|
||||||
*/
|
*/
|
||||||
PatternModel.prototype.revealPattern = function (pattern) {
|
PatternModel.prototype.revealPattern = function (pattern) {
|
||||||
const clear = {}
|
const clear = {}
|
||||||
|
@ -374,49 +416,6 @@ PatternModel.prototype.revealPattern = function (pattern) {
|
||||||
return { ...pattern, ...clear }
|
return { ...pattern, ...clear }
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* Helper method to set the response code, result, and body
|
|
||||||
*
|
|
||||||
* Will be used by this.sendResponse()
|
|
||||||
*/
|
|
||||||
PatternModel.prototype.setResponse = function (
|
|
||||||
status = 200,
|
|
||||||
error = false,
|
|
||||||
data = {},
|
|
||||||
rawData = false
|
|
||||||
) {
|
|
||||||
this.response = {
|
|
||||||
status,
|
|
||||||
body: rawData
|
|
||||||
? data
|
|
||||||
: {
|
|
||||||
result: 'success',
|
|
||||||
...data,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if (status > 201) {
|
|
||||||
this.response.body.error = error
|
|
||||||
this.response.body.result = 'error'
|
|
||||||
this.error = true
|
|
||||||
} else this.error = false
|
|
||||||
|
|
||||||
return this.setExists()
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Helper method to send response
|
|
||||||
*/
|
|
||||||
PatternModel.prototype.sendResponse = async function (res) {
|
|
||||||
return res.status(this.response.status).send(this.response.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Helper method to send response as YAML
|
|
||||||
*/
|
|
||||||
PatternModel.prototype.sendYamlResponse = async function (res) {
|
|
||||||
return res.status(this.response.status).type('yaml').send(yaml.dump(this.response.body))
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Returns record data fit for public publishing
|
* Returns record data fit for public publishing
|
||||||
*/
|
*/
|
||||||
|
@ -432,6 +431,12 @@ PatternModel.prototype.asPublicPattern = function () {
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
*
|
||||||
|
* Everything below this comment is v2 => v3 migration code
|
||||||
|
* And can be removed after the migration
|
||||||
|
*/
|
||||||
|
|
||||||
const migratePattern = (v2, userId) => ({
|
const migratePattern = (v2, userId) => ({
|
||||||
createdAt: new Date(v2.created ? v2.created : v2.createdAt),
|
createdAt: new Date(v2.created ? v2.created : v2.createdAt),
|
||||||
data: { version: v2.data.version, notes: ['Migrated from version 2'] },
|
data: { version: v2.data.version, notes: ['Migrated from version 2'] },
|
||||||
|
|
|
@ -1,109 +1,124 @@
|
||||||
import { log } from '../utils/log.mjs'
|
import { log } from '../utils/log.mjs'
|
||||||
import { replaceImage, storeImage, ensureImage, importImage } from '../utils/cloudflare-images.mjs'
|
import { replaceImage, storeImage, ensureImage, importImage } from '../utils/cloudflare-images.mjs'
|
||||||
import yaml from 'js-yaml'
|
import yaml from 'js-yaml'
|
||||||
|
import { decorateModel } from '../utils/model-decorator.mjs'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This model handles all flows (typically that involves sending out emails)
|
||||||
|
*/
|
||||||
export function SetModel(tools) {
|
export function SetModel(tools) {
|
||||||
this.config = tools.config
|
return decorateModel(this, tools, {
|
||||||
this.prisma = tools.prisma
|
name: 'set',
|
||||||
this.decrypt = tools.decrypt
|
encryptedFields: ['measies', 'img', 'name', 'notes'],
|
||||||
this.encrypt = tools.encrypt
|
})
|
||||||
this.rbac = tools.rbac
|
|
||||||
this.encryptedFields = ['measies', 'img', 'name', 'notes']
|
|
||||||
this.clear = {} // For holding decrypted data
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
SetModel.prototype.guardedCreate = async function ({ body, user }) {
|
|
||||||
if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
|
||||||
if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing')
|
|
||||||
if (!body.name || typeof body.name !== 'string') return this.setResponse(400, 'nameMissing')
|
|
||||||
|
|
||||||
// Prepare data
|
|
||||||
const data = { name: body.name }
|
|
||||||
// Name (will be encrypted, so always set _some_ value)
|
|
||||||
if (typeof body.name === 'string') data.name = body.name
|
|
||||||
else data.name = '--'
|
|
||||||
// Notes (will be encrypted, so always set _some_ value)
|
|
||||||
if (body.notes || typeof body.notes === 'string') data.notes = body.notes
|
|
||||||
else data.notes = '--'
|
|
||||||
if (body.public === true) data.public = true
|
|
||||||
if (body.measies) data.measies = this.sanitizeMeasurements(body.measies)
|
|
||||||
else data.measies = {}
|
|
||||||
data.imperial = body.imperial === true ? true : false
|
|
||||||
data.userId = user.uid
|
|
||||||
// Set this one initially as we need the ID to store the image on cloudflare
|
|
||||||
data.img = this.config.avatars.set
|
|
||||||
|
|
||||||
// Create record
|
|
||||||
await this.unguardedCreate(data)
|
|
||||||
|
|
||||||
// Update img? (now that we have the ID)
|
|
||||||
const img =
|
|
||||||
this.config.use.cloudflareImages &&
|
|
||||||
typeof body.img === 'string' &&
|
|
||||||
(!body.test || (body.test && this.config.use.tests?.cloudflareImages))
|
|
||||||
? await storeImage({
|
|
||||||
id: `set-${this.record.id}`,
|
|
||||||
metadata: {
|
|
||||||
user: user.uid,
|
|
||||||
name: this.clear.name,
|
|
||||||
},
|
|
||||||
b64: body.img,
|
|
||||||
requireSignedURLs: true,
|
|
||||||
})
|
|
||||||
: false
|
|
||||||
|
|
||||||
if (img) await this.unguardedUpdate(this.cloak({ img: img.url }))
|
|
||||||
else await this.read({ id: this.record.id })
|
|
||||||
|
|
||||||
return this.setResponse(201, 'created', { set: this.asSet() })
|
|
||||||
}
|
|
||||||
|
|
||||||
SetModel.prototype.unguardedCreate = async function (data) {
|
|
||||||
try {
|
|
||||||
this.record = await this.prisma.set.create({ data: this.cloak(data) })
|
|
||||||
} catch (err) {
|
|
||||||
log.warn(err, 'Could not create set')
|
|
||||||
return this.setResponse(500, 'createSetFailed')
|
|
||||||
}
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Loads a measurements set from the database based on the where clause you pass it
|
* Creates a set - Uses user input so we need to validate it
|
||||||
*
|
*
|
||||||
* Stores result in this.record
|
* @params {body} object - The request body
|
||||||
|
* @params {user} object - The user as provided by the auth middleware
|
||||||
|
* @returns {SetModel} object - The SetModel
|
||||||
*/
|
*/
|
||||||
SetModel.prototype.read = async function (where) {
|
SetModel.prototype.guardedCreate = async function ({ body, user }) {
|
||||||
try {
|
/*
|
||||||
this.record = await this.prisma.set.findUnique({ where })
|
* Enforce RBAC
|
||||||
} catch (err) {
|
*/
|
||||||
log.warn({ err, where }, 'Could not read measurements set')
|
if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
}
|
|
||||||
|
|
||||||
this.reveal()
|
/*
|
||||||
|
* Do we have a POST body?
|
||||||
|
*/
|
||||||
|
if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing')
|
||||||
|
|
||||||
return this.setExists()
|
/*
|
||||||
|
* Is name set?
|
||||||
|
*/
|
||||||
|
if (!body.name || typeof body.name !== 'string') return this.setResponse(400, 'nameMissing')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Create the initial record
|
||||||
|
*/
|
||||||
|
await this.createRecord({
|
||||||
|
name: typeof body.name === 'string' && body.name.length > 0 ? body.name : '--',
|
||||||
|
notes: typeof body.notes === 'string' && body.notes.length > 0 ? body.notes : '--',
|
||||||
|
public: body.public === true ? true : false,
|
||||||
|
measies: typeof body.measies === 'object' ? this.sanitizeMeasurements(body.measies) : {},
|
||||||
|
imperial: body.imperial === true ? true : false,
|
||||||
|
userId: user.uid,
|
||||||
|
img: this.config.avatars.set,
|
||||||
|
})
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If an image was added, now update it since we have the record id now
|
||||||
|
*/
|
||||||
|
const img =
|
||||||
|
typeof body.img === 'string'
|
||||||
|
? await storeImage(
|
||||||
|
{
|
||||||
|
id: `set-${this.record.id}`,
|
||||||
|
metadata: {
|
||||||
|
user: user.uid,
|
||||||
|
name: this.clear.name,
|
||||||
|
},
|
||||||
|
b64: body.img,
|
||||||
|
requireSignedURLs: true,
|
||||||
|
},
|
||||||
|
this.isTest(body)
|
||||||
|
)
|
||||||
|
: false
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Either update the image, or refresh the record
|
||||||
|
*/
|
||||||
|
if (img) await this.update({ img })
|
||||||
|
else await this.read({ id: this.record.id })
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Now return 201 and the data
|
||||||
|
*/
|
||||||
|
return this.setResponse201({ set: this.asSet() })
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Loads a measurements set from the database based on the where clause you pass it
|
* Loads a measurements set from the database based on the where clause you pass it
|
||||||
* In addition prepares it for returning the set data
|
* In addition prepares it for returning the set data
|
||||||
*
|
*
|
||||||
* Stores result in this.record
|
* @params {params} object - The request (URL) parameters
|
||||||
|
* @params {user} object - The user as provided by the auth middleware
|
||||||
|
* @returns {SetModel} object - The SetModel
|
||||||
*/
|
*/
|
||||||
SetModel.prototype.guardedRead = async function ({ params, user }) {
|
SetModel.prototype.guardedRead = async function ({ params, user }) {
|
||||||
|
/*
|
||||||
|
* Enforce RBAC
|
||||||
|
*/
|
||||||
if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check JWT
|
||||||
|
*/
|
||||||
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
|
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Attempt to read the record from the database
|
||||||
|
*/
|
||||||
await this.read({ id: parseInt(params.id) })
|
await this.read({ id: parseInt(params.id) })
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If it does not exist, send a 404
|
||||||
|
*/
|
||||||
if (!this.record) return this.setResponse(404)
|
if (!this.record) return this.setResponse(404)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* You need to have at least the bughunter role to read other user's patterns
|
||||||
|
*/
|
||||||
if (this.record.userId !== user.uid && !this.rbac.bughunter(user)) {
|
if (this.record.userId !== user.uid && !this.rbac.bughunter(user)) {
|
||||||
return this.setResponse(403, 'insufficientAccessLevel')
|
return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return 200 and send the pattern data
|
||||||
|
*/
|
||||||
return this.setResponse(200, false, {
|
return this.setResponse(200, false, {
|
||||||
result: 'success',
|
result: 'success',
|
||||||
set: this.asSet(),
|
set: this.asSet(),
|
||||||
|
@ -112,84 +127,85 @@ SetModel.prototype.guardedRead = async function ({ params, user }) {
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Loads a measurements set from the database but only if it's public
|
* Loads a measurements set from the database but only if it's public
|
||||||
|
* This is a public route (no authentication)
|
||||||
*
|
*
|
||||||
* Stores result in this.record
|
* @params {params} object - The request (URL) parameters
|
||||||
|
* @returns {SetModel} object - The SetModel
|
||||||
*/
|
*/
|
||||||
SetModel.prototype.publicRead = async function ({ params }) {
|
SetModel.prototype.publicRead = async function ({ params }) {
|
||||||
|
/*
|
||||||
|
* Attemp to read the record from the database
|
||||||
|
*/
|
||||||
await this.read({ id: parseInt(params.id) })
|
await this.read({ id: parseInt(params.id) })
|
||||||
if (this.record.public !== true) {
|
|
||||||
// Note that we return 404
|
|
||||||
// because we don't want to reveal that a non-public set exists.
|
|
||||||
return this.setResponse(404)
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.setResponse(200, false, this.asPublicSet(), true)
|
/*
|
||||||
|
* If it is not public, return 404 rather than
|
||||||
|
* reveal that a non-public set exists
|
||||||
|
*/
|
||||||
|
if (this.record.public !== true) return this.setResponse(404)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return 200 and the set data
|
||||||
|
*/
|
||||||
|
return this.setResponse200(this.asPublicSet())
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Clones a measurements set
|
* Clones a measurements set
|
||||||
* In addition prepares it for returning the set data
|
* In addition prepares it for returning the set data
|
||||||
*
|
*
|
||||||
* Stores result in this.record
|
* @params {params} object - The request (URL) parameters
|
||||||
|
* @params {user} object - The user as provided by the auth middleware
|
||||||
|
* @returns {SetModel} object - The SetModel
|
||||||
*/
|
*/
|
||||||
SetModel.prototype.guardedClone = async function ({ params, user }) {
|
SetModel.prototype.guardedClone = async function ({ params, user }) {
|
||||||
|
/*
|
||||||
|
* Enforce RBAC
|
||||||
|
*/
|
||||||
if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check the JWT
|
||||||
|
*/
|
||||||
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
|
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Attempt to read the record from the database
|
||||||
|
*/
|
||||||
await this.read({ id: parseInt(params.id) })
|
await this.read({ id: parseInt(params.id) })
|
||||||
|
|
||||||
|
/*
|
||||||
|
* You need at least the support role to clone another user's (non-public) set
|
||||||
|
*/
|
||||||
if (this.record.userId !== user.uid && !this.record.public && !this.rbac.support(user)) {
|
if (this.record.userId !== user.uid && !this.record.public && !this.rbac.support(user)) {
|
||||||
return this.setResponse(403, 'insufficientAccessLevel')
|
return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clone set
|
/*
|
||||||
|
* Now clone the set
|
||||||
|
*/
|
||||||
const data = this.asSet()
|
const data = this.asSet()
|
||||||
delete data.id
|
delete data.id
|
||||||
data.name += ` (cloned from #${this.record.id})`
|
data.name += ` (cloned from #${this.record.id})`
|
||||||
data.notes += ` (Note: This measurements set was cloned from set #${this.record.id})`
|
data.notes += ` (Note: This measurements set was cloned from set #${this.record.id})`
|
||||||
await this.unguardedCreate(data)
|
await this.createRecord(data)
|
||||||
|
|
||||||
// Update unencrypted data
|
/*
|
||||||
|
* Decrypt data at rest
|
||||||
|
*/
|
||||||
this.reveal()
|
this.reveal()
|
||||||
|
|
||||||
return this.setResponse(200, false, {
|
/*
|
||||||
result: 'success',
|
* Return 200 and the cloned data
|
||||||
set: this.asSet(),
|
*/
|
||||||
})
|
return this.setResponse201({ set: this.asSet() })
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Helper method to decrypt at-rest data
|
|
||||||
*/
|
|
||||||
SetModel.prototype.reveal = async function () {
|
|
||||||
this.clear = {}
|
|
||||||
if (this.record) {
|
|
||||||
for (const field of this.encryptedFields) {
|
|
||||||
try {
|
|
||||||
this.clear[field] = this.decrypt(this.record[field])
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Helper method to encrypt at-rest data
|
|
||||||
*/
|
|
||||||
SetModel.prototype.cloak = function (data) {
|
|
||||||
for (const field of this.encryptedFields) {
|
|
||||||
if (typeof data[field] !== 'undefined') {
|
|
||||||
data[field] = this.encrypt(data[field])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Helper method to decrypt data from a non-instantiated set
|
* Helper method to decrypt data from a non-instantiated set
|
||||||
|
*
|
||||||
|
* @param {mset} object - The set data
|
||||||
|
* @returns {mset} object - The unencrypted data
|
||||||
*/
|
*/
|
||||||
SetModel.prototype.revealSet = function (mset) {
|
SetModel.prototype.revealSet = function (mset) {
|
||||||
const clear = {}
|
const clear = {}
|
||||||
|
@ -204,117 +220,133 @@ SetModel.prototype.revealSet = function (mset) {
|
||||||
return { ...mset, ...clear }
|
return { ...mset, ...clear }
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* Checks this.record and sets a boolean to indicate whether
|
|
||||||
* the user exists or not
|
|
||||||
*
|
|
||||||
* Stores result in this.exists
|
|
||||||
*/
|
|
||||||
SetModel.prototype.setExists = function () {
|
|
||||||
this.exists = this.record ? true : false
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Updates the set data - Used when we create the data ourselves
|
|
||||||
* so we know it's safe
|
|
||||||
*/
|
|
||||||
SetModel.prototype.unguardedUpdate = async function (data) {
|
|
||||||
try {
|
|
||||||
this.record = await this.prisma.set.update({
|
|
||||||
where: { id: this.record.id },
|
|
||||||
data,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
log.warn(err, 'Could not update set record')
|
|
||||||
process.exit()
|
|
||||||
return this.setResponse(500, 'updateSetFailed')
|
|
||||||
}
|
|
||||||
await this.reveal()
|
|
||||||
|
|
||||||
return this.setResponse(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Updates the set data - Used when we pass through user-provided data
|
* Updates the set data - Used when we pass through user-provided data
|
||||||
* so we can't be certain it's safe
|
* so we can't be certain it's safe
|
||||||
|
*
|
||||||
|
* @params {params} object - The request (URL) parameters
|
||||||
|
* @params {body} object - The request body
|
||||||
|
* @returns {SetModel} object - The SetModel
|
||||||
*/
|
*/
|
||||||
SetModel.prototype.guardedUpdate = async function ({ params, body, user }) {
|
SetModel.prototype.guardedUpdate = async function ({ params, body, user }) {
|
||||||
|
/*
|
||||||
|
* Enforce RBAC
|
||||||
|
*/
|
||||||
if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check JWT
|
||||||
|
*/
|
||||||
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
|
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Attempt to read record from database
|
||||||
|
*/
|
||||||
await this.read({ id: parseInt(params.id) })
|
await this.read({ id: parseInt(params.id) })
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Only admins can update other user's sets
|
||||||
|
*/
|
||||||
if (this.record.userId !== user.uid && !this.rbac.admin(user)) {
|
if (this.record.userId !== user.uid && !this.rbac.admin(user)) {
|
||||||
return this.setResponse(403, 'insufficientAccessLevel')
|
return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Prepare data to update the record
|
||||||
|
*/
|
||||||
const data = {}
|
const data = {}
|
||||||
// Imperial
|
|
||||||
|
/*
|
||||||
|
* imperial
|
||||||
|
*/
|
||||||
if (body.imperial === true || body.imperial === false) data.imperial = body.imperial
|
if (body.imperial === true || body.imperial === false) data.imperial = body.imperial
|
||||||
// Name
|
|
||||||
|
/*
|
||||||
|
* name
|
||||||
|
*/
|
||||||
if (typeof body.name === 'string') data.name = body.name
|
if (typeof body.name === 'string') data.name = body.name
|
||||||
// Notes
|
|
||||||
|
/*
|
||||||
|
* notes
|
||||||
|
*/
|
||||||
if (typeof body.notes === 'string') data.notes = body.notes
|
if (typeof body.notes === 'string') data.notes = body.notes
|
||||||
// Public
|
|
||||||
|
/*
|
||||||
|
* public
|
||||||
|
*/
|
||||||
if (body.public === true || body.public === false) data.public = body.public
|
if (body.public === true || body.public === false) data.public = body.public
|
||||||
// Measurements
|
|
||||||
const measies = {}
|
|
||||||
if (typeof body.measies === 'object') {
|
|
||||||
const remove = []
|
|
||||||
for (const [key, val] of Object.entries(body.measies)) {
|
|
||||||
if (this.config.measies.includes(key)) {
|
|
||||||
if (val === null) remove.push(key)
|
|
||||||
else if (typeof val == 'number' && val > 0) measies[key] = val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
data.measies = { ...this.clear.measies, ...measies }
|
|
||||||
for (const key of remove) delete data.measies[key]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Image (img)
|
/*
|
||||||
if (typeof body.img === 'string') {
|
* measurements
|
||||||
const img = await replaceImage({
|
*/
|
||||||
id: `set-${this.record.id}`,
|
if (typeof body.measies === 'object') data.measies = this.sanitizeMeasurements(body.measies)
|
||||||
metadata: {
|
|
||||||
user: user.uid,
|
/*
|
||||||
name: this.clear.name,
|
* img
|
||||||
|
*/
|
||||||
|
if (typeof body.img === 'string')
|
||||||
|
data.img = await replaceImage(
|
||||||
|
{
|
||||||
|
id: `set-${this.record.id}`,
|
||||||
|
metadata: {
|
||||||
|
user: user.uid,
|
||||||
|
name: this.clear.name,
|
||||||
|
},
|
||||||
|
b64: body.img,
|
||||||
|
notPublic: true,
|
||||||
},
|
},
|
||||||
b64: body.img,
|
this.isTest(body)
|
||||||
notPublic: true,
|
)
|
||||||
})
|
|
||||||
data.img = img.url
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now update the record
|
/*
|
||||||
await this.unguardedUpdate(this.cloak(data))
|
* Now update the database record
|
||||||
|
*/
|
||||||
|
await this.update(data)
|
||||||
|
|
||||||
return this.setResponse(200, false, { set: this.asSet() })
|
/*
|
||||||
}
|
* Return 200 and the record data
|
||||||
|
*/
|
||||||
/*
|
return this.setResponse200({ set: this.asSet() })
|
||||||
* Removes the set - No questions asked
|
|
||||||
*/
|
|
||||||
SetModel.prototype.unguardedDelete = async function () {
|
|
||||||
await this.prisma.set.delete({ where: { id: this.record.id } })
|
|
||||||
this.record = null
|
|
||||||
this.clear = null
|
|
||||||
|
|
||||||
return this.setExists()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Removes the set - Checks permissions
|
* Removes the set - Checks permissions
|
||||||
|
*
|
||||||
|
* @params {params} object - The request (URL) parameters
|
||||||
|
* @params {user} object - The user as provided by the auth middleware
|
||||||
|
* @returns {SetModel} object - The SetModel
|
||||||
*/
|
*/
|
||||||
SetModel.prototype.guardedDelete = async function ({ params, user }) {
|
SetModel.prototype.guardedDelete = async function ({ params, user }) {
|
||||||
|
/*
|
||||||
|
* Enforce RBAC
|
||||||
|
*/
|
||||||
if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check the JWT
|
||||||
|
*/
|
||||||
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
|
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Attempt to read the record from the database
|
||||||
|
*/
|
||||||
await this.read({ id: parseInt(params.id) })
|
await this.read({ id: parseInt(params.id) })
|
||||||
|
|
||||||
|
/*
|
||||||
|
* You need to be admin to remove another user's data
|
||||||
|
*/
|
||||||
if (this.record.userId !== user.uid && !this.rbac.admin(user)) {
|
if (this.record.userId !== user.uid && !this.rbac.admin(user)) {
|
||||||
return this.setResponse(403, 'insufficientAccessLevel')
|
return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.unguardedDelete()
|
/*
|
||||||
|
* Delete the record
|
||||||
|
*/
|
||||||
|
await this.delete()
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return 204
|
||||||
|
*/
|
||||||
return this.setResponse(204, false)
|
return this.setResponse(204, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -366,87 +398,10 @@ SetModel.prototype.asPublicSet = function () {
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Helper method to set the response code, result, and body
|
|
||||||
*
|
*
|
||||||
* Will be used by this.sendResponse()
|
* Everything below this comment is part of the v2 => v3 migration code
|
||||||
|
* and can be removed once that migration is complete
|
||||||
*/
|
*/
|
||||||
SetModel.prototype.setResponse = function (
|
|
||||||
status = 200,
|
|
||||||
error = false,
|
|
||||||
data = {},
|
|
||||||
rawData = false
|
|
||||||
) {
|
|
||||||
this.response = {
|
|
||||||
status,
|
|
||||||
body: rawData
|
|
||||||
? data
|
|
||||||
: {
|
|
||||||
result: 'success',
|
|
||||||
...data,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if (status > 201) {
|
|
||||||
this.response.body.error = error
|
|
||||||
this.response.body.result = 'error'
|
|
||||||
this.error = true
|
|
||||||
} else this.error = false
|
|
||||||
|
|
||||||
return this.setExists()
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Helper method to send response (as JSON)
|
|
||||||
*/
|
|
||||||
SetModel.prototype.sendResponse = async function (res) {
|
|
||||||
return res.status(this.response.status).send(this.response.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Helper method to send response as YAML
|
|
||||||
*/
|
|
||||||
SetModel.prototype.sendYamlResponse = async function (res) {
|
|
||||||
return res.status(this.response.status).type('yaml').send(yaml.dump(this.response.body))
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Update method to determine whether this request is
|
|
||||||
* part of a test
|
|
||||||
*/
|
|
||||||
//UserModel.prototype.isTest = function (body) {
|
|
||||||
// if (!body.test) return false
|
|
||||||
// if (!this.clear.email.split('@').pop() === this.config.tests.domain) return false
|
|
||||||
// if (body.email && !body.email.split('@').pop() === this.config.tests.domain) return false
|
|
||||||
//
|
|
||||||
// return true
|
|
||||||
//}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Helper method to check an account is ok
|
|
||||||
*/
|
|
||||||
//UserModel.prototype.isOk = function () {
|
|
||||||
// if (
|
|
||||||
// this.exists &&
|
|
||||||
// this.record &&
|
|
||||||
// this.record.status > 0 &&
|
|
||||||
// this.record.consent > 0 &&
|
|
||||||
// this.record.role &&
|
|
||||||
// this.record.role !== 'blocked'
|
|
||||||
// )
|
|
||||||
// return true
|
|
||||||
//
|
|
||||||
// return false
|
|
||||||
//}
|
|
||||||
|
|
||||||
/* Helper method to parse user-supplied measurements */
|
|
||||||
SetModel.prototype.sanitizeMeasurements = function (input) {
|
|
||||||
const measies = {}
|
|
||||||
if (typeof input !== 'object') return measies
|
|
||||||
for (const [m, val] of Object.entries(input)) {
|
|
||||||
if (this.config.measies.includes(m) && typeof val === 'number' && val > 0) measies[m] = val
|
|
||||||
}
|
|
||||||
|
|
||||||
return measies
|
|
||||||
}
|
|
||||||
|
|
||||||
const migratePerson = (v2) => ({
|
const migratePerson = (v2) => ({
|
||||||
createdAt: new Date(v2.created ? v2.created : v2.createdAt),
|
createdAt: new Date(v2.created ? v2.created : v2.createdAt),
|
||||||
|
@ -464,7 +419,7 @@ SetModel.prototype.import = async function (v2user, userId) {
|
||||||
const lut = {} // lookup tabel for v2 handle to v3 id
|
const lut = {} // lookup tabel for v2 handle to v3 id
|
||||||
for (const [handle, person] of Object.entries(v2user.people)) {
|
for (const [handle, person] of Object.entries(v2user.people)) {
|
||||||
const data = { ...migratePerson(person), userId }
|
const data = { ...migratePerson(person), userId }
|
||||||
await this.unguardedCreate(data)
|
await this.createRecord(data)
|
||||||
// Now that we have an ID, we can handle the image
|
// Now that we have an ID, we can handle the image
|
||||||
if (person.picture && person.picture.slice(-4) !== '.svg') {
|
if (person.picture && person.picture.slice(-4) !== '.svg') {
|
||||||
const imgId = `set-${this.record.id}`
|
const imgId = `set-${this.record.id}`
|
||||||
|
|
|
@ -1,59 +1,76 @@
|
||||||
import { hash } from '../utils/crypto.mjs'
|
import { hash } from '../utils/crypto.mjs'
|
||||||
import { log } from '../utils/log.mjs'
|
import { log } from '../utils/log.mjs'
|
||||||
import { clean, i18nUrl } from '../utils/index.mjs'
|
import { clean, i18nUrl } from '../utils/index.mjs'
|
||||||
|
import { decorateModel } from '../utils/model-decorator.mjs'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This model handles all user updates
|
||||||
|
*/
|
||||||
export function SubscriberModel(tools) {
|
export function SubscriberModel(tools) {
|
||||||
this.config = tools.config
|
return decorateModel(this, tools, {
|
||||||
this.prisma = tools.prisma
|
name: 'subscriber',
|
||||||
this.mailer = tools.email
|
encryptedFields: ['email'],
|
||||||
this.decrypt = tools.decrypt
|
})
|
||||||
this.encrypt = tools.encrypt
|
|
||||||
this.encryptedFields = ['email']
|
|
||||||
this.clear = {} // For holding decrypted data
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Creates a new subscriber - Takes user input so we need to validate it
|
||||||
|
* This is an unauthenticated route
|
||||||
|
*
|
||||||
|
* @param {body} object - The request body
|
||||||
|
* @returns {SubscriberModal} object - The SubscriberModel
|
||||||
|
*/
|
||||||
SubscriberModel.prototype.guardedCreate = async function ({ body }) {
|
SubscriberModel.prototype.guardedCreate = async function ({ body }) {
|
||||||
|
/*
|
||||||
|
* Is email set and a string?
|
||||||
|
*/
|
||||||
if (!body.email || typeof body.email !== 'string') return this.setResponse(400, 'emailMissing')
|
if (!body.email || typeof body.email !== 'string') return this.setResponse(400, 'emailMissing')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is language set and a known language?
|
||||||
|
*/
|
||||||
if (!body.language || !this.config.languages.includes(body.language.toLowerCase()))
|
if (!body.language || !this.config.languages.includes(body.language.toLowerCase()))
|
||||||
return this.setResponse(400, 'languageMissing')
|
return this.setResponse(400, 'languageMissing')
|
||||||
|
|
||||||
// Clean up email address and hash it
|
/*
|
||||||
|
* Clean (lowercase + trim) the email address and hash it
|
||||||
|
*/
|
||||||
const email = clean(body.email)
|
const email = clean(body.email)
|
||||||
const language = body.language.toLowerCase()
|
|
||||||
const ehash = hash(email)
|
const ehash = hash(email)
|
||||||
|
|
||||||
log.info(`New newsletter subscriber: ${email}`)
|
/*
|
||||||
|
* Lowecase the language
|
||||||
|
*/
|
||||||
|
const language = body.language.toLowerCase()
|
||||||
|
|
||||||
// Check whether this is a unit test
|
/*
|
||||||
|
* Check whether this is a unit test
|
||||||
|
*/
|
||||||
const isTest = this.isTest(body)
|
const isTest = this.isTest(body)
|
||||||
|
|
||||||
// Check to see if this email address is already subscribed.
|
/*
|
||||||
|
* Attempt to read existing subscriber record for this email address
|
||||||
|
*/
|
||||||
let newSubscriber = false
|
let newSubscriber = false
|
||||||
await this.read({ ehash })
|
await this.read({ ehash })
|
||||||
|
|
||||||
if (!this.record) {
|
/*
|
||||||
// No record found. Create subscriber record.
|
* If no record can be found, create a new subscriber record.
|
||||||
newSubscriber = true
|
*/
|
||||||
const data = await this.cloak({ ehash, email, language, active: false })
|
if (!this.record) await this.createRecord({ ehash, email, language, active: false })
|
||||||
try {
|
|
||||||
this.record = await this.prisma.subscriber.create({ data })
|
|
||||||
} catch (err) {
|
|
||||||
log.warn(err, 'Could not create subscriber record')
|
|
||||||
return this.setResponse(500, 'createSubscriberFailed')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct the various URLs
|
/*
|
||||||
|
* Construct the various URLs for the outgoing email
|
||||||
|
*/
|
||||||
const actionUrl = i18nUrl(
|
const actionUrl = i18nUrl(
|
||||||
`/newsletter/${this.record.active ? 'un' : ''}subscribe/${this.record.id}/${ehash}`
|
`/newsletter/${this.record.active ? 'un' : ''}subscribe/${this.record.id}/${ehash}`
|
||||||
)
|
)
|
||||||
|
|
||||||
// Send out confirmation email unless it's a test and we don't want to send test emails
|
/*
|
||||||
|
* Send out confirmation email unless it's a test and we don't want to send test emails
|
||||||
|
*/
|
||||||
if (!isTest || this.config.use.tests.email) {
|
if (!isTest || this.config.use.tests.email) {
|
||||||
const template = newSubscriber ? 'nlsub' : this.record.active ? 'nlsubact' : 'nlsubinact'
|
const template = newSubscriber ? 'nlsub' : this.record.active ? 'nlsubact' : 'nlsubinact'
|
||||||
|
|
||||||
await this.mailer.send({
|
await this.mailer.send({
|
||||||
template,
|
template,
|
||||||
language,
|
language,
|
||||||
|
@ -66,234 +83,119 @@ SubscriberModel.prototype.guardedCreate = async function ({ body }) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Prepare the return data
|
||||||
|
*/
|
||||||
const returnData = { language, email }
|
const returnData = { language, email }
|
||||||
if (isTest) {
|
if (isTest) {
|
||||||
returnData.id = this.record.id
|
returnData.id = this.record.id
|
||||||
returnData.ehash = ehash
|
returnData.ehash = ehash
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.setResponse(200, 'success', { data: returnData })
|
/*
|
||||||
|
* Return 200 and the data
|
||||||
|
*/
|
||||||
|
return this.setResponse200({ data: returnData })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Confirms a pending subscription
|
||||||
|
* This is an unauthenticated route
|
||||||
|
*
|
||||||
|
* @param {body} object - The request body
|
||||||
|
* @returns {SubscriberModal} object - The SubscriberModel
|
||||||
|
*/
|
||||||
SubscriberModel.prototype.subscribeConfirm = async function ({ body }) {
|
SubscriberModel.prototype.subscribeConfirm = async function ({ body }) {
|
||||||
const { id, ehash } = body
|
/*
|
||||||
if (!id) return this.setResponse(400, 'idMissing')
|
* Validate input and load subscription record
|
||||||
if (!ehash) return this.setResponse(400, 'ehashMissing')
|
*/
|
||||||
|
await this.verifySubscription(body)
|
||||||
|
|
||||||
// Find subscription
|
/*
|
||||||
await this.read({ ehash })
|
* If a status code is already set, do not continue
|
||||||
|
*/
|
||||||
|
if (this.response?.status) return this
|
||||||
|
|
||||||
if (!this.record) {
|
/*
|
||||||
// Subscriber not found
|
* Update the status if the subscription is not active
|
||||||
return this.setResponse(404, 'subscriberNotFound')
|
*/
|
||||||
}
|
if (this.record.active !== true) await this.update({ active: true })
|
||||||
|
|
||||||
if (this.record.status !== true) {
|
/*
|
||||||
// Update username
|
* Return 200
|
||||||
try {
|
*/
|
||||||
await this.unguardedUpdate({ active: true })
|
return this.setResponse200()
|
||||||
} catch (err) {
|
|
||||||
log.warn(err, 'Could not update active state after subscribe confirmation')
|
|
||||||
return this.setResponse(500, 'subscriberActivationFailed')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.setResponse(200, 'success')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Confirms a pending unsubscription
|
||||||
|
* This is an unauthenticated route
|
||||||
|
*
|
||||||
|
* @param {body} object - The request body
|
||||||
|
* @returns {SubscriberModal} object - The SubscriberModel
|
||||||
|
*/
|
||||||
SubscriberModel.prototype.unsubscribeConfirm = async function ({ body }) {
|
SubscriberModel.prototype.unsubscribeConfirm = async function ({ body }) {
|
||||||
|
/*
|
||||||
|
* Validate input and load subscription record
|
||||||
|
*/
|
||||||
|
await this.verifySubscription(body)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If a status code is already set, do not continue
|
||||||
|
*/
|
||||||
|
if (this.response?.status) return this
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Remove the record
|
||||||
|
*/
|
||||||
|
await this.delete({ id: this.record.id })
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return 204
|
||||||
|
*/
|
||||||
|
return this.setResponse(204)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A helper method to validate input and load the subscription record
|
||||||
|
*
|
||||||
|
* @param {body} object - The request body
|
||||||
|
* @returns {SubscriberModal} object - The SubscriberModel
|
||||||
|
*/
|
||||||
|
SubscriberModel.prototype.verifySubscription = async function (body) {
|
||||||
|
/*
|
||||||
|
* Get the id and ehash from the body
|
||||||
|
*/
|
||||||
const { id, ehash } = body
|
const { id, ehash } = body
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is id set?
|
||||||
|
*/
|
||||||
if (!id) return this.setResponse(400, 'idMissing')
|
if (!id) return this.setResponse(400, 'idMissing')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is ehash set?
|
||||||
|
*/
|
||||||
if (!ehash) return this.setResponse(400, 'ehashMissing')
|
if (!ehash) return this.setResponse(400, 'ehashMissing')
|
||||||
|
|
||||||
// Find subscription
|
/*
|
||||||
|
* Find the subscription record
|
||||||
|
*/
|
||||||
await this.read({ ehash })
|
await this.read({ ehash })
|
||||||
|
|
||||||
if (this.record) {
|
/*
|
||||||
// Remove record
|
* If it is not found, return 404
|
||||||
try {
|
*/
|
||||||
await this.unguardedDelete()
|
if (!this.record) return this.setResponse(404, 'subscriberNotFound')
|
||||||
} catch (err) {
|
|
||||||
log.warn(err, 'Could not remove subscriber')
|
|
||||||
return this.setResponse(500, 'subscriberRemovalFailed')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.setResponse(200, 'success')
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Updates the subscriber data
|
|
||||||
* Used when we create the data ourselves so we know it's safe
|
|
||||||
*/
|
|
||||||
SubscriberModel.prototype.unguardedUpdate = async function (data) {
|
|
||||||
try {
|
|
||||||
this.record = await this.prisma.subscriber.update({
|
|
||||||
where: { id: this.record.id },
|
|
||||||
data,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
log.warn(err, 'Could not update subscriber record')
|
|
||||||
process.exit()
|
|
||||||
return this.setResponse(500, 'updateSubscriberFailed')
|
|
||||||
}
|
|
||||||
await this.reveal()
|
|
||||||
|
|
||||||
return this.setResponse(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Removes the subscriber record
|
|
||||||
* Used when we call for removal ourselves so we know it's safe
|
|
||||||
*/
|
|
||||||
SubscriberModel.prototype.unguardedDelete = async function () {
|
|
||||||
await this.prisma.subscriber.delete({ where: { id: this.record.id } })
|
|
||||||
this.record = null
|
|
||||||
this.clear = null
|
|
||||||
|
|
||||||
return this.subscriberExists()
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Loads a subscriber from the database based on the where clause you pass it
|
|
||||||
*
|
|
||||||
* Stores result in this.record
|
|
||||||
*/
|
|
||||||
SubscriberModel.prototype.read = async function (where) {
|
|
||||||
try {
|
|
||||||
this.record = await this.prisma.subscriber.findUnique({ where })
|
|
||||||
} catch (err) {
|
|
||||||
log.warn({ err, where }, 'Could not read subscriber')
|
|
||||||
}
|
|
||||||
|
|
||||||
this.reveal()
|
|
||||||
|
|
||||||
return this.subscriberExists()
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Checks this.record and sets a boolean to indicate whether
|
|
||||||
* the subscription exists or not
|
|
||||||
*
|
|
||||||
* Stores result in this.exists
|
|
||||||
*/
|
|
||||||
SubscriberModel.prototype.subscriberExists = function () {
|
|
||||||
this.exists = this.record ? true : false
|
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Loads a measurements set from the database based on the where clause you pass it
|
|
||||||
* In addition prepares it for returning the set data
|
|
||||||
*
|
*
|
||||||
* Stores result in this.record
|
* Anything below this comment is migration code for the v2 => v3 migration
|
||||||
|
* and can be safely removed after the migration is done
|
||||||
*/
|
*/
|
||||||
SubscriberModel.prototype.guardedRead = async function ({ params, user }) {
|
|
||||||
if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
|
||||||
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
|
|
||||||
|
|
||||||
await this.read({ id: parseInt(params.id) })
|
|
||||||
if (!this.record) return this.setResponse(404)
|
|
||||||
if (this.record.userId !== user.uid && !this.rbac.bughunter(user)) {
|
|
||||||
return this.setResponse(403, 'insufficientAccessLevel')
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.setResponse(200, false, {
|
|
||||||
result: 'success',
|
|
||||||
set: this.asSet(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Helper method to decrypt at-rest data
|
|
||||||
*/
|
|
||||||
SubscriberModel.prototype.reveal = async function () {
|
|
||||||
this.clear = {}
|
|
||||||
if (this.record) {
|
|
||||||
for (const field of this.encryptedFields) {
|
|
||||||
try {
|
|
||||||
this.clear[field] = this.decrypt(this.record[field])
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Helper method to encrypt at-rest data
|
|
||||||
*/
|
|
||||||
SubscriberModel.prototype.cloak = function (data) {
|
|
||||||
for (const field of this.encryptedFields) {
|
|
||||||
if (typeof data[field] !== 'undefined') {
|
|
||||||
data[field] = this.encrypt(data[field])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Removes the subscriber - No questions asked
|
|
||||||
*/
|
|
||||||
SubscriberModel.prototype.unguardedDelete = async function () {
|
|
||||||
await this.prisma.subscriber.delete({ where: { id: this.record.id } })
|
|
||||||
this.record = null
|
|
||||||
this.clear = null
|
|
||||||
|
|
||||||
return this.subscriberExists()
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Helper method to set the response code, result, and body
|
|
||||||
*
|
|
||||||
* Will be used by this.sendResponse()
|
|
||||||
*/
|
|
||||||
SubscriberModel.prototype.setResponse = function (
|
|
||||||
status = 200,
|
|
||||||
error = false,
|
|
||||||
data = {},
|
|
||||||
rawData = false
|
|
||||||
) {
|
|
||||||
this.response = {
|
|
||||||
status,
|
|
||||||
body: rawData
|
|
||||||
? data
|
|
||||||
: {
|
|
||||||
result: 'success',
|
|
||||||
...data,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if (status > 201) {
|
|
||||||
this.response.body.error = error
|
|
||||||
this.response.body.result = 'error'
|
|
||||||
this.error = true
|
|
||||||
} else this.error = false
|
|
||||||
|
|
||||||
return this.subscriberExists()
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Helper method to send response (as JSON)
|
|
||||||
*/
|
|
||||||
SubscriberModel.prototype.sendResponse = async function (res) {
|
|
||||||
return res.status(this.response.status).send(this.response.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Update method to determine whether this request is part of a (unit) test
|
|
||||||
*/
|
|
||||||
SubscriberModel.prototype.isTest = function (body) {
|
|
||||||
// Disalowing tests in prodution is hard-coded to protect people from themselves
|
|
||||||
if (this.config.env === 'production' && !this.config.tests.production) return false
|
|
||||||
if (!body.test) return false
|
|
||||||
if (body.email && !body.email.split('@').pop() === this.config.tests.domain) return false
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* This is a special route not available for API users
|
* This is a special route not available for API users
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -10,7 +10,9 @@ const headers = { Authorization: `Bearer ${config.token}` }
|
||||||
* Method that does the actual image upload to cloudflare
|
* Method that does the actual image upload to cloudflare
|
||||||
* Use this for a new image that does not yet exist
|
* Use this for a new image that does not yet exist
|
||||||
*/
|
*/
|
||||||
export async function storeImage(props) {
|
export async function storeImage(props, isTest = false) {
|
||||||
|
if (isTest) return props.id || false
|
||||||
|
|
||||||
const form = getFormData(props)
|
const form = getFormData(props)
|
||||||
let result
|
let result
|
||||||
try {
|
try {
|
||||||
|
@ -41,7 +43,8 @@ export async function storeImage(props) {
|
||||||
* Method that does the actual image upload to cloudflare
|
* Method that does the actual image upload to cloudflare
|
||||||
* Use this to replace an existing image
|
* Use this to replace an existing image
|
||||||
*/
|
*/
|
||||||
export async function replaceImage(props) {
|
export async function replaceImage(props, isTest = false) {
|
||||||
|
if (isTest) return props.id || false
|
||||||
const form = getFormData(props)
|
const form = getFormData(props)
|
||||||
// Ignore errors on delete, probably means the image does not exist
|
// Ignore errors on delete, probably means the image does not exist
|
||||||
try {
|
try {
|
||||||
|
@ -64,7 +67,8 @@ export async function replaceImage(props) {
|
||||||
* Method that uploads an image to cloudflare
|
* Method that uploads an image to cloudflare
|
||||||
* Use this to merely ensure the image exists (will fail silently if it does)
|
* Use this to merely ensure the image exists (will fail silently if it does)
|
||||||
*/
|
*/
|
||||||
export async function ensureImage(props) {
|
export async function ensureImage(props, isTest = false) {
|
||||||
|
if (isTest) return props.id || false
|
||||||
const form = getFormData(props)
|
const form = getFormData(props)
|
||||||
let result
|
let result
|
||||||
try {
|
try {
|
||||||
|
@ -80,7 +84,8 @@ export async function ensureImage(props) {
|
||||||
/*
|
/*
|
||||||
* Method that imports and image from URL and does not bother waiting for the answer
|
* Method that imports and image from URL and does not bother waiting for the answer
|
||||||
*/
|
*/
|
||||||
export async function importImage(props) {
|
export async function importImage(props, isTest = false) {
|
||||||
|
if (isTest) return props.id || false
|
||||||
// Bypass slow ass upload when testing import
|
// Bypass slow ass upload when testing import
|
||||||
if (!config.import) return `default-avatar`
|
if (!config.import) return `default-avatar`
|
||||||
|
|
||||||
|
|
29
sites/backend/src/utils/github.mjs
Normal file
29
sites/backend/src/utils/github.mjs
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import fetch from 'node-fetch'
|
||||||
|
import { githubToken as token } from '../config.mjs'
|
||||||
|
const issueApi = `https://api.github.com/repos/freesewing/freesewing/issues`
|
||||||
|
|
||||||
|
export async function createIssue(body) {
|
||||||
|
let response
|
||||||
|
try {
|
||||||
|
response = await fetch(issueApi, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
Accept: 'application/vnd.github.v3+json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 201) response = await response.json()
|
||||||
|
else {
|
||||||
|
console.log(response)
|
||||||
|
response = false
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('An error occurred while creating a GitHub issue:', error.message)
|
||||||
|
response = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
354
sites/backend/src/utils/model-decorator.mjs
Normal file
354
sites/backend/src/utils/model-decorator.mjs
Normal file
|
@ -0,0 +1,354 @@
|
||||||
|
import { log } from '../utils/log.mjs'
|
||||||
|
import yaml from 'js-yaml'
|
||||||
|
import { hashPassword } from '../utils/crypto.mjs'
|
||||||
|
import { asJson, capitalize } from '../utils/index.mjs'
|
||||||
|
/*
|
||||||
|
* Models will be attached on-demand
|
||||||
|
*/
|
||||||
|
import { ApikeyModel } from '../models/apikey.mjs'
|
||||||
|
import { ConfirmationModel } from '../models/confirmation.mjs'
|
||||||
|
import { CuratedSetModel } from '../models/curated-set.mjs'
|
||||||
|
import { FlowModel } from '../models/flow.mjs'
|
||||||
|
import { IssueModel } from '../models/issue.mjs'
|
||||||
|
import { PatternModel } from '../models/pattern.mjs'
|
||||||
|
import { SetModel } from '../models/set.mjs'
|
||||||
|
import { SubscriberModel } from '../models/subscriber.mjs'
|
||||||
|
import { UserModel } from '../models/user.mjs'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This adds a bunch of stuff to a model that is common
|
||||||
|
* between various models, and thus abstracted here
|
||||||
|
*/
|
||||||
|
export function decorateModel(Model, tools, modelConfig) {
|
||||||
|
/*
|
||||||
|
* Make config available
|
||||||
|
*/
|
||||||
|
Model.config = tools.config
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Set the name
|
||||||
|
*/
|
||||||
|
Model.name = modelConfig.name
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Attach all tooling
|
||||||
|
*/
|
||||||
|
Model.prisma = tools.prisma
|
||||||
|
Model.decrypt = tools.decrypt
|
||||||
|
Model.encrypt = tools.encrypt
|
||||||
|
Model.mfa = tools.mfa
|
||||||
|
Model.rbac = tools.rbac
|
||||||
|
Model.mailer = tools.email
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Set encrypted fields based on config
|
||||||
|
*/
|
||||||
|
Model.encryptedFields = modelConfig.encryptedFields || []
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Create object to hold decrypted data
|
||||||
|
*/
|
||||||
|
Model.clear = {}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Make various helper models available, as configured
|
||||||
|
*/
|
||||||
|
if (modelConfig.models && Array.isArray(modelConfig.models)) {
|
||||||
|
if (modelConfig.models.includes('apikey')) Model.Apikey = new ApikeyModel(tools)
|
||||||
|
if (modelConfig.models.includes('confirmation'))
|
||||||
|
Model.Confirmation = new ConfirmationModel(tools)
|
||||||
|
if (modelConfig.models.includes('cset')) Model.CuratedSet = new CuratedSetModel(tools)
|
||||||
|
if (modelConfig.models.includes('flow')) Model.Flow = new FlowModel(tools)
|
||||||
|
if (modelConfig.models.includes('issue')) Model.Issue = new IssueModel(tools)
|
||||||
|
if (modelConfig.models.includes('pattern')) Model.Pattern = new PatternModel(tools)
|
||||||
|
if (modelConfig.models.includes('set')) Model.Set = new SetModel(tools)
|
||||||
|
if (modelConfig.models.includes('subscriber')) Model.Subscriber = new SubscriberModel(tools)
|
||||||
|
if (modelConfig.models.includes('user')) Model.User = new UserModel(tools)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Loads a model instance from the database based on the where clause you pass it
|
||||||
|
*
|
||||||
|
* Stores result in this.record
|
||||||
|
*/
|
||||||
|
Model.read = async function (where, include = {}) {
|
||||||
|
if ((where.id && typeof where.id === 'number' && isNaN(where.id)) || where.id === null) {
|
||||||
|
return this.recordExists()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.record = this.unserialize(
|
||||||
|
await this.prisma[modelConfig.name].findUnique({ where, include })
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
log.warn({ err, where }, `Could not read ${modelConfig.name}`)
|
||||||
|
return this.recordExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.reveal()
|
||||||
|
|
||||||
|
return this.recordExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Helper method to decrypt at-rest data
|
||||||
|
*/
|
||||||
|
Model.reveal = async function () {
|
||||||
|
this.clear = {}
|
||||||
|
if (this.record) {
|
||||||
|
for (const field of this.encryptedFields) {
|
||||||
|
this.clear[field] = await this.decrypt(this.record[field])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Handle nested records with JSON fields
|
||||||
|
*/
|
||||||
|
if (this.record?.cset) {
|
||||||
|
for (const lang of this.config.languages) {
|
||||||
|
const key = `tags${capitalize(lang)}`
|
||||||
|
if (this.record.cset[key]) this.record.cset[key] = JSON.parse(this.record.cset[key])
|
||||||
|
}
|
||||||
|
if (this.record.cset.measies) this.record.cset.measies = JSON.parse(this.record.cset.measies)
|
||||||
|
}
|
||||||
|
if (this.record?.set) this.record.set = this.Set.revealSet(this.record.set)
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Helper method to encrypt at-rest data
|
||||||
|
*/
|
||||||
|
Model.cloak = function (data) {
|
||||||
|
for (const field of this.encryptedFields) {
|
||||||
|
if (typeof data[field] !== 'undefined') {
|
||||||
|
data[field] = this.encrypt(data[field])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* Password needs to be hashed too
|
||||||
|
*/
|
||||||
|
if (data.password && typeof data.password === 'string') {
|
||||||
|
data.password = asJson(hashPassword(data.password))
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Checks this.record and sets a boolean to indicate whether
|
||||||
|
* the record exists or not
|
||||||
|
*
|
||||||
|
* Stores result in this.exists
|
||||||
|
*/
|
||||||
|
Model.recordExists = function () {
|
||||||
|
this.exists = this.record ? true : false
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A helper method to serialize data, making sure it's fit for writing to the database
|
||||||
|
*/
|
||||||
|
Model.serialize = function (data) {
|
||||||
|
if (this.name === 'curatedSet') {
|
||||||
|
/*
|
||||||
|
* Serialize to JSON
|
||||||
|
* See https://github.com/prisma/prisma/issues/3786
|
||||||
|
*/
|
||||||
|
if (data.measies && typeof data.measies === 'object')
|
||||||
|
data.measies = JSON.stringify(data.measies)
|
||||||
|
for (const lang of this.config.languages) {
|
||||||
|
const key = `tags${capitalize(lang)}`
|
||||||
|
if (data[key] && Array.isArray(data[key])) data[key] = JSON.stringify(data[key] || [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A helper method to unserialize data, making sure it's fit for sending to the client
|
||||||
|
*/
|
||||||
|
Model.unserialize = function (data) {
|
||||||
|
if (this.name === 'curatedSet') {
|
||||||
|
/*
|
||||||
|
* Unserialize from JSON
|
||||||
|
* See https://github.com/prisma/prisma/issues/3786
|
||||||
|
*/
|
||||||
|
if (data.measies && typeof data.measies === 'string') data.measies = JSON.parse(data.measies)
|
||||||
|
for (const lang of this.config.languages) {
|
||||||
|
const key = `tags${capitalize(lang)}`
|
||||||
|
if (data[key] && typeof data[key] === 'string') data[key] = JSON.parse(data[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Creates a record based on the model data
|
||||||
|
* Used when we create the data ourselves so we know it's safe
|
||||||
|
*/
|
||||||
|
Model.createRecord = async function (data) {
|
||||||
|
try {
|
||||||
|
const cloaked = await this.cloak(this.serialize(data))
|
||||||
|
this.record = await this.prisma[modelConfig.name].create({ data: cloaked })
|
||||||
|
} catch (err) {
|
||||||
|
/*
|
||||||
|
* Some error occured. Log warning and return 500
|
||||||
|
*/
|
||||||
|
log.warn(err, 'Could not create set')
|
||||||
|
return this.setResponse(500, 'createSetFailed')
|
||||||
|
}
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Updates the model data
|
||||||
|
* Used when we create the data ourselves so we know it's safe
|
||||||
|
*/
|
||||||
|
Model.update = async function (data, include = {}) {
|
||||||
|
try {
|
||||||
|
const cloaked = await this.cloak(this.serialize(data))
|
||||||
|
this.record = this.unserialize(
|
||||||
|
await this.prisma[modelConfig.name].update({
|
||||||
|
where: { id: this.record.id },
|
||||||
|
include,
|
||||||
|
data: cloaked,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(err, `Could not update ${modelConfig.name} record`)
|
||||||
|
return this.setResponse(500, 'updateUserFailed')
|
||||||
|
}
|
||||||
|
await this.reveal()
|
||||||
|
|
||||||
|
return this.setResponse(200)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Deletes the model data
|
||||||
|
*/
|
||||||
|
Model.delete = async function () {
|
||||||
|
await this.prisma[modelConfig.name].delete({ where: { id: this.record.id } })
|
||||||
|
this.record = null
|
||||||
|
this.clear = null
|
||||||
|
|
||||||
|
return this.recordExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Helper method to set the response code, result, and body
|
||||||
|
*
|
||||||
|
* Will be used by this.sendResponse()
|
||||||
|
*/
|
||||||
|
Model.setResponse = function (status = 200, result = 'success', data = {}) {
|
||||||
|
this.response = {
|
||||||
|
status,
|
||||||
|
body: { result, ...data },
|
||||||
|
}
|
||||||
|
if (status > 201) {
|
||||||
|
this.response.body.error = result
|
||||||
|
this.response.body.result = 'error'
|
||||||
|
this.error = true
|
||||||
|
} else this.error = false
|
||||||
|
if (status === 404) this.response.body = null
|
||||||
|
|
||||||
|
return this.recordExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Helper method to set response code 200, as it's so common
|
||||||
|
*/
|
||||||
|
Model.setResponse200 = function (data = {}) {
|
||||||
|
return this.setResponse(200, 'success', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Helper method to set response code 201, as it's so common
|
||||||
|
*/
|
||||||
|
Model.setResponse201 = function (data = {}) {
|
||||||
|
return this.setResponse(201, 'created', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Helper method to send response
|
||||||
|
*/
|
||||||
|
Model.sendResponse = async function (res) {
|
||||||
|
return res.status(this.response.status).send(this.response.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Helper method to send response as YAML
|
||||||
|
*/
|
||||||
|
Model.sendYamlResponse = async function (res) {
|
||||||
|
let body
|
||||||
|
try {
|
||||||
|
body = yaml.dump(this.response.body)
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err)
|
||||||
|
}
|
||||||
|
return res.status(this.response.status).type('yaml').send(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Helper method to sanitize measurments
|
||||||
|
*/
|
||||||
|
Model.sanitizeMeasurements = function (input) {
|
||||||
|
const measies = {}
|
||||||
|
if (typeof input !== 'object') return input
|
||||||
|
for (const [m, val] of Object.entries(input)) {
|
||||||
|
if (this.config.measies.includes(m) && typeof val === 'number' && val > 0) measies[m] = val
|
||||||
|
}
|
||||||
|
|
||||||
|
return measies
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Helper method to determine whether this request is part of a (unit) test
|
||||||
|
*/
|
||||||
|
Model.isTest = function (body) {
|
||||||
|
/*
|
||||||
|
* Test in production need to be explicitly allowed
|
||||||
|
*/
|
||||||
|
if (this.config.env === 'production' && !this.config.tests.production) return false
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If there's not test in the body, it's not a test
|
||||||
|
*/
|
||||||
|
if (!body.test) return false
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If the authenticated user does not use the configured test domain for email, it's not a test
|
||||||
|
*/
|
||||||
|
if (this.clear?.email && !this.clear.email.split('@').pop() === this.config.tests.domain)
|
||||||
|
return false
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If the email used in the POST body does not use the configured test domain for email, it's not a test
|
||||||
|
*/
|
||||||
|
if (body.email && !body.email.split('@').pop() === this.config.tests.domain) return false
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Looks like it's a test
|
||||||
|
*/
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Helper method to troubleshoot requests by outputting timing data
|
||||||
|
*/
|
||||||
|
Model.time = function (key) {
|
||||||
|
if (this.timer)
|
||||||
|
log.info(`Timer split [${key ? key : modalConfig.name}] ${Date.now() - this.timer}ms`)
|
||||||
|
else {
|
||||||
|
this.timer = Date.now()
|
||||||
|
log.info(`Timer start [${key ? key : modalConfig.name}] 0ms`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
return Model
|
||||||
|
}
|
|
@ -4,6 +4,7 @@ import { capitalize } from '../src/utils/index.mjs'
|
||||||
export const curatedSetTests = async (chai, config, expect, store) => {
|
export const curatedSetTests = async (chai, config, expect, store) => {
|
||||||
const data = {
|
const data = {
|
||||||
jwt: {
|
jwt: {
|
||||||
|
test: true,
|
||||||
nameDe: 'Beispielmessungen A',
|
nameDe: 'Beispielmessungen A',
|
||||||
nameEn: 'Example measurements A',
|
nameEn: 'Example measurements A',
|
||||||
nameEs: 'Medidas de ejemplo A',
|
nameEs: 'Medidas de ejemplo A',
|
||||||
|
@ -28,6 +29,7 @@ export const curatedSetTests = async (chai, config, expect, store) => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
key: {
|
key: {
|
||||||
|
test: true,
|
||||||
nameDe: 'Beispielmessungen B',
|
nameDe: 'Beispielmessungen B',
|
||||||
nameEn: 'Example measurements B',
|
nameEn: 'Example measurements B',
|
||||||
nameEs: 'Medidas de ejemplo B',
|
nameEs: 'Medidas de ejemplo B',
|
||||||
|
@ -83,16 +85,16 @@ export const curatedSetTests = async (chai, config, expect, store) => {
|
||||||
.end((err, res) => {
|
.end((err, res) => {
|
||||||
expect(err === null).to.equal(true)
|
expect(err === null).to.equal(true)
|
||||||
expect(res.status).to.equal(201)
|
expect(res.status).to.equal(201)
|
||||||
expect(res.body.result).to.equal(`success`)
|
expect(res.body.result).to.equal(`created`)
|
||||||
for (const [key, val] of Object.entries(data[auth])) {
|
for (const [key, val] of Object.entries(data[auth])) {
|
||||||
if (!['measies', 'img'].includes(key))
|
if (!['measies', 'img', 'test'].includes(key)) {
|
||||||
expect(JSON.stringify(res.body.curatedSet[key])).to.equal(JSON.stringify(val))
|
expect(JSON.stringify(res.body.curatedSet[key])).to.equal(JSON.stringify(val))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
store.curatedSet[auth] = res.body.curatedSet
|
store.curatedSet[auth] = res.body.curatedSet
|
||||||
done()
|
done()
|
||||||
})
|
})
|
||||||
}).timeout(5000)
|
}).timeout(5000)
|
||||||
|
|
||||||
for (const field of ['name', 'notes']) {
|
for (const field of ['name', 'notes']) {
|
||||||
for (const lang of config.languages) {
|
for (const lang of config.languages) {
|
||||||
const langField = field + capitalize(lang)
|
const langField = field + capitalize(lang)
|
||||||
|
@ -234,7 +236,7 @@ export const curatedSetTests = async (chai, config, expect, store) => {
|
||||||
.end((err, res) => {
|
.end((err, res) => {
|
||||||
expect(err === null).to.equal(true)
|
expect(err === null).to.equal(true)
|
||||||
expect(res.status).to.equal(201)
|
expect(res.status).to.equal(201)
|
||||||
expect(res.body.result).to.equal(`success`)
|
expect(res.body.result).to.equal(`created`)
|
||||||
expect(typeof res.body.error).to.equal(`undefined`)
|
expect(typeof res.body.error).to.equal(`undefined`)
|
||||||
expect(typeof res.body.set.id).to.equal(`number`)
|
expect(typeof res.body.set.id).to.equal(`number`)
|
||||||
expect(res.body.set.name).to.equal(store.curatedSet[auth].nameNl + '_updated')
|
expect(res.body.set.name).to.equal(store.curatedSet[auth].nameNl + '_updated')
|
||||||
|
|
58
sites/backend/tests/flow.mjs
Normal file
58
sites/backend/tests/flow.mjs
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import { cat } from './cat.mjs'
|
||||||
|
import { capitalize } from '../src/utils/index.mjs'
|
||||||
|
|
||||||
|
export const flowTests = async (chai, config, expect, store) => {
|
||||||
|
const auths = ['jwt', 'key']
|
||||||
|
|
||||||
|
for (const auth of auths) {
|
||||||
|
describe(`${store.icon('flow', auth)} Flow tests (${auth})`, () => {
|
||||||
|
it(`${store.icon('set', auth)} Should request a translator invite (${auth})`, (done) => {
|
||||||
|
chai
|
||||||
|
.request(config.api)
|
||||||
|
.post(`/flows/translator-invite/${auth}`)
|
||||||
|
.set(
|
||||||
|
'Authorization',
|
||||||
|
auth === 'jwt'
|
||||||
|
? 'Bearer ' + store.account.token
|
||||||
|
: 'Basic ' +
|
||||||
|
new Buffer(`${store.account.apikey.key}:${store.account.apikey.secret}`).toString(
|
||||||
|
'base64'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.send({
|
||||||
|
language: 'nl',
|
||||||
|
})
|
||||||
|
.end((err, res) => {
|
||||||
|
expect(err === null).to.equal(true)
|
||||||
|
expect(res.status).to.equal(200)
|
||||||
|
expect(res.body.result).to.equal(`success`)
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`${store.icon('set', auth)} Should suggest a new language (${auth})`, (done) => {
|
||||||
|
chai
|
||||||
|
.request(config.api)
|
||||||
|
.post(`/flows/language-suggestion/${auth}`)
|
||||||
|
.set(
|
||||||
|
'Authorization',
|
||||||
|
auth === 'jwt'
|
||||||
|
? 'Bearer ' + store.account.token
|
||||||
|
: 'Basic ' +
|
||||||
|
new Buffer(`${store.account.apikey.key}:${store.account.apikey.secret}`).toString(
|
||||||
|
'base64'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.send({
|
||||||
|
language: 'cn',
|
||||||
|
})
|
||||||
|
.end((err, res) => {
|
||||||
|
expect(err === null).to.equal(true)
|
||||||
|
expect(res.status).to.equal(200)
|
||||||
|
expect(res.body.result).to.equal(`success`)
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,6 +6,8 @@ import { setTests } from './set.mjs'
|
||||||
import { curatedSetTests } from './curated-set.mjs'
|
import { curatedSetTests } from './curated-set.mjs'
|
||||||
import { patternTests } from './pattern.mjs'
|
import { patternTests } from './pattern.mjs'
|
||||||
import { subscriberTests } from './subscriber.mjs'
|
import { subscriberTests } from './subscriber.mjs'
|
||||||
|
import { flowTests } from './flow.mjs'
|
||||||
|
import { issueTests } from './issue.mjs'
|
||||||
import { setup } from './shared.mjs'
|
import { setup } from './shared.mjs'
|
||||||
|
|
||||||
const runTests = async (...params) => {
|
const runTests = async (...params) => {
|
||||||
|
@ -17,6 +19,8 @@ const runTests = async (...params) => {
|
||||||
await curatedSetTests(...params)
|
await curatedSetTests(...params)
|
||||||
await patternTests(...params)
|
await patternTests(...params)
|
||||||
await subscriberTests(...params)
|
await subscriberTests(...params)
|
||||||
|
await flowTests(...params)
|
||||||
|
await issueTests(...params)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load initial data required for tests
|
// Load initial data required for tests
|
||||||
|
|
22
sites/backend/tests/issue.mjs
Normal file
22
sites/backend/tests/issue.mjs
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
export const issueTests = async (chai, config, expect, store) => {
|
||||||
|
describe(`${store.icon('issue')} Issue tests`, () => {
|
||||||
|
it(`${store.icon('set')} Should create an issue`, (done) => {
|
||||||
|
chai
|
||||||
|
.request(config.api)
|
||||||
|
.post(`/issues`)
|
||||||
|
.send({
|
||||||
|
title: '[test] This issue was created by a unit test',
|
||||||
|
body: `This issue was created by a unit test. Feel free to close it or even delete it.`,
|
||||||
|
assignees: ['joostdecock'],
|
||||||
|
labels: [':test_tube: tests'],
|
||||||
|
})
|
||||||
|
.end((err, res) => {
|
||||||
|
expect(err === null).to.equal(true)
|
||||||
|
expect(res.status).to.equal(201)
|
||||||
|
expect(res.body.result).to.equal(`created`)
|
||||||
|
expect(res.body.issue.state).to.equal(`open`)
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
|
@ -6,7 +6,7 @@ export const mfaTests = async (chai, config, expect, store) => {
|
||||||
key: store.altaccount,
|
key: store.altaccount,
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const auth in secret) {
|
for (const auth of ['jwt']) {
|
||||||
describe(`${store.icon('mfa', auth)} Setup Multi-Factor Authentication (MFA) (${auth})`, () => {
|
describe(`${store.icon('mfa', auth)} Setup Multi-Factor Authentication (MFA) (${auth})`, () => {
|
||||||
it(`${store.icon('mfa')} Should return 400 on MFA enable without proper value`, (done) => {
|
it(`${store.icon('mfa')} Should return 400 on MFA enable without proper value`, (done) => {
|
||||||
chai
|
chai
|
||||||
|
@ -21,7 +21,7 @@ export const mfaTests = async (chai, config, expect, store) => {
|
||||||
'base64'
|
'base64'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.send({ mfa: 'yes' })
|
.send({ mfa: 'yes', test: true })
|
||||||
.end((err, res) => {
|
.end((err, res) => {
|
||||||
expect(err === null).to.equal(true)
|
expect(err === null).to.equal(true)
|
||||||
expect(res.status).to.equal(400)
|
expect(res.status).to.equal(400)
|
||||||
|
@ -44,7 +44,7 @@ export const mfaTests = async (chai, config, expect, store) => {
|
||||||
'base64'
|
'base64'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.send({ mfa: true })
|
.send({ mfa: true, test: true })
|
||||||
.end((err, res) => {
|
.end((err, res) => {
|
||||||
expect(err === null).to.equal(true)
|
expect(err === null).to.equal(true)
|
||||||
expect(res.status).to.equal(200)
|
expect(res.status).to.equal(200)
|
||||||
|
@ -72,6 +72,7 @@ export const mfaTests = async (chai, config, expect, store) => {
|
||||||
)
|
)
|
||||||
.send({
|
.send({
|
||||||
mfa: true,
|
mfa: true,
|
||||||
|
test: true,
|
||||||
secret: secret[auth].mfaSecret,
|
secret: secret[auth].mfaSecret,
|
||||||
token: authenticator.generate(secret[auth].mfaSecret),
|
token: authenticator.generate(secret[auth].mfaSecret),
|
||||||
})
|
})
|
||||||
|
@ -96,7 +97,7 @@ export const mfaTests = async (chai, config, expect, store) => {
|
||||||
'base64'
|
'base64'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.send({ mfa: true })
|
.send({ mfa: true, test: true })
|
||||||
.end((err, res) => {
|
.end((err, res) => {
|
||||||
expect(err === null).to.equal(true)
|
expect(err === null).to.equal(true)
|
||||||
expect(res.status).to.equal(400)
|
expect(res.status).to.equal(400)
|
||||||
|
@ -121,6 +122,7 @@ export const mfaTests = async (chai, config, expect, store) => {
|
||||||
)
|
)
|
||||||
.send({
|
.send({
|
||||||
mfa: true,
|
mfa: true,
|
||||||
|
test: true,
|
||||||
secret: secret[auth].mfaSecret,
|
secret: secret[auth].mfaSecret,
|
||||||
token: authenticator.generate(secret[auth].mfaSecret),
|
token: authenticator.generate(secret[auth].mfaSecret),
|
||||||
})
|
})
|
||||||
|
@ -138,6 +140,7 @@ export const mfaTests = async (chai, config, expect, store) => {
|
||||||
.request(config.api)
|
.request(config.api)
|
||||||
.post('/signin')
|
.post('/signin')
|
||||||
.send({
|
.send({
|
||||||
|
test: true,
|
||||||
username: secret[auth].username,
|
username: secret[auth].username,
|
||||||
password: secret[auth].password,
|
password: secret[auth].password,
|
||||||
})
|
})
|
||||||
|
@ -155,6 +158,7 @@ export const mfaTests = async (chai, config, expect, store) => {
|
||||||
.request(config.api)
|
.request(config.api)
|
||||||
.post('/signin')
|
.post('/signin')
|
||||||
.send({
|
.send({
|
||||||
|
test: true,
|
||||||
username: secret[auth].username,
|
username: secret[auth].username,
|
||||||
password: secret[auth].password,
|
password: secret[auth].password,
|
||||||
token: authenticator.generate(secret[auth].mfaSecret),
|
token: authenticator.generate(secret[auth].mfaSecret),
|
||||||
|
@ -173,6 +177,7 @@ export const mfaTests = async (chai, config, expect, store) => {
|
||||||
.request(config.api)
|
.request(config.api)
|
||||||
.post('/signin')
|
.post('/signin')
|
||||||
.send({
|
.send({
|
||||||
|
test: true,
|
||||||
username: secret[auth].username,
|
username: secret[auth].username,
|
||||||
password: secret[auth].password,
|
password: secret[auth].password,
|
||||||
token: '1234',
|
token: '1234',
|
||||||
|
@ -200,6 +205,7 @@ export const mfaTests = async (chai, config, expect, store) => {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.send({
|
.send({
|
||||||
|
test: true,
|
||||||
mfa: false,
|
mfa: false,
|
||||||
password: secret[auth].password,
|
password: secret[auth].password,
|
||||||
token: authenticator.generate(secret[auth].mfaSecret),
|
token: authenticator.generate(secret[auth].mfaSecret),
|
||||||
|
|
|
@ -18,9 +18,11 @@ export const patternTests = async (chai, config, expect, store) => {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.send({
|
.send({
|
||||||
|
test: true,
|
||||||
design: 'aaron',
|
design: 'aaron',
|
||||||
settings: {
|
settings: {
|
||||||
sa: 5,
|
sa: 5,
|
||||||
|
measurements: store.account.sets.her.measurements,
|
||||||
},
|
},
|
||||||
name: 'Just a test',
|
name: 'Just a test',
|
||||||
notes: 'These are my notes',
|
notes: 'These are my notes',
|
||||||
|
@ -34,7 +36,7 @@ export const patternTests = async (chai, config, expect, store) => {
|
||||||
.end((err, res) => {
|
.end((err, res) => {
|
||||||
expect(err === null).to.equal(true)
|
expect(err === null).to.equal(true)
|
||||||
expect(res.status).to.equal(201)
|
expect(res.status).to.equal(201)
|
||||||
expect(res.body.result).to.equal(`success`)
|
expect(res.body.result).to.equal(`created`)
|
||||||
expect(typeof res.body.pattern?.id).to.equal('number')
|
expect(typeof res.body.pattern?.id).to.equal('number')
|
||||||
expect(res.body.pattern.userId).to.equal(store.account.id)
|
expect(res.body.pattern.userId).to.equal(store.account.id)
|
||||||
expect(res.body.pattern.setId).to.equal(store.account.sets.her.id)
|
expect(res.body.pattern.setId).to.equal(store.account.sets.her.id)
|
||||||
|
|
|
@ -54,7 +54,7 @@ export const setTests = async (chai, config, expect, store) => {
|
||||||
.end((err, res) => {
|
.end((err, res) => {
|
||||||
expect(err === null).to.equal(true)
|
expect(err === null).to.equal(true)
|
||||||
expect(res.status).to.equal(201)
|
expect(res.status).to.equal(201)
|
||||||
expect(res.body.result).to.equal(`success`)
|
expect(res.body.result).to.equal(`created`)
|
||||||
for (const [key, val] of Object.entries(data[auth])) {
|
for (const [key, val] of Object.entries(data[auth])) {
|
||||||
if (!['measies', 'img', 'test'].includes(key)) expect(res.body.set[key]).to.equal(val)
|
if (!['measies', 'img', 'test'].includes(key)) expect(res.body.set[key]).to.equal(val)
|
||||||
}
|
}
|
||||||
|
@ -325,8 +325,8 @@ export const setTests = async (chai, config, expect, store) => {
|
||||||
)
|
)
|
||||||
.end((err, res) => {
|
.end((err, res) => {
|
||||||
expect(err === null).to.equal(true)
|
expect(err === null).to.equal(true)
|
||||||
expect(res.status).to.equal(200)
|
expect(res.status).to.equal(201)
|
||||||
expect(res.body.result).to.equal(`success`)
|
expect(res.body.result).to.equal(`created`)
|
||||||
expect(typeof res.body.error).to.equal(`undefined`)
|
expect(typeof res.body.error).to.equal(`undefined`)
|
||||||
expect(typeof res.body.set.id).to.equal(`number`)
|
expect(typeof res.body.set.id).to.equal(`number`)
|
||||||
done()
|
done()
|
||||||
|
@ -352,8 +352,8 @@ export const setTests = async (chai, config, expect, store) => {
|
||||||
.end((err, res) => {
|
.end((err, res) => {
|
||||||
if (store.set[auth].public) {
|
if (store.set[auth].public) {
|
||||||
expect(err === null).to.equal(true)
|
expect(err === null).to.equal(true)
|
||||||
expect(res.status).to.equal(200)
|
expect(res.status).to.equal(201)
|
||||||
expect(res.body.result).to.equal(`success`)
|
expect(res.body.result).to.equal(`created`)
|
||||||
expect(typeof res.body.error).to.equal(`undefined`)
|
expect(typeof res.body.error).to.equal(`undefined`)
|
||||||
expect(typeof res.body.set.id).to.equal(`number`)
|
expect(typeof res.body.set.id).to.equal(`number`)
|
||||||
} else {
|
} else {
|
||||||
|
@ -365,7 +365,6 @@ export const setTests = async (chai, config, expect, store) => {
|
||||||
done()
|
done()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// TODO:
|
// TODO:
|
||||||
// - Clone set
|
// - Clone set
|
||||||
// - Clone set accross accounts of they are public
|
// - Clone set accross accounts of they are public
|
||||||
|
|
|
@ -41,7 +41,9 @@ export const setup = async () => {
|
||||||
key: '🎟️ ',
|
key: '🎟️ ',
|
||||||
set: '🧕 ',
|
set: '🧕 ',
|
||||||
pattern: '👕 ',
|
pattern: '👕 ',
|
||||||
subscriber: '📬',
|
subscriber: '📬 ',
|
||||||
|
flow: '🪁 ',
|
||||||
|
issue: '🚩 ',
|
||||||
},
|
},
|
||||||
randomString,
|
randomString,
|
||||||
}
|
}
|
||||||
|
|
|
@ -52,7 +52,7 @@ export const userTests = async (chai, config, expect, store) => {
|
||||||
expect(res.status).to.equal(201)
|
expect(res.status).to.equal(201)
|
||||||
expect(res.type).to.equal('application/json')
|
expect(res.type).to.equal('application/json')
|
||||||
expect(res.charset).to.equal('utf-8')
|
expect(res.charset).to.equal('utf-8')
|
||||||
expect(res.body.result).to.equal(`success`)
|
expect(res.body.result).to.equal(`created`)
|
||||||
expect(res.body.email).to.equal(fields.email)
|
expect(res.body.email).to.equal(fields.email)
|
||||||
done()
|
done()
|
||||||
})
|
})
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue