import path from 'path'
/**
* Plugin factory
*
* @param {string} pkgName - NPM package name
* @returns {class}
*/
async function factory (pkgName) {
const me = this
/**
* Sumba class
*
* @class
*/
class Sumba extends this.app.pluginClass.base {
static alias = 'sumba'
static dependencies = ['bajo-extra', 'bajo-common-db', 'bajo-config']
constructor () {
super(pkgName, me.app)
this.config = {
multiSite: false,
waibu: {
title: 'site',
prefix: 'site'
},
waibuMpa: {
home: 'sumba:/your-stuff/profile',
icon: 'globe',
redirect: {
'/': 'sumba:/your-stuff/profile',
'/your-stuff': 'sumba:/your-stuff/profile',
'/info': 'sumba:/info/about-us',
'/user': 'sumba:/your-stuff/profile',
'/db/export': 'sumba:/db/export/list',
'/help': 'sumba:/help/contact-form',
'/help/trouble-tickets': 'sumba:/help/trouble-tickets/list'
},
menuHandler: [{
title: 'account',
level: 9998,
children: [
// anonymous only
{ title: 'signin', href: 'sumba:/signin', visible: 'anon' },
{ title: 'forgotPassword', href: 'sumba:/user/forgot-password', visible: 'anon' },
{ title: 'newUserSignup', href: 'sumba:/user/signup', visible: 'anon' },
{ title: '-', visible: 'anon' },
{ title: 'activation', href: 'sumba:/user/activation', visible: 'anon' },
// authenticated only
{ title: 'yourProfile', href: 'sumba:/your-stuff/profile', visible: 'auth' },
{ title: 'changePassword', href: 'sumba:/your-stuff/change-password', visible: 'auth' },
{ title: 'downloadList', href: 'sumba:/your-stuff/download/list', visible: 'auth' },
{ title: '-', visible: 'auth' },
{ title: 'signout', href: 'sumba:/signout', visible: 'auth' }
]
}, {
title: 'help',
level: 9999,
children: [
{ title: 'contactForm', href: 'sumba:/help/contact-form' },
{ title: 'troubleTickets', href: 'sumba:/help/trouble-tickets', visible: 'auth' },
{ title: '-' },
{ title: 'cookiePolicy', href: 'sumba:/info/cookie-policy' },
{ title: 'privacy', href: 'sumba:/info/privacy' },
{ title: 'termsConditions', href: 'sumba:/info/terms-conditions' }
]
}]
},
waibuAdmin: {
menuHandler: 'sumba:adminMenu',
menuCollapsible: true,
modelDisabled: 'all'
},
auth: {
common: {
apiKey: {
type: 'Bearer',
qsKey: 'apiKey',
headerKey: 'X-Sumba-ApiKey'
},
basic: {
},
jwt: {
type: 'Bearer',
qsKey: 'token',
headerKey: 'X-Sumba-Token',
secret: '668de9cf57316c7dbf52f7ff7611c299',
expiresIn: 604800000
}
},
waibuRestApi: {
methods: ['basic', 'apiKey', 'jwt'],
silentOnError: false
},
waibuMpa: {
methods: ['session'],
silentOnError: false
},
waibuStatic: {
methods: ['basic', 'apiKey', 'jwt'],
basic: {
useUtf8: true,
realm: 'Protected Area',
warningMessage: 'Please authenticate yourself, thank you!'
},
silentOnError: false
}
},
redirect: {
signin: 'sumba:/signin',
afterSignin: '/',
signout: 'sumba:/signout',
afterSignout: '/'
},
siteSetting: {
forgotPasswordExpDur: '5m',
userPassword: {
minUppercase: 1,
minLowercase: 1,
minSpecialChar: 1,
minNumeric: 1,
noWhitespace: false,
latinOnlyChars: false
}
}
}
this.unsafeUserFields = ['password']
}
init = async () => {
const { getPluginDataDir } = this.app.bajo
this.downloadDir = `${getPluginDataDir(this.ns)}/download`
this.app.lib.fs.ensureDirSync(this.downloadDir)
for (const type of ['secure', 'anonymous', 'team']) {
this[`${type}Routes`] = this[`${type}Routes`] ?? []
this[`${type}NegRoutes`] = this[`${type}NegRoutes`] ?? []
}
}
_getSetting = async (type, source) => {
const { defaultsDeep } = this.app.lib.aneka
const { get } = this.app.lib._
const setting = defaultsDeep(get(this.config, `auth.${source}.${type}`, {}), get(this.config, `auth.common.${type}`, {}))
if (type === 'basic') setting.type = 'Basic'
return setting
}
_getToken = async (type, req, source) => {
const { isEmpty } = this.app.lib._
const setting = await this._getSetting(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
}
adminMenu = async (locals, req) => {
if (!this.app.waibuAdmin) return
const { getPluginPrefix } = this.app.waibu
const prefix = getPluginPrefix(this.ns)
return [{
title: 'supportSystem',
children: [
{ title: 'contactForm', href: `waibuAdmin:/${prefix}/contact-form/list` },
{ title: 'contactFormCat', href: `waibuAdmin:/${prefix}/contact-form-cat/list` },
{ title: 'ticket', href: `waibuAdmin:/${prefix}/ticket/list` },
{ title: 'ticketCat', href: `waibuAdmin:/${prefix}/ticket-cat/list` }
]
}, {
title: 'management',
children: [
{ title: 'manageSite', href: `waibuAdmin:/${prefix}/site` },
{ title: 'manageUser', href: `waibuAdmin:/${prefix}/user/list` },
{ title: 'manageTeam', href: `waibuAdmin:/${prefix}/team/list` },
{ title: 'manageTeamUser', href: `waibuAdmin:/${prefix}/team-user/list` },
{ title: 'manageDownload', href: `waibuAdmin:/${prefix}/download/list` },
{ title: 'resetUserPassword', href: `waibuAdmin:/${prefix}/reset-user-password` }
]
}, {
title: 'misc',
children: [
{ title: 'userSession', href: `waibuAdmin:/${prefix}/session/list` }
]
}]
}
getUser = async (rec, safe = true) => {
const { recordGet } = this.app.dobo
const { omit, isPlainObject } = this.app.lib._
let user
if (isPlainObject(rec)) user = rec
else user = await recordGet('SumbaUser', rec, { noHook: true })
return safe ? omit(user, this.unsafeUserFields) : user
}
mergeTeam = async (user, site) => {
if (!user) return
const { map, pick } = this.app.lib._
const { recordFindAll } = this.app.dobo
user.teams = []
const query = { userId: user.id, siteId: site.id }
const userTeam = await recordFindAll('SumbaTeamUser', { query })
if (userTeam.length === 0) return
delete query.userId
query.id = { $in: map(userTeam, 'id'), status: 'ENABLED' }
const team = await recordFindAll('SumbaTeam', { query })
if (team.length > 0) user.teams.push(...map(team, t => pick(t, ['id', 'alias'])))
}
getUserFromUsernamePassword = async (username = '', password = '', req) => {
const { importPkg } = this.app.bajo
const { recordFind, validate } = this.app.dobo
const model = 'SumbaUser'
await validate({ username, password }, model, { ns: ['sumba', 'dobo'], fields: ['username', 'password'] })
const bcrypt = await importPkg('bajoExtra:bcrypt')
const query = { username, provider: 'local' }
const rows = await recordFind(model, { query }, { req, forceNoHidden: true, noHook: true })
if (rows.length === 0) throw this.error('validationError', { details: [{ field: 'username', error: 'Unknown username' }], statusCode: 401 })
const rec = rows[0]
if (rec.status !== 'ACTIVE') throw this.error('validationError', { details: ['User is inactive or temporarily disabled'], statusCode: 401 })
const verified = await bcrypt.compare(password, rec.password)
if (!verified) throw this.error('validationError', { details: [{ field: 'password', error: 'invalidPassword' }], statusCode: 401 })
return rec
}
createJwtFromUserRecord = async (rec) => {
const { importPkg } = this.app.bajo
const { dayjs } = this.app.lib
const { hash } = this.app.bajoExtra
const { get, pick } = this.app.lib._
const fastJwt = await importPkg('bajoExtra:fast-jwt')
const { createSigner } = fastJwt
const opts = pick(this.config.auth.common.jwt, ['expiresIn'])
opts.key = get(this.config, 'auth.common.jwt.secret')
const sign = createSigner(opts)
const apiKey = await hash(rec.password)
const payload = { uid: rec.id, apiKey }
const token = await sign(payload)
const expiresAt = dayjs().add(opts.expiresIn).toDate()
return { token, expiresAt }
}
verifySession = async (req, reply, source, payload) => {
const { getUser } = this
const { routePath } = this.app.waibu
if (!req.session) return false
if (req.session.userId) {
req.user = await getUser(req.session.userId)
return true
}
const redir = routePath(this.config.redirect.signin, req)
req.session.ref = req.url
throw this.error('_redirect', { redirect: redir })
}
verifyApiKey = async (req, reply, source, payload) => {
const { merge } = this.app.lib._
const { isMd5, hash } = this.app.bajoExtra
const { getUser } = this
const { recordFind } = this.app.dobo
let token = await this._getToken('apiKey', req, source)
if (!isMd5(token)) return false
token = await hash(token)
const query = { token }
const rows = await recordFind('SumbaUser', { query }, { req, noHook: true })
if (rows.length === 0) throw this.error('invalidKey', merge({ statusCode: 401 }, payload))
if (rows[0].status !== 'ACTIVE') throw this.error('userInactive', merge({ details: [{ field: 'status', error: 'inactive' }], statusCode: 401 }, payload))
req.user = await getUser(rows[0])
return true
}
verifyBasic = async (req, reply, source, payload) => {
const { getUserFromUsernamePassword } = this
const { getUser } = this
const { isEmpty, merge } = this.app.lib._
const setHeader = async (setting, reply) => {
const { isString } = this.app.lib._
let header = setting.type
const exts = []
if (isString(setting.realm)) exts.push(`realm="${setting.realm}"`)
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 = await this._getSetting('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(setting.warningMessage)
} else return false
}
const decoded = Buffer.from(authInfo, 'base64').toString()
const [username, password] = decoded.split(':')
try {
const user = await getUserFromUsernamePassword(username, password, req)
req.user = await getUser(user)
} catch (err) {
if (err.statusCode === 401 && setting.realm) {
await setHeader(setting, reply)
return err.message
}
throw merge(err, payload)
}
return true
}
verifyJwt = async (req, reply, source, payload) => {
const { importPkg } = this.app.bajo
const { recordGet } = this.app.dobo
const { getUser } = this
const { isEmpty, merge } = this.app.lib._
const fastJwt = await importPkg('bajoExtra:fast-jwt')
const { createVerifier } = fastJwt
const setting = await this._getSetting('jwt', source)
const token = await this._getToken('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 rec = await recordGet('SumbaUser', id, { req, noHook: true })
if (!rec) throw this.error('invalidToken', { statusCode: 401 })
if (rec.status !== 'ACTIVE') throw this.error('userInactive', { details: [{ field: 'status', error: 'inactive' }], statusCode: 401 })
req.user = await getUser(rec)
} catch (err) {
merge(err, payload)
throw err
}
return true
}
checkPathsByTeam = ({ paths = [], method = 'GET', teams = [], guards = [] }) => {
const { includes } = this.app.lib.aneka
const { outmatch } = this.app.lib
for (const item of guards) {
const matchPath = outmatch(item.path)
for (const path of paths) {
if (matchPath(path)) {
const matchMethods = outmatch(item.methods, { separator: false })
if (matchMethods(method)) {
if (item.teams.length === 0) return item
if (includes(teams, item.teams)) return item
}
}
}
}
}
checkPathsByRoute = ({ paths = [], method = 'GET', guards = [] }) => {
const { outmatch } = this.app.lib
for (const item of guards) {
const matchPath = outmatch(item.path)
for (const path of paths) {
if (matchPath(path)) {
const matchMethods = outmatch(item.methods, { separator: false })
if (matchMethods(method)) return item
}
}
}
}
checkPathsByGuard = ({ guards, paths }) => {
const { outmatch } = this.app.lib
const matcher = outmatch(guards)
let guarded
for (const path of paths) {
if (!guarded) guarded = matcher(path)
}
return guarded
}
getSite = async (hostname, useId) => {
const { omit } = this.app.lib._
const { recordFind } = this.app.dobo
const omitted = ['status']
const mergeSetting = async (site) => {
const { defaultsDeep } = this.app.lib.aneka
const { parseObject } = this.app.bajo
const { trim, get, filter } = this.app.lib._
const { recordFind, recordGet } = this.app.dobo
const defSetting = {}
const nsSetting = {}
const names = this.app.getAllNs()
const query = {
ns: { $in: names },
siteId: site.id
}
const all = await recordFind('SumbaSiteSetting', { query, limit: -1 })
for (const ns of names) {
nsSetting[ns] = {}
defSetting[ns] = get(this, `app.${ns}.config.siteSetting`, {})
const items = filter(all, { ns })
for (const item of items) {
let value = trim([item.value] ?? '')
if (['[', '{'].includes(value[0])) value = JSON.parse(value)
else if (Number(value)) value = Number(value)
else if (['true', 'false'].includes(value)) value = value === 'true'
nsSetting[ns][item.key] = value
}
}
site.setting = parseObject(defaultsDeep({}, nsSetting, defSetting))
// additional fields
const country = await recordGet('CdbCountry', site.country, { noHook: true })
site.countryName = (country ?? {}).name ?? site.country
}
let site = {}
if (!this.config.multiSite) {
const resp = await recordFind('SumbaSite', { query: { alias: 'default' } }, { noHook: true })
site = omit(resp[0], omitted)
await mergeSetting(site)
return site
}
let query
if (useId) query = { id: hostname }
else {
query = {
$or: [
{ hostname },
{ alias: hostname }
]
}
}
const filter = { query, limit: 1 }
const rows = await recordFind('SumbaSite', filter, { noHook: true })
if (rows.length === 0) throw this.error('unknownSite')
const row = omit(rows[0], omitted)
if (row.status !== 'ACTIVE') throw this.error('siteInactiveInfo')
site = row
await mergeSetting(site)
return site
}
signin = async ({ user, req, reply }) => {
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 })
}
generatePassword = (req) => {
const { generateId } = this.app.bajo
const cfg = req ? req.site.setting.sumba.userPassword : this.config.siteSetting.userPassword
let passwd = generateId()
if (cfg.minLowercase) passwd += generateId({ pattern: 'abcdefghijklmnopqrstuvwxyz', length: cfg.minLowercase })
if (cfg.minUppercase) passwd += generateId({ pattern: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', length: cfg.minUppercase })
if (cfg.minSpecialChar) passwd += generateId({ pattern: '!@#$%*', length: cfg.minSpecialChar })
if (cfg.minNumeric) passwd += generateId({ pattern: '0123456789', length: cfg.minNumeric })
return passwd
}
pushDownload = async ({ description, worker, data, source, req, file, type }) => {
const { getPlugin } = this.app.bajo
const { recordCreate } = getPlugin('waibuDb')
const { push } = 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 recordCreate({ model: 'SumbaDownload', body, req, options: { noFlash: true } })
jobQueue.payload.data.download = { id: rec.data.id, file }
await push(jobQueue)
}
getApiKeyFromUserId = async id => {
const { hash } = this.app.bajoExtra
const { recordGet } = this.app.dobo
const options = { forceNoHidden: true, noHook: true, noCache: true, attachment: true, mimeType: true }
const resp = await recordGet('SumbaUser', id, options)
return await hash(resp.salt)
}
}
return Sumba
}
export default factory