import path from 'path'
import configFactory, { defMultiSite } from './lib/config.js'
import { joiPasswordExtendCore } from 'joi-password'
/**
* Plugin factory.
*
* **Never** call this function directly!!! It's only-meant to be called by the {@link https://ardhi.github.io/bajo|Bajo framework} during plugin initialization.
*
* @param {string} pkgName - NPM package name
* @returns {Sumba} Sumba class
*/
async function factory (pkgName) {
const me = this
const { getModel } = this.app.dobo
const { cloneDeep, isEmpty } = this.app.lib._
const config = await configFactory.call(this)
/**
* Sumba class definition.
*
* @class
*/
class Sumba extends this.app.baseClass.Base {
/**
* Constructor
*/
constructor () {
super(pkgName, me.app)
this.config = config
this.secureGuards = []
this.actionGuards = []
this.queryGuards = []
this.attribGuards = []
this.secureGuards = []
this.anonymousGuards = []
this.unsafeUserFields = ['password']
}
/**
* Plugin initialization method. This method is called by the {@link https://ardhi.github.io/bajo|Bajo framework} during plugin initialization.
*
* @async
* @method
*/
init = async () => {
this.downloadDir = `${this.app.getPluginDataDir(this.ns)}/download`
this.app.lib.fs.ensureDirSync(this.downloadDir)
if (this.config.multiSite === true) {
this.config.multiSite = cloneDeep(defMultiSite)
this.config.multiSite.enabled = true
}
}
/**
* Plugin start method. This method is called by the {@link https://ardhi.github.io/bajo|Bajo framework} during plugin start.
*
* @async
* @method
*/
start = async () => {
await this.populateRouteGuards()
if (!this.config.multiSite.enabled) {
this.config.xSiteAdmins = []
return
}
if (this.config.xSiteAdmins.length === 0) {
const site = await getModel('SumbaSite').findOneRecord({ query: { alias: 'default' } }, { noMagic: true })
const user = await getModel('SumbaUser').findOneRecord({ query: { username: 'admin', siteId: site.id + '' } }, { noMagic: true })
this.config.xSiteAdmins.push(`${site.alias}:${user.username}`)
}
}
/**
* Populate route guards at start
*
* @async
* @method
* @param {array} [sites] - List of sites to populate route guards for. If not provided, it will fetch all active sites.
*/
populateRouteGuards = async (sites) => {
const { isString, get, difference } = this.app.lib._
const { pascalCase } = this.app.lib.aneka
const { eachPlugins, readConfig, breakNsPath } = this.app.bajo
const { getModel } = this.app.dobo
const allNs = this.app.getAllNs()
if (!sites) sites = await getModel('SumbaSite').findAllRecord({ query: { status: 'ACTIVE' } }, { noMagic: true, dataOnly: true, fields: ['id', 'hostname'] })
const sanitize = (item, ns) => {
if (isString(item)) item = { path: item }
let [prefix, ...args] = item.path.split(':')
const neg = prefix[0] === '!'
if (neg) prefix = prefix.slice(1)
if (isEmpty(args)) {
args = prefix
prefix = null
} else args = args.join(':')
if (isEmpty(prefix)) prefix = ns
else {
const [_ns, subNs] = prefix.split('.')
if (subNs) prefix = `${ns}.${subNs}`
else if (!allNs.includes(_ns)) prefix = `${ns}.${_ns}`
else prefix = _ns
}
item.path = `${neg ? '!' : ''}${prefix}:${args}`
item._immutable = item._immutable ?? ['path']
if (neg) {
item.allTeams = true
item.teamIds = []
item._immutable = ['path', 'teamIds', 'allTeams']
}
return item
}
const filterFn = item => {
let [ns] = (item.path.split(':')[0] ?? '').split('.')
if (ns[0] === '!') ns = ns.slice(1)
return allNs.includes(ns)
}
for (const type of ['secure', 'anonymous']) {
const routes = []
// get it from <pluginDir>/extend/sumba/route/*
await eachPlugins(async function ({ file }) {
const { ns } = this
const items = (await readConfig(file)).map(item => sanitize(item, ns)).filter(filterFn)
routes.push(...items)
}, { glob: `route/${type}.*`, prefix: this.ns })
// get it from config
const items = get(this, `config.route.${type}`, []).map(item => {
if (isString(item)) item = { path: item }
const neg = item.path[0] === '!'
if (neg) item.path.slice(1)
const { fullNs } = breakNsPath(item.path)
if (neg) item.path = '!' + item.path
return sanitize(item, fullNs)
}).filter(filterFn)
routes.push(...items)
const paths = routes.map(item => item.path)
const model = getModel(pascalCase(`Sumba ${type} Guard`))
for (const site of sites) {
const query = { path: { $in: paths }, siteId: site.id + '' }
const recs = await model.findAllRecord({ query }, { noMagic: true, dataOnly: true, fields: ['path', 'status'] })
const spaths = difference(paths, recs.map(rec => rec.path))
for (const path of spaths) {
const body = cloneDeep(routes.find(r => r.path === path))
body.status = 'ACTIVE'
body.siteId = site.id + ''
await model.sanitizeFixture({ body, lookupValue: body })
try {
await model.createRecord(body, { noMagic: true, noReturn: true })
} catch (err) {}
}
}
}
}
/**
* Get authentication setting based on type and source
*
* @method
* @private
* @param {string} type - The type of authentication setting to retrieve. If not provided, it will use parent setting.
* @param {string} [source] - The source of the authentication setting. If not provided, it will use the default setting.
* @returns {object} - The authentication setting object.
*/
_getAuthSetting = (type, source) => {
const { defaultsDeep } = this.app.lib.aneka
const { get } = this.app.lib._
let setting
const withType = type ? `.${type}` : ''
if (source) setting = defaultsDeep(get(this.config, `${source}.auth${withType}`, {}), get(this.config, `auth${withType}`, {}))
else setting = get(this.config, `auth${withType}`, {})
if (type === 'basic') setting.type = 'Basic'
return setting
}
/**
* Get authentication token based on type, request, and source
*
* @method
* @private
* @param {string} type - The type of authentication setting.
* @param {object} req - The request object.
* @param {string} [source] - The source of the authentication setting. If not provided, it will use the default setting.
* @returns {string|boolean} - The token if found, otherwise false.
*/
_getAuthToken = (type, req, source) => {
const { isEmpty } = this.app.lib._
const setting = this._getAuthSetting(type, source)
let token = req.headers[setting.headerKey.toLowerCase()]
if (!['basic'].includes(type) && isEmpty(token)) token = req.query[setting.qsKey]
if (isEmpty(token)) {
const parts = (req.headers.authorization || '').split(' ')
if (parts[0] === setting.type) token = parts[1]
}
if (isEmpty(token)) return false
return token
}
/**
* Build admin menu based on locals and request.
*
* - If multi-site is enabled, it will include the cross-site menu with options to manage all sites and site settings.
* - If caching is enabled, it will include the cache storage menu in the appropriate section.
* - Session management menu is also included in the appropriate section.
*
* @async
* @method
* @param {object} locals - The locals object.
* @param {object} req - The request object.
* @returns {array} - The admin menu items.
*/
adminMenu = async (locals, req) => {
if (!this.app.waibuAdmin) return
const { getPluginPrefix } = this.app.waibu
const { findIndex } = this.app.lib._
const prefix = getPluginPrefix(this.ns)
const params = { action: 'list' }
const items = [{
title: 'manageSite',
children: [
{ title: 'siteProfile', href: `waibuAdmin:/${prefix}/site` },
{ title: 'manageSiteSetting', href: `waibuAdmin:/${prefix}/site-setting/:action`, params }
]
}, {
title: 'manageUserTeam',
children: [
{ title: 'manageUser', href: `waibuAdmin:/${prefix}/user/:action`, params },
{ title: 'manageUserSetting', href: `waibuAdmin:/${prefix}/user-setting/:action`, params },
{ title: 'manageTeam', href: `waibuAdmin:/${prefix}/team/:action`, params },
{ title: 'manageTeamUser', href: `waibuAdmin:/${prefix}/team-user/:action`, params },
{ title: 'manageTeamSetting', href: `waibuAdmin:/${prefix}/team-setting/:action`, params },
{ title: 'resetUserPassword', href: `waibuAdmin:/${prefix}/reset-user-password` }
]
}, {
title: 'permissionGuard',
children: [
{ title: 'secureGuard', href: `waibuAdmin:/${prefix}/secure-guard/:action`, params },
{ title: 'anonymousGuard', href: `waibuAdmin:/${prefix}/anonymous-guard/:action`, params },
{ title: 'actionGuard', href: `waibuAdmin:/${prefix}/action-guard/:action`, params },
{ title: 'queryGuard', href: `waibuAdmin:/${prefix}/query-guard/:action`, params },
{ title: 'attribGuard', href: `waibuAdmin:/${prefix}/attrib-guard/:action`, params }
]
}, {
title: 'supportSystem',
children: [
{ title: 'contactForm', href: `waibuAdmin:/${prefix}/contact-form/:action`, params },
{ title: 'contactFormCat', href: `waibuAdmin:/${prefix}/contact-form-cat/:action`, params },
{ title: 'ticket', href: `waibuAdmin:/${prefix}/ticket/:action`, params },
{ title: 'ticketCat', href: `waibuAdmin:/${prefix}/ticket-cat/:action`, params }
]
}, {
title: 'misc',
children: [
{ title: 'manageDownload', href: `waibuAdmin:/${prefix}/download/:action`, params }
]
}]
const sessionMenu = { title: 'userSession', href: `waibuAdmin:/${prefix}/x/session/:action`, params }
const cacheMenu = { title: 'cacheStorage', href: `waibuAdmin:/${prefix}/x/cache/:action`, params }
if (this.config.multiSite.enabled) {
items.unshift({
title: 'xSite',
children: [
{ title: 'manageAllSites', href: `waibuAdmin:/${prefix}/x/site/:action`, params },
{ title: 'manageAllSiteSettings', href: `waibuAdmin:/${prefix}/x/site-setting/:action`, params },
sessionMenu
]
})
} else {
const idx = findIndex(items, i => i.title === 'misc')
if (idx > -1) items[idx].children.push(sessionMenu)
}
if (this.app.bajoCache) {
const idx = findIndex(items, i => i.title === this.config.multiSite.enabled ? 'xSite' : 'misc')
if (idx > -1)items[idx].children.push(cacheMenu)
}
return items
}
/**
* Create JWT from user record
*
* @async
* @method
* @param {object} rec - The user record object.
* @returns {Promise<object>} - The JWT token and its expiration date.
*/
createJwtFromUserRecord = async (rec) => {
const { importPkg } = this.app.bajo
const { dayjs } = this.app.lib
const { get, pick } = this.app.lib._
const fastJwt = await importPkg('bajoExtra:fast-jwt')
const { createSigner } = fastJwt
const setting = this._getAuthSetting('jwt')
const opts = pick(setting, ['expiresInDur'])
opts.key = get(setting, 'secret')
const sign = createSigner(opts)
const apiKey = await this.hash(rec.token)
const payload = { uid: rec.id, apiKey }
const token = await sign(payload)
const expiresAt = dayjs().add(opts.expiresInDur).toDate()
return { token, expiresAt }
}
/**
* Verify user session
*
* @async
* @method
* @param {object} req - The request object.
* @param {object} reply - The reply object.
* @param {string} source - The source of the authentication setting.
* @param {object} payload - The payload object.
* @returns {boolean} - True if the session is valid, otherwise false.
*/
verifySession = async (req, reply, source, payload) => {
const { routePath } = this.app.waibu
const { query, params } = req
if (!req.session) return false
if (req.session.userId) {
req.user = await this.getUserById(req.session.userId, req)
return true
}
const redir = routePath(this.config.redirect.signin, { query, params })
req.session.ref = req.url
throw this.error('_redirect', { path: redir })
}
/**
* Verify API key
*
* @async
* @method
* @param {object} req - The request object.
* @param {object} reply - The reply object.
* @param {string} source - The source of the authentication setting.
* @param {object} payload - The payload object.
* @returns {boolean} - True if the API key is valid, otherwise false.
*/
verifyApiKey = async (req, reply, source, payload) => {
const { merge, camelCase } = this.app.lib._
const setting = this._getAuthSetting(null, source)
const checker = this.app.bajoExtra[camelCase(`is ${setting.apiKey.algo}`)]
let token = await this._getAuthToken('apiKey', req, source)
if (!checker(token)) return false
token = await this.hash(token)
const user = await this.getUserByToken(token, req)
if (!user) throw this.error('invalidKey', merge({ statusCode: 401 }, payload))
if (user.status !== 'ACTIVE') throw this.error('userInactive', merge({ details: [{ field: 'status', error: 'inactive' }], statusCode: 401 }, payload))
req.user = user
return true
}
/**
* Verify basic authentication
*
* @async
* @method
* @param {object} req - The request object.
* @param {object} reply - The reply object.
* @param {string} source - The source of the authentication setting.
* @param {object} payload - The payload object.
* @returns {boolean|string} - True if the basic authentication is valid, otherwise false or an error message.
*/
verifyBasic = async (req, reply, source, payload) => {
const { isEmpty, merge } = this.app.lib._
const setHeader = async (setting, reply) => {
let header = setting.type
const exts = []
if (setting.realm) exts.push(`realm="${req.t('protectedArea')}"`)
if (setting.useUtf8) exts.push('charset="UTF-8"')
if (exts.length > 0) header += ` ${exts.join(', ')}`
reply.header('WWW-Authenticate', header)
reply.code(401)
}
const setting = this._getAuthSetting('basic', source)
let authInfo
const parts = (req.headers.authorization ?? '').split(' ')
if (parts[0] === setting.type) authInfo = parts[1]
if (isEmpty(authInfo)) {
if (setting.realm) {
await setHeader(setting, reply)
throw this.error(req.t('pleaseAuthenticate'), { statusCode: 403 })
} else return false
}
const decoded = Buffer.from(authInfo, 'base64').toString()
const [username, password] = decoded.split(':')
try {
req.user = await this.getUserByUsernamePassword(username, password, req)
} catch (err) {
if (err.statusCode === 401 && setting.realm) {
await setHeader(setting, reply)
return err.message
}
throw merge(err, payload)
}
return true
}
/**
* Verify JWT authentication
*
* @async
* @method
* @param {object} req - The request object.
* @param {object} reply - The reply object.
* @param {string} source - The source of the authentication setting.
* @param {object} payload - The payload object.
* @returns {boolean} - True if the JWT authentication is valid, otherwise false.
*/
verifyJwt = async (req, reply, source, payload) => {
const { importPkg } = this.app.bajo
const { isEmpty, merge } = this.app.lib._
const fastJwt = await importPkg('bajoExtra:fast-jwt')
const { createVerifier } = fastJwt
const setting = this._getAuthSetting('jwt', source)
const token = this._getAuthToken('jwt', req, source)
if (isEmpty(token)) return false
const verifier = createVerifier({
key: setting.secret,
complete: true
})
const decoded = await verifier(token)
const id = decoded.payload.uid
try {
const user = await this.getUserById(id, req)
if (!user) throw this.error('invalidToken', { statusCode: 401 })
if (user.status !== 'ACTIVE') throw this.error('userInactive', { details: [{ field: 'status', error: 'inactive' }], statusCode: 401 })
req.user = user
} catch (err) {
merge(err, payload)
throw err
}
return true
}
/**
* Check route guard
*
* @method
* @param {Array} guards - The array of guard objects.
* @param {Array} paths - The array of paths to check.
* @returns {string|undefined} - The matched path or undefined if no match is found.
*/
checkRouteGuard = (guards, paths) => {
const { outmatch } = this.app.lib
const all = guards.map(item => item.path)
const isMatch = outmatch(all)
return paths.find(isMatch)
}
/**
* Sign out user and redirect to the specified URL
*
* @async
* @method
* @param {object} options - The options object.
* @param {object} options.req - The request object.
* @param {object} options.reply - The reply object.
* @param {string} [options.reason] - The reason for signing out.
*/
signout = async (options = {}) => {
const { req, reply, reason } = options
const { runHook } = this.app.bajo
const { getSessionId } = this.app.waibuMpa
const sid = await getSessionId(req.headers.cookie)
req.session.userId = null
await runHook(`${this.ns}:afterSignout`, sid, req)
const { query, params } = req
// const url = !isEmpty(referer) ? referer : this.config.redirect.home
const url = this.config.redirect.afterSignout
req.flash('notify', req.t(reason ?? 'signoutSuccessfully'))
return reply.redirectTo(url, { query, params })
}
/**
* Sign in user and redirect to the specified URL
*
* @async
* @method
* @param {object} options - The options object.
* @param {object} options.req - The request object.
* @param {object} options.reply - The reply object.
* @param {object} options.user - The user object.
*/
signin = async (options = {}) => {
const { req, reply, user } = options
const { getSessionId } = this.app.waibuMpa
const { runHook } = this.app.bajo
const { isEmpty, omit } = this.app.lib._
let { referer } = req.body || {}
if (req.session.ref) referer = req.session.ref
req.session.ref = null
const _user = omit(user, ['password', 'token'])
req.session.userId = _user.id
const sid = await getSessionId(req.headers.cookie)
await runHook(`${this.ns}:afterSignin`, _user, sid, req)
const { query, params } = req
const url = !isEmpty(referer) ? referer : this.config.redirect.afterSignin
req.flash('notify', req.t('signinSuccessfully'))
return reply.redirectTo(url, { query, params })
}
/**
* Generate a random password based on the configuration settings
*
* @method
* @param {object} req - The request object.
* @returns {string} - The generated password.
*/
generatePassword = (req = {}) => {
const { get } = this.app.lib._
const { generateId } = this.app.lib.aneka
const cfg = get(req, 'site.setting.sumba.userPassword', this.config.siteSetting.userPassword)
let passwd = generateId()
if (cfg.minLowerCase > 0) passwd += generateId({ pattern: cfg.pattern.lowerCase, length: cfg.minLowercase })
if (cfg.minUpperCase > 0) passwd += generateId({ pattern: cfg.pattern.upperCase, length: cfg.minUppercase })
if (cfg.minSpecialChar > 0) passwd += generateId({ pattern: cfg.pattern.specialChar, length: cfg.minSpecialChar })
if (cfg.minNumeric > 0) passwd += generateId({ pattern: cfg.pattern.numeric, length: cfg.minNumeric })
return passwd
}
pushDownload = async ({ description, worker, data, source, req, file, type }) => {
const { createRecord } = this.app.getPlugin('waibuDb')
const { push } = this.app.getPlugin('bajoQueue')
description = description ?? file
const jobQueue = {
worker,
source,
payload: {
type: 'object',
data
}
}
if (!type) type = path.extname(file)
if (type[0] === '.') type = type.slice(1)
const body = { file, description, jobQueue, type }
const rec = await createRecord({ model: 'SumbaDownload', body, req, options: { noFlash: true } })
jobQueue.payload.data.download = { id: rec.data.id, file }
await push(jobQueue)
}
/**
* Get API key from user ID by hashing the user's salt
*
* @async
* @method
* @param {string|number} id - The user ID.
* @returns {Promise<string>} - A promise that resolves to the API key.
*/
getApiKeyFromUserId = async id => {
const options = { forceNoHidden: true, noHook: true, noCache: true, attachment: true, mimeType: true }
const resp = await getModel('SumbaUser').getRecord(id, options)
return await this.hash(resp.salt)
}
/**
* Get a list of countries with their values and names
*
* @async
* @method
* @returns {Promise<Array<{ value: string|number, text: string }>>} - A promise that resolves to an array of country values and names.
*/
getCountriesValues = async () => {
const { getModel } = this.app.dobo
const model = getModel('CdbCountry')
const items = await model.findAllRecord({}, { noMagic: true, dataOnly: true })
return items.map(item => ({ value: item.id, text: item.name }))
}
/**
* Method to send mail through Masohi Messaging System. It is a thin wrapper
* for {@link https://github.com/ardhi/masohi-mail|masohi-mail} send method.
*
* If both masohiMail and waibu are not loaded, nothing is delivered.
*
* @method
* @async
* @param {(string|Array)} tpl - Mail's template to use. If a string is given, the same template will be used for html & plaintext versions. Otherwise, the first template will be used for html mail, and the second one is for it's plaintext version
* @param {Object} [params={}] - {@link https://github.com/ardhi/masohi-mail|masohi-mail}'s params object.
*/
sendMail = async (tpl, { payload = {}, conn, source, options = {} } = {}) => {
if (!this.app.masohiMail || !this.app.waibu) return
conn = conn ?? 'masohiMail:default'
const { importModule } = this.app.bajo
const { get, isString } = this.app.lib._
const { generateId } = this.app.lib.aneka
const { render } = this.app.bajoTemplate
const buildLocals = await importModule('waibu:/lib/build-locals.js')
if (isString(tpl)) tpl = [tpl]
options.partial = false
const locals = await buildLocals.call(this, { params: payload.data, opts: options })
payload.from = payload.from ?? get(options, 'req.site.email', payload.from)
const opts = {
lang: get(options, 'req.lang'),
groupId: get(options, 'req.id', generateId())
}
payload.html = await render(tpl[0], locals, opts)
if (tpl[1]) payload.text = await render(tpl[1], locals, opts)
await this.app.masohiMail.send({ payload, source: source ?? this.ns, conn })
}
/**
* Reset token by generating a new salt and hashing it twice to create a new token.
*
* @async
* @method
* @param {string} [text] - Optional text to use as the salt. If not provided, a new salt will be generated.
* @returns {Promise<{ salt: string, token: string }>} - A promise that resolves to an object containing the salt and token.
*/
resetToken = async (text) => {
const { generateId } = this.app.lib.aneka
const salt = text ?? generateId()
const token = await this.hash(await this.hash(salt))
return { salt, token }
}
/**
* Generate a password validation rule based on the configuration settings.
*
* @async
* @method
* @param {object} req - The request object.
* @returns {object} - The Joi password validation rule.
*/
passwordRule = async (req = {}) => {
const { get } = this.app.lib._
const { importPkg } = this.app.bajo
const joi = await importPkg('dobo:joi')
const joiPassword = joi.extend(joiPasswordExtendCore)
let password = joiPassword
.string()
.min(8)
.max(100)
.required()
const cfg = get(req, 'site.setting.sumba.userPassword', this.config.siteSetting.userPassword)
if (cfg.minUppeCase) password = password.minOfUppercase(cfg.minUpperCase)
if (cfg.minLowercase) password = password.minOfLowercase(cfg.minLowerCase)
if (cfg.minSpecialChar) password = password.minOfSpecialCharacters(cfg.minSpecialChar)
if (cfg.minNumeric) password = password.minOfNumeric(cfg.minNumeric)
if (cfg.noWhitespace) password = password.noWhiteSpaces()
if (cfg.latinOnlyChars) password = password.onlyLatinCharacters()
return password
}
/**
* Hash the given item using the specified algorithm from the configuration.
*
* @async
* @method
* @param {*} item - The item to be hashed.
* @returns {Promise<string>} - A promise that resolves to the hashed value.
*/
hash = async (item) => {
const { hash } = this.app.bajoExtra
const setting = this._getAuthSetting('apiKey')
return await hash(item, setting.algo)
}
/**
* Fetch guards of the specified type from the database.
*
* @async
* @method
* @private
* @param {string} type - The type of guards to fetch. Available types are 'Action', 'Attrib', 'Query', 'Secure', and 'Anonymous'.
* @returns {Promise<Array>} - A promise that resolves to an array of guards.
*/
_fetchGuards = async (type) => {
const { getModel } = this.app.dobo
const options = { noMagic: true, noCache: true, noDriverHook: true, dataOnly: true }
const filter = { query: { status: 'ACTIVE' } }
const results = await getModel(`Sumba${type}Guard`).findAllRecord(filter, options)
return results.map(result => {
result.teamIds = result.teamIds.map(item => item + '')
return result
})
}
/**
* Process and order the guards based on their path and weight.
*
* @method
* @private
* @param {Array} inputs - The array of guard inputs.
* @returns {Array} - The processed and ordered array of guards.
*/
_getGuards = (inputs = []) => {
const { routePath } = this.app.waibu
const { orderBy } = this.app.lib._
const normal = orderBy(inputs.filter(input => {
try {
input.path = routePath(input.path)
} catch (err) {
return false
}
return input.path[0] !== '!'
}), ['weight', 'path'], ['desc', 'asc'])
const inverse = orderBy(inputs.filter(input => {
try {
input.path = routePath(input.path)
} catch (err) {
return false
}
return input.path[0] === '!'
}), ['weight', 'path'], ['desc', 'asc'])
return [...normal, ...inverse]
}
/**
* Get anonymous guards, optionally reloading them from the database.
*
* @async
* @method
* @param {boolean} [reread] - Whether to reload the guards from the database.
* @returns {Promise<Array>} - A promise that resolves to an array of anonymous guards.
*/
getAnonymousGuards = async (reread) => {
if (!reread) return this.anonymousGuards
const guards = await this._fetchGuards('Anonymous')
this.anonymousGuards = this._getGuards(guards)
return this.anonymousGuards
}
/**
* Get secure guards, optionally reloading them from the database.
*
* @async
* @method
* @param {boolean} [reread] - Whether to reload the guards from the database.
* @returns {Promise<Array>} - A promise that resolves to an array of secure guards.
*/
getSecureGuards = async (reread) => {
if (!reread) return this.secureGuards
const guards = await this._fetchGuards('Secure')
this.secureGuards = this._getGuards(guards)
return this.secureGuards
}
/**
* Get query guards, optionally reloading them from the database.
*
* @async
* @method
* @param {boolean} [reread] - Whether to reload the guards from the database.
* @returns {Promise<Array>} - A promise that resolves to an array of query guards.
*/
getQueryGuards = async (reread) => {
if (!reread) return this.queryGuards
this.queryGuards = await this._fetchGuards('Query')
return this.queryGuards
}
/**
* Get action guards, optionally reloading them from the database.
*
* @async
* @method
* @param {boolean} [reread] - Whether to reload the guards from the database.
* @returns {Promise<Array>} - A promise that resolves to an array of action guards.
*/
getActionGuards = async (reread) => {
if (!reread) return this.actionGuards
this.actionGuards = await this._fetchGuards('Action')
return this.actionGuards
}
/**
* Get attribute guards, optionally reloading them from the database.
*
* @async
* @method
* @param {boolean} [reread] - Whether to reload the guards from the database.
* @returns {Promise<Array>} - A promise that resolves to an array of attribute guards.
*/
getAttribGuards = async (reread) => {
if (!reread) return this.attribGuards
this.attribGuards = await this._fetchGuards('Attrib')
return this.attribGuards
}
/**
* Get all loaded model names, optionally returning them as value-text pairs.
*
* @method
* @param {boolean} [asValue] - Whether to return the model names as value-text pairs.
* @returns {Array} - An array of model names or value-text pairs.
*/
getModelNames = (asValue) => {
const values = this.app.dobo.models.map(item => item.name).sort()
return asValue ? values.map(item => ({ value: item, text: item })) : values
}
/**
* Gather all paths to check for a request.
*
* @method
* @param {Object} req - The request object.
* @returns {Array} - An array of paths to check.
*/
pathsToCheck = (req) => {
const { isSet } = this.app.lib.aneka
const { uniq, without } = this.app.lib._
const items = [req.routeOptions.url, req.url].filter(url => isSet(url)).map(url => url.split('?')[0].split('#')[0])
return uniq(without(items, undefined, null))
}
/**
* Check and set the iconset for a request.
*
* @async
* @method
* @param {Object} req - The request object.
* @param {Object} reply - The reply object.
* @returns {Promise<void>} - A promise that resolves when the iconset is checked and set.
*/
checkIconset = async (req, reply) => {
const { get, isString } = this.app.lib._
const mpa = this.app.waibuMpa
if (!req.site) return
const siteIconset = get(req, 'site.setting.waibuMpa.iconset')
req.iconset = siteIconset ?? get(mpa, 'config.iconset.set', 'default')
const hiconset = req.headers['x-iconset']
if (isString(hiconset) && mpa.getIconset(hiconset)) req.iconset = hiconset
req.iconset = req.iconset ?? 'default'
}
/**
* Check and set the theme for a request.
*
* @async
* @method
* @param {Object} req - The request object.
* @param {Object} reply - The reply object.
* @returns {Promise<void>} - A promise that resolves when the theme is checked and set.
*/
checkTheme = async (req, reply) => {
const { get, isString } = this.app.lib._
const mpa = this.app.waibuMpa
if (!req.site) return
const siteTheme = get(req, 'site.setting.waibuMpa.theme')
req.theme = siteTheme ?? get(mpa, 'config.theme.set', 'default')
const htheme = req.headers['x-theme']
if (isString(htheme) && mpa.getTheme(htheme)) req.theme = htheme
req.theme = req.theme ?? 'default'
}
/**
* Check and set the team for a request.
*
* @async
* @method
* @param {Object} req - The request object.
* @param {Object} reply - The reply object.
* @returns {Promise<void>} - A promise that resolves when the team is checked and set.
*/
checkTeam = async (req, reply) => {
const { includes } = this.app.lib.aneka
const { outmatch } = this.app.lib
if (req.user.isAdmin) return
if (req.routeOptions.config.xSite && req.user.isXSiteAdmin) return
const teamIds = req.user.teams.map(item => item.id + '')
// if (req.user.teams.map(item => item.alias).length === 0) throw this.error('accessDenied', { statusCode: 403 })
const paths = this.pathsToCheck(req)
const results = (await this.getSecureGuards()).filter(item => {
if (item.siteId !== req.site.id + '' || item.path[0] === '!') return false
return paths.some(outmatch([item.path]))
})
for (const result of results) {
if (result.allTeams) continue
if (!includes(teamIds, result.teamIds)) throw this.error('accessDenied', { statusCode: 403 })
}
// passed
}
/**
* Check and set the user for a request.
*
* @async
* @method
* @param {Object} req - The request object.
* @param {Object} reply - The reply object.
* @param {Object} source - The source object.
* @returns {Promise<boolean|void>} - A promise that resolves to a boolean indicating the result of the check or void.
*/
checkUser = async (req, reply, source) => {
const { merge, isEmpty, camelCase, get } = this.app.lib._
const { routePath } = this.app.waibu
const userId = get(req, 'session.userId')
const setUser = async () => {
if (!userId) return
try {
const user = await this.getUserById(userId, req)
if (user) req.user = user
else req.session.userId = null
} catch (err) {
console.log(err)
req.session.userId = null
}
}
if (req.session) req.session.siteId = req.site.id
const webApp = get(req, 'routeOptions.config.webApp', 'waibu')
if (!req.routeOptions.url) {
if (!req.session) return
await setUser()
return
}
const paths = this.pathsToCheck(req)
let guards = (await this.getAnonymousGuards()).filter(item => item.siteId === req.site.id + '')
const anonymous = this.checkRouteGuard(guards, paths)
if (anonymous) {
if (!userId) return false
req.session.ref = req.url
return reply.redirectTo(routePath(this.config.redirect.signout))
}
guards = (await this.getSecureGuards()).filter(item => item.siteId === req.site.id + '')
const secure = this.checkRouteGuard(guards, paths)
if (!secure) {
if (userId) await setUser()
return false // regular, unguarded path. Not secure & not anonymous path
}
if (userId) {
await setUser()
return secure
}
const { silentOnError, methods } = this._getAuthSetting(null, webApp)
const payload = silentOnError ? { noContent: true } : undefined
if (isEmpty(methods)) throw this.error('noAuthMethod', merge({ statusCode: 500 }, payload))
let success
for (const m of methods) {
const handler = this[camelCase(`verify ${m}`)]
if (!handler) throw this.error('invalidAuthMethod%s', m, merge({ statusCode: 500 }, payload))
const check = await handler(req, reply, source, payload)
if (check) {
success = check
break
}
}
if (!success) throw this.error('accessDeniedNoAuth', merge({ statusCode: 403 }, payload))
return secure
}
/**
* Check and set the Cross-site settings for a request.
*
* @async
* @method
* @param {Object} req - The request object.
* @param {Object} reply - The reply object.
* @returns {Promise<void>} - A promise that resolves when the XSite is checked and set.
*/
checkXSite = async (req, reply) => {
const { get } = this.app.lib._
if (!this.config.multiSite.enabled) return
const config = get(req, 'routeOptions.config')
if (!get(config, 'xSite') || ['/dashboard'].includes(config.pathSrc)) return
if (!get(req, 'user.isXSiteAdmin')) throw this.error('accessDenied', { statusCode: 403 })
}
/**
* Check the route for a request.
*
* @async
* @method
* @param {Object} req - The request object.
* @returns {Promise<void>} - A promise that resolves when the route is checked.
*/
checkRoute = async (req) => {
const { routePath } = this.app.waibu
const { outmatch } = this.app.lib
const routes = req.getSetting('waibu:route.disabled', []).map(item => routePath(item, false))
if (routes.length === 0) return
const isMatch = outmatch(routes)
const paths = this.pathsToCheck(req)
if (paths.find(isMatch)) throw this.error('_notFound')
}
/**
* Parse namespace settings for a request.
*
* @method
* @param {string} ns - The namespace.
* @param {Object} setting - The setting object.
* @param {Array} items - The items to parse.
*/
parseNsSettings = (ns, setting, items) => {
const { trim, set, isPlainObject, isArray, isEmpty, find } = this.app.lib._
const { parseObject, dayjs } = this.app.lib
for (const item of items) {
if (item.ns === '_var' || ns === '_var') continue
let value = trim([item.value] ?? '')
if (value[0] === '#' && value[value.length - 1] === '#') {
const val = value.slice(1, -1)
const newValue = find(items, { ns: '_var', key: val })
if (newValue) value = newValue.value
}
if (['[', '{'].includes(value[0]) && [']', '}'].includes(value[value.length - 1])) {
try {
value = parseObject(JSON.parse(value))
} catch (err) {}
} else if (Number(value)) value = Number(value)
else if (['true', 'false'].includes(value)) value = value === 'true'
else {
const dt = dayjs(value)
if (dt.isValid()) value = dt.toDate()
}
if ((isPlainObject(value) || isArray(value)) && isEmpty(value)) continue
set(setting, `${ns}.${item.key}`, value)
}
}
/**
* Get all fixtures for the specified alias, replacing any occurrences of '{alias}' in the fixture data with the provided alias.
*
* @async
* @method
* @param {string} [alias] - The alias for the fixtures.
* @returns {Promise<object>} - A promise that resolves to the fixtures data.
*/
getAllFixtures = async (alias) => {
const { readConfig } = this.app.bajo
const { isEmpty, omit, kebabCase, isString } = this.app.lib._
const { getModel } = this.app.dobo
const { fastGlob } = this.app.lib
const data = {}
function replaceAlias (alias, items) {
for (const item of items) {
for (const key in item) {
const val = item[key]
if (isString(val)) item[key] = val.replaceAll('{alias}', alias)
}
}
}
const formats = this.app.configHandlers.map(item => item.ext.slice(1))
const overrideBase = `${this.app.getPluginDataDir('sumba')}/create-new-site-fixtures/${alias}`
const models = this.app.dobo.models.filter(m => {
const prop = m.properties.find(p => p.name === 'siteId')
return !!prop
}).map(m => m.name)
models.unshift('SumbaSite')
const files = (await fastGlob(`${overrideBase}/*.{${formats.join(',')}}`)).map(file => {
const ext = path.extname(file)
return file.slice(0, file.length - ext.length)
})
for (const m of models) {
const model = getModel(m)
const file = `${overrideBase}/${kebabCase(m)}`
let fixtures = (await model.loadFixtures({ collectItems: true, noLookup: true })) ?? []
if (files.includes(file)) {
const items = await readConfig(file, { defValue: [] })
if (!isEmpty(items)) fixtures = items
}
if (!Array.isArray(fixtures)) fixtures = [fixtures]
if (fixtures.length === 0) continue
replaceAlias(alias, fixtures)
if (isEmpty(alias)) throw this.error('aliasRequired%s', alias)
const omitted = ['_attachments', 'siteId']
for (const item of ['createdAt', 'updatedAt', 'immutable']) {
const props = model.properties.filter(prop => prop.feature === ('dobo:' + item)).map(prop => prop.name)
if (props.length > 0) omitted.push(...props)
}
for (const idx in fixtures) {
fixtures[idx] = omit(fixtures[idx], omitted)
}
data[m] = m === 'SumbaSite' ? fixtures[0] : fixtures
}
return data
}
/**
* Create references for the specified site using the provided data, options, and verbosity settings.
*
* @async
* @method
* @param {object} site - The site object for which to create references.
* @param {object} data - The data object containing fixtures for the site.
* @param {object} options - Options to pass to the createRecord method.
* @param {boolean} verbose - Whether to log verbose output.
*/
createRefs = async (site, data, options, verbose) => {
const { isString } = this.app.lib._
const { getModel } = this.app.dobo
for (const m in data) {
if (m === 'SumbaSite') continue
const mdl = getModel(m)
const fixtures = data[m]
for (const f of fixtures) {
f.siteId = site.id + ''
const lv = { siteId: f.siteId }
for (const key in f) {
const val = f[key]
if (isString(val) && val.slice(0, 2) === '?:') f[key] = await mdl._simpleLookup(val.slice(2), lv, options)
}
await mdl.createRecord(f, { ...options, noFlash: true })
}
if (verbose) this.print.succeed('writingModel%s%s', mdl.name, fixtures.length)
}
if (verbose) this.print.succeed('populateRouteGuard')
await this.populateRouteGuards([site])
}
/**
* Create a new site with the specified alias and optional hostname, using fixtures for initial data.
*
* @async
* @method
* @param {string} alias - The alias for the new site.
* @param {string} [hostname] - The hostname for the new site.
* @param {boolean} [verbose=false] - Whether to log verbose output.
*/
createNewSite = async (alias, hostname, verbose) => {
const { isEmpty } = this.app.lib._
const { getModel } = this.app.dobo
if (isEmpty(alias)) throw this.error('aliasRequired%s', alias)
let spin
if (verbose) spin = this.print.spinner().start('processing...')
const query = { $or: [{ alias }] }
if (!isEmpty(hostname)) query.$or.push({ hostname })
const model = getModel('SumbaSite')
const site = await model.findOneRecord({ query })
if (!isEmpty(site)) {
if (verbose) spin.stop()
throw this.error('aliasOrHostnameExists%s%s', alias, hostname ?? '')
}
// lets go
const fixtures = await this.getAllFixtures(alias)
if (verbose) spin.stop()
fixtures.SumbaSite.alias = alias
if (!isEmpty(hostname)) fixtures.SumbaSite.hostname = hostname
await model.transaction(async (trx) => {
const newSite = await model.createRecord(fixtures.SumbaSite, { trx, noHook: true })
if (verbose) this.print.succeed('writingModel%s%s', model.name, 1)
const options = { trx }
await this.createRefs(newSite, fixtures, options, verbose)
})
if (verbose) this.print.info('done')
}
/**
* Remove references for the specified site using the provided options and verbosity settings.
*
* @async
* @method
* @param {object} site - The site object for which to remove references.
* @param {object} options - Options to pass to the removeRecord method.
* @param {boolean} verbose - Whether to log verbose output.
*/
removeRefs = async (site, options, verbose) => {
const { orderBy } = this.app.lib._
const { getModel } = this.app.dobo
const models = orderBy(this.app.dobo.models, ['buildLevel'], ['desc']).filter(m => {
const prop = m.properties.find(p => p.name === 'siteId')
return !!prop
}).map(m => m.name)
for (const m of models) {
const mdl = getModel(m)
const rows = await mdl.findAllRecord({ query: { siteId: site.id + '' } }, { ...options, dataOnly: true })
const ids = rows.map(item => item.id)
// TODO: backup
if (ids.length === 0) continue
for (const id of ids) {
await mdl.removeRecord(id, { noReturn: true, ...options, noFlash: true })
}
if (verbose) this.print.succeed('removedFrom%s%s', mdl.name, ids.length)
}
}
/**
* Remove a site with the specified alias, along with its references, using the provided verbosity settings.
*
* @async
* @method
* @param {string} alias - The alias of the site to remove.
* @param {boolean} [verbose=false] - Whether to log verbose output.
*/
removeSite = async (alias, verbose) => {
const { isEmpty } = this.app.lib._
const { getModel } = this.app.dobo
if (isEmpty(alias)) throw this.error('aliasRequired%s', alias)
const model = getModel('SumbaSite')
const query = { alias }
const site = await model.findOneRecord({ query })
if (isEmpty(site)) throw this.error('aliasNotFound%s', alias)
await model.transaction(async (trx) => {
const options = { trx, noHook: true }
await this.removeRefs(site, options, verbose)
await model.removeRecord(site.id, { noReturn: true, trx, noHook: true })
if (verbose) this.print.succeed('removedFrom%s%s', model.name, 1)
})
if (verbose) this.print.info('done')
}
/**
* Get site information based on the provided input, which can be either a hostname or an ID. If multi-site is enabled, it will fetch the site based on the hostname or ID; otherwise, it will return the default site.
*
* @async
* @method
* @param {string|number} input - The input value, which can be either a hostname or an ID.
* @param {boolean} [byId=false] - Whether to fetch the site by ID.
* @returns {object} The site information.
*/
getSite = async (input, byId = false) => {
const { omit } = this.app.lib._
const { set: setCache, get: getCache } = this.app.bajoCache ?? {}
const omitted = ['status']
let site = {}
const mergeSetting = async (site) => {
const { defaultsDeep, isSet } = this.app.lib.aneka
const { get, filter } = this.app.lib._
const defSetting = {}
const nsSetting = {}
const names = this.app.getAllNs()
const query = {
ns: { $in: names },
siteId: site.id + ''
}
const all = await this.app.dobo.getModel('SumbaSiteSetting').findAllRecord({ query })
for (const ns of names) {
const item = get(this, `app.${ns}.config.siteSetting`)
if (isSet(item)) defSetting[ns] = item
const items = filter(all, { ns })
this.parseNsSettings(ns, nsSetting, items)
}
site.setting = defaultsDeep({}, nsSetting, defSetting)
// additional fields
const country = await this.app.dobo.getModel('CdbCountry').getRecord(site.country, { noMagic: true })
site.countryName = (country ?? {}).name ?? site.country
}
if (!this.config.multiSite.enabled) {
const filter = { query: { alias: 'default' } }
const key = 'dobo|SumbaSite|getSite|default'
if (getCache) {
site = await getCache({ key })
if (site) return site
}
const resp = await this.app.dobo.getModel('SumbaSite').findOneRecord(filter, { noHook: true })
site = omit(resp, omitted)
await mergeSetting(site)
if (setCache) {
await setCache({ key, value: site, ttl: this.config.cacheTtl.getSiteDur })
}
return site
}
let query
if (byId) query = { id: input }
else query = { hostname: input }
const key = `dobo|SumbaSite|getSite|multiSite|${input}`
if (getCache) {
site = await getCache({ key })
if (site) return JSON.parse(site)
}
let row = await this.app.dobo.getModel('SumbaSite').findOneRecord({ query }, { noHook: true })
if (!row) {
if (this.config.multiSite.catchAll) {
query = { alias: this.config.multiSite.catchAll === true ? 'default' : this.config.multiSite.catchAll }
row = await this.app.dobo.getModel('SumbaSite').findOneRecord({ query }, { noHook: true })
}
if (!row) throw this.error('unknownSite')
}
if (row.status !== 'ACTIVE') throw this.error('siteInactiveInfo')
site = omit(row, omitted)
await mergeSetting(site)
if (setCache) {
await setCache({ key, value: JSON.stringify(site), ttl: this.config.cacheTtl.getSiteDur })
}
return site
}
/**
* Merge team information into the user object.
*
* @async
* @method
* @param {object} user - The user object to merge team information into.
* @param {object} req - The request object.
*/
mergeTeam = async (user, req) => {
const { map, pick } = this.app.lib._
const { getModel } = this.app.dobo
user.teams = []
const query = { userId: user.id + '', siteId: user.siteId, status: 'ACTIVE' }
let mdl = getModel('SumbaTeamUser')
const userTeam = await mdl.findAllRecord({ query })
if (userTeam.length === 0) return
delete query.userId
const idProp = mdl.getProperty('id')
const $in = userTeam.map(item => ['integer', 'smallint'].includes(idProp.type) ? parseInt(item.teamId) : item.teamId)
query.id = { $in }
mdl = getModel('SumbaTeam')
const teams = await mdl.findAllRecord({ query })
if (teams.length > 0) {
delete query.id
delete query.status
query.teamId = { $in: teams.map(t => t.id + '') }
mdl = getModel('SumbaTeamSetting')
const items = await mdl.findAllRecord({ query })
for (const team of teams) {
const names = map(items, 'ns')
const item = pick(team, ['id', 'alias'])
item.setting = {}
for (const ns of names) {
this.parseNsSettings(ns, item.setting, items.filter(s => s.teamId === (team.id + '')))
}
user.teams.push(item)
}
}
user.isXSiteAdmin = this.config.xSiteAdmins.includes(`${req.site.alias}:${user.username}`)
user.isAdmin = user.teams.some(t => t.alias === 'administrator')
for (const field of this.unsafeUserFields) {
delete user[field]
}
}
/**
* Get a user by their ID.
*
* @async
* @method
* @param {string|number} id - The ID of the user to retrieve.
* @param {object} req - The request object.
* @returns {object|null} The user object if found, otherwise null.
*/
getUserById = async (id, req) => {
const { getModel } = this.app.dobo
const { set: setCache, get: getCache } = this.app.bajoCache ?? {}
const key = `dobo|SumbaUser|getUserById|${id}`
let user
if (getCache) {
user = await getCache({ key })
if (user) return JSON.parse(user)
}
user = await getModel('SumbaUser').getRecord(id, { noHook: true, noDriverHook: true, throwNotFound: false, req })
if (!user) return
await this.mergeTeam(user, req)
if (setCache) {
await setCache({ key, value: JSON.stringify(user), ttl: this.config.cacheTtl.getUserByIdDur })
}
return user
}
/**
* Get a user by their token.
*
* @async
* @method
* @param {string} token - The token of the user to retrieve.
* @param {object} req - The request object.
* @returns {object|null} The user object if found, otherwise null.
*/
getUserByToken = async (token, req) => {
const { getModel } = this.app.dobo
const { set: setCache, get: getCache } = this.app.bajoCache ?? {}
const key = `dobo|SumbaUser|getUserByToken|${token}`
let user
if (getCache) {
user = await getCache({ key })
if (user) return JSON.parse(user)
}
user = await getModel('SumbaUser').findOneRecord({ query: { token } }, { noHook: true, noDriverHook: true, throwNotFound: false, req })
if (!user) return
await this.mergeTeam(user, req)
if (setCache) {
await setCache({ key, value: JSON.stringify(user), ttl: this.config.cacheTtl.getUserByTokenDur })
}
return user
}
getUserByUsernamePassword = async (username = '', password = '', req) => {
const { getModel } = this.app.dobo
const { importPkg } = this.app.bajo
const model = getModel('SumbaUser')
await model.validate({ username, password }, null, { partial: true, ns: ['sumba', 'dobo'], fields: ['username', 'password'] })
const bcrypt = await importPkg('bajoExtra:bcrypt')
const query = { username, provider: 'local', siteId: req.site.id + '' }
const user = await model.findOneRecord({ query }, { req, forceNoHidden: true, noHook: true, noDriverHook: true })
if (!user) throw this.error('validationError', { details: [{ field: 'username', error: 'Unknown username' }], statusCode: 401 })
if (user.status !== 'ACTIVE') throw this.error('validationError', { details: ['User is inactive or temporarily disabled'], statusCode: 401 })
const verified = await bcrypt.compare(password, user.password)
if (!verified) throw this.error('validationError', { details: [{ field: 'password', error: 'invalidPassword' }], statusCode: 401 })
await this.mergeTeam(user, req)
return user
}
/**
* Clear the cache for a specific site.
*
* @async
* @method
* @param {string|number} id - The ID of the site.
* @param {object} result - The result object containing site data.
*/
clearCacheSite = async (id, result) => {
if (!this.app.bajoCache) return
const { clear } = this.app.bajoCache ?? {}
const { get } = this.app.lib._
await clear({ key: 'dobo|SumbaSite|getSite|default' })
await clear({ key: `dobo|SumbaSite|getSite|multiSite|${id}` })
await clear({ key: `dobo|SumbaSite|getSite|multiSite|${get(result, 'data.hostname', get(result, 'oldData.hostname'))}` })
}
/**
* Clear the cache for a specific user.
*
* @async
* @method
* @param {string|number} id - The ID of the user.
* @param {object} result - The result object containing user data.
*/
clearCacheUser = async (id, result) => {
if (!this.app.bajoCache) return
const { clear } = this.app.bajoCache ?? {}
const { get } = this.app.lib._
const token = get(result, 'data.token', get(result, 'oldData.token', ''))
await clear({ key: `dobo|SumbaUser|getUserById|${id}` })
await clear({ key: `dobo|SumbaUser|getUserByToken|${token}` })
}
/**
* Filter model data based on settings.
*
* @method
* @param {object} opts - The options object.
* @param {object} opts.model - The model object.
* @param {string} opts.field - The field to filter.
* @param {object} [opts.options={}] - Additional options.
* @returns {object} The filtered results and operation values.
*/
filterModelFromSetting = (opts = {}) => {
const { model, field, options = {} } = opts
const { isEmpty, isArray, set } = this.app.lib._
const { isSet } = this.app.lib.aneka
const { req } = options
const opValue = req.getSetting(`sumba:queryGuard.${field}`, {})
const results = []
const prop = model.getProperty(field)
for (const op of ['in', 'nin', 'inOrEmpty']) {
if (isEmpty(opValue[op]) || !isArray(opValue[op])) continue
if (op === 'inOrEmpty') {
const val = set({}, field, set({}, '$in', opValue[op]))
const nl = set({}, field, { $eq: null })
const item = { $or: [val, nl] }
let v
if (['string', 'text'].includes(prop.type)) v = ''
else if (['integer', 'smallint', 'float', 'double'].includes(prop.type)) v = 0
if (isSet(v)) {
const bl = set({}, field, { $eq: v })
item.$or.push(bl)
}
results.push(item)
} else results.push(set({}, field, set({}, '$' + op, opValue[op])))
}
return { results, opValue }
}
/**
* Apply query guards to the provided query.
*
* @async
* @method
* @param {object} [opts={}] - The options object.
* @param {object} opts.model - The model object.
* @param {object} [opts.q={}] - The query object.
* @param {array} [opts.teamIds=[]] - The team IDs.
* @param {object} [opts.options={}] - Additional options.
*/
runQueryGuard = async (opts = {}) => {
const { model, q = {}, teamIds = [], options = {} } = opts
const { set, orderBy, isEmpty, isArray } = this.app.lib._
const { includes } = this.app.lib.aneka
const { sanitizeByType } = this.app.dobo
const { req } = options
const results = []
const guards = await this.getQueryGuards()
const fields = model.getNonVirtualProperties().map(prop => prop.name)
const filterFn = item => {
const bySiteId = item.siteId === req.site.id + ''
const byModel = item.models.includes(model.name)
const byFields = fields.includes(item.field)
return bySiteId && byModel && byFields
}
const rules = orderBy(guards.filter(filterFn), ['field'])
for (const field of fields) {
if (!model.getNonVirtualProperties(true).includes(field)) continue // or, should it throws exception instead?
const inSetting = this.filterModelFromSetting({ model, field, options })
const prop = model.getProperty(field)
const items = rules.filter(item => item.field === field)
for (const item of items) {
let values = item.value.map(val => {
return sanitizeByType(val, prop.type, { strict: true, inputFormat: 'string', model: model.name })
})
const op = item.condition.toLowerCase()
if (['in', 'nin'].includes(op) && !isEmpty(inSetting.opValue[op]) && isArray(inSetting.opValue[op])) values = values.filter(val => inSetting.opValue[op].includes(val))
if (isEmpty(values)) continue
let value
if (['in', 'nin'].includes(op)) value = set({}, '$' + op, values)
else if (op === 'between') value = { $gte: values[0], $lte: values[1] }
else value = set({}, '$' + op, values[0])
const teamOk = item.allTeams ? true : (item.teamIds.length === 0 ? false : includes(item.teamIds, teamIds))
if (!teamOk) throw this.error('_abortAction')
results.push(set({}, field, value))
}
}
q.$and.push(...results)
}
/**
* Apply attribute guards to the provided model.
*
* @async
* @method
* @param {object} [opts={}] - The options object.
* @param {object} opts.model - The model object.
* @param {array} [opts.teamIds=[]] - The team IDs.
* @param {object} [opts.options={}] - Additional options.
*/
runAttribGuard = async (opts = {}) => {
const { model, teamIds = [], options = {} } = opts
const { uniq } = this.app.lib._
const { includes } = this.app.lib.aneka
const { req } = options
const results = []
const guards = await this.getAttribGuards()
const filterFn = item => {
const bySiteId = item.siteId === req.site.id + ''
const byModel = item.models.includes(model.name)
const byTeamId = item.allTeams ? true : includes(item.teamIds, teamIds)
return bySiteId && byModel && byTeamId
}
guards.filter(filterFn).forEach(item => results.push(...item.hiddenFields))
options.hidden = options.hidden ?? []
options.hidden.push(...results)
options.hidden = uniq(options.hidden)
}
/**
* Get the team IDs and aliases for the user in the request.
*
* @method
* @param {object} req - The request object.
* @returns {object} An object containing the team IDs and aliases.
*/
getTeamIdsAndAliases = (req) => {
const { get } = this.app.lib._
const teams = get(req, 'user.teams', [])
const teamIds = teams.map(team => team.id + '')
const aliases = teams.map(team => team.alias)
return { teamIds, aliases }
}
/**
* Rebuild the filter for the provided model.
*
* @async
* @method
* @param {object} model - The model object.
* @param {object} [filter={}] - The filter object.
* @param {object} [options={}] - Additional options.
* @returns {Promise<object>} The rebuilt filter.
*/
rebuildFilter = async (model, filter = {}, options = {}) => {
const { isEmpty, get, keys } = this.app.lib._
const { req } = options
const allowEmpty = !get(this, `app.${model.ns}.config.sumba.noEmptyQuery`, []).includes(model.name)
const hasSiteId = model.hasProperty('siteId')
const hasUserId = model.hasProperty('userId')
const hasTeamId = model.hasProperty('teamId')
const { teamIds, aliases } = this.getTeamIdsAndAliases(req)
const q = { $and: [] }
filter.query = filter.query ?? {}
if (!isEmpty(filter.query)) {
if (filter.query.$and) q.$and.push(...filter.query.$and)
else q.$and.push(filter.query)
}
if (req.routeOptions.config.xSite) {
filter.query = q
return
}
if (hasSiteId) q.$and.push({ siteId: req.site.id + '' })
if (aliases.includes('administrator')) {
if (get(req, 'site.alias') === 'default') {
filter.query = q
return
}
const fields = keys(req.getSetting('sumba:queryGuard', {}))
const results = []
for (const field of fields) {
if (!model.hasProperty(field)) continue
const inSetting = this.filterModelFromSetting({ model, field, options })
if (inSetting.results.length > 0) results.push(...inSetting.results)
}
if (results.length > 0) q.$and.push(...results)
filter.query = q
return
}
if (isEmpty(req.user)) {
if (q.$and.length === 0 && !allowEmpty) throw this.error('_emptyColumnQuery')
filter.query = q
return
}
const condUserId = {
$or: [
{ userId: req.user.id + '' },
{ userId: { $eq: null } }
]
}
const condTeamId = {
$or: [
{ teamId: { $in: teamIds } },
{ teamId: { $eq: null } }
]
}
if (hasTeamId) {
if (hasUserId) q.$and.push({ $or: [condTeamId, condUserId] })
else q.$and.push(condTeamId)
} else if (hasUserId) q.$and.push(condUserId)
await this.runQueryGuard({ model, q, teamIds, options, allowEmpty })
await this.runAttribGuard({ model, teamIds, options })
if (q.$and.length === 0 && !allowEmpty) throw this.error('_emptyColumnQuery')
filter.query = q
}
/**
* Apply query hook to the provided model.
*
* @async
* @method
* @param {object} model - The model object.
* @param {string|number} id - The ID of the model.
* @param {object} options - Additional options.
* @returns {object} The result of the model hook.
*/
applyQueryHook = async (model, id, options = {}) => {
const { isEmpty } = this.app.lib._
const { req = {} } = options
if (options.noAutoFilter || isEmpty(req) || isEmpty(req.site)) return
const filter = {}
await this.rebuildFilter(model, filter, options)
if (filter.query.$and) filter.query.$and.push({ id })
else filter.query.id = id
const row = await model.findOneRecord(filter, { count: false })
if (!row) throw this.app.dobo.error('recordNotFound%s%s', id, this.name, { statusCode: 404 })
}
/**
* Apply action hook to the provided model.
*
* @async
* @method
* @param {object} [opts={}] - The options object.
* @param {object} opts.model - The model object.
* @param {array} [opts.teamIds=[]] - The team IDs.
* @param {object} [opts.options={}] - Additional options.
*/
applyActionHook = async (opts = {}) => {
const { model, options = {} } = opts
const { req } = options
if (!req) return
if (!req.site) return
let action
if (['findOneRecord', 'findRecord', 'countRecord', 'getRecord'].includes(options.action)) action = 'READ'
else if (['createRecord', 'bulkCreateRecord'].includes(options.action)) action = 'CREATE'
else if (['updateRecord', 'upsertRecord'].includes(options.action)) action = 'UPDATE'
else if (['removeRecord'].includes(options.action)) action = 'REMOVE'
else if (['findAllRecord'].includes(options.action)) action = 'EXPORT'
if (!action) return
const allowed = await this.isActionAllowed(action, model, req)
if (!allowed) throw this.error('accessDenied', { statusCode: 403 })
}
/**
* Check if the specified action is allowed for the given model and request. It retrieves the action guards, filters them based on the site ID, model name, and team IDs, and checks if the action is included in the allowed values. If any guard denies the action, it returns false; otherwise, it returns true.
*
* @async
* @method
* @param {string} action - The action to check. Available actions are: 'READ', 'CREATE', 'UPDATE', 'REMOVE' and 'EXPORT'.
* @param {object} model - The model object.
* @param {object} req - The request object.
* @returns {Promise<boolean>} - Returns true if the action is allowed, otherwise false.
*/
isActionAllowed = async (action, model, req) => {
const { includes } = this.app.lib.aneka
if (!req) return true
if (!req.site) return true
const guards = await this.getActionGuards()
const { teamIds, aliases } = this.getTeamIdsAndAliases(req)
if (aliases.includes('administrator')) return true
const filterFn = item => {
const bySiteId = item.siteId === req.site.id + ''
const byModel = item.models.includes(model.name)
const byTeamId = item.allTeams ? true : includes(item.teamIds, teamIds)
return bySiteId && byModel && byTeamId
}
let denied = false
for (const item of guards.filter(filterFn)) {
if (item.value.includes('*')) continue
if (!item.value.includes(action)) denied = true
}
return !denied
}
}
return Sumba
}
export default factory