UNPKG

@waline/vercel

Version:

vercel server for waline comment system

757 lines (629 loc) 20.2 kB
const BaseRest = require('./rest.js'); const akismet = require('../service/akismet.js'); const { getMarkdownParser } = require('../service/markdown/index.js'); const markdownParser = getMarkdownParser(); async function formatCmt( { ua, ip, ...comment }, users = [], { avatarProxy, deprecated }, loginUser, ) { ua = think.uaParser(ua); if (!think.config('disableUserAgent')) { comment.browser = `${ua.browser.name || ''}${(ua.browser.version || '') .split('.') .slice(0, 2) .join('.')}`; comment.os = [ua.os.name, ua.os.version].filter((v) => v).join(' '); } const user = users.find(({ objectId }) => comment.user_id === objectId); if (!think.isEmpty(user)) { comment.nick = user.display_name; comment.mail = user.email; comment.link = user.url; comment.type = user.type; comment.label = user.label; } const avatarUrl = user?.avatar ? user.avatar : await think.service('avatar').stringify(comment); comment.avatar = avatarProxy && !avatarUrl.includes(avatarProxy) ? avatarProxy + '?url=' + encodeURIComponent(avatarUrl) : avatarUrl; const isAdmin = loginUser && loginUser.type === 'administrator'; if (loginUser) { comment.orig = comment.comment; } if (!isAdmin) { delete comment.mail; } else { comment.ip = ip; } // administrator can always show region if (isAdmin || !think.config('disableRegion')) { comment.addr = await think.ip2region(ip, { depth: isAdmin ? 3 : 1 }); } comment.comment = markdownParser(comment.comment); comment.like = Number(comment.like) || 0; // compat sql storage return number flag to string if (typeof comment.sticky === 'string') { comment.sticky = Boolean(Number(comment.sticky)); } comment.time = new Date(comment.insertedAt).getTime(); if (!deprecated) { delete comment.insertedAt; } delete comment.createdAt; delete comment.updatedAt; return comment; } module.exports = class extends BaseRest { constructor(ctx) { super(ctx); this.modelInstance = this.getModel('Comment'); } async getAction() { const { type } = this.get(); const fnMap = { recent: this.getRecentCommentList, count: this.getCommentCount, list: this.getAdminCommentList, }; const fn = fnMap[type] || this.getCommentList; const data = await fn.call(this); return this.jsonOrSuccess(data); } async postAction() { think.logger.debug('Post Comment Start!'); const { comment, link, mail, nick, pid, rid, ua, url, at } = this.post(); const data = { link, mail, nick, pid, rid, ua, url, comment, ip: this.ctx.ip, insertedAt: new Date(), user_id: this.ctx.state.userInfo.objectId, }; if (pid && this.ctx.state.deprecated) { data.comment = `[@${at}](#${pid}): ` + data.comment; } think.logger.debug('Post Comment initial Data:', data); const { userInfo } = this.ctx.state; if (!userInfo || userInfo.type !== 'administrator') { /** IP disallowList */ const { disallowIPList } = this.config(); if ( think.isArray(disallowIPList) && disallowIPList.length && disallowIPList.includes(data.ip) ) { think.logger.debug(`Comment IP ${data.ip} is in disallowIPList`); return this.ctx.throw(403); } think.logger.debug(`Comment IP ${data.ip} check OK!`); /** Duplicate content detect */ const duplicate = await this.modelInstance.select({ url, mail: data.mail, nick: data.nick, link: data.link, comment: data.comment, }); if (!think.isEmpty(duplicate)) { think.logger.debug( 'The comment author had post same comment content before', ); return this.fail(this.locale('Duplicate Content')); } think.logger.debug('Comment duplicate check OK!'); /** IP frequency */ const { IPQPS = 60 } = process.env; const recent = await this.modelInstance.select({ ip: this.ctx.ip, insertedAt: ['>', new Date(Date.now() - IPQPS * 1000)], }); if (!think.isEmpty(recent)) { think.logger.debug(`The author has posted in ${IPQPS} seconds.`); return this.fail(this.locale('Comment too fast!')); } think.logger.debug(`Comment post frequency check OK!`); /** Akismet */ data.status = this.config('audit') ? 'waiting' : 'approved'; think.logger.debug(`Comment initial status is ${data.status}`); if (data.status === 'approved') { const spam = await akismet(data, this.ctx.serverURL).catch((e) => console.log(e), ); // ignore akismet error if (spam === true) { data.status = 'spam'; } } think.logger.debug(`Comment akismet check result: ${data.status}`); if (data.status !== 'spam') { /** KeyWord Filter */ const { forbiddenWords } = this.config(); if (!think.isEmpty(forbiddenWords)) { const regexp = new RegExp('(' + forbiddenWords.join('|') + ')', 'ig'); if (regexp.test(comment)) { data.status = 'spam'; } } } think.logger.debug(`Comment keyword check result: ${data.status}`); } else { data.status = 'approved'; } const preSaveResp = await this.hook('preSave', data); if (preSaveResp) { return this.fail(preSaveResp.errmsg); } think.logger.debug(`Comment post hooks preSave done!`); const resp = await this.modelInstance.add(data); think.logger.debug(`Comment have been added to storage.`); let parentComment; let parentUser; if (pid) { parentComment = await this.modelInstance.select({ objectId: pid }); parentComment = parentComment[0]; if (parentComment.user_id) { parentUser = await this.getModel('Users').select({ objectId: parentComment.user_id, }); parentUser = parentUser[0]; } } await this.ctx.webhook('new_comment', { comment: { ...resp, rawComment: comment }, reply: parentComment, }); const cmtReturn = await formatCmt( resp, [userInfo], { ...this.config(), deprecated: this.ctx.state.deprecated }, userInfo, ); const parentReturn = parentComment ? await formatCmt( parentComment, parentUser ? [parentUser] : [], { ...this.config(), deprecated: this.ctx.state.deprecated }, userInfo, ) : undefined; if (comment.status !== 'spam') { const notify = this.service('notify', this); await notify.run( { ...cmtReturn, mail: resp.mail, rawComment: comment }, parentReturn ? { ...parentReturn, mail: parentComment.mail } : undefined, ); } think.logger.debug(`Comment notify done!`); await this.hook('postSave', resp, parentComment); think.logger.debug(`Comment post hooks postSave done!`); return this.success( await formatCmt( resp, [userInfo], { ...this.config(), deprecated: this.ctx.state.deprecated }, userInfo, ), ); } async putAction() { const { userInfo } = this.ctx.state; const isAdmin = userInfo.type === 'administrator'; let data = isAdmin ? this.post() : this.post('comment,like'); let oldData = await this.modelInstance.select({ objectId: this.id }); if (think.isEmpty(oldData) || think.isEmpty(data)) { return this.success(); } oldData = oldData[0]; if (think.isBoolean(data.like)) { const likeIncMax = this.config('LIKE_INC_MAX') || 1; data.like = (Number(oldData.like) || 0) + (data.like ? Math.ceil(Math.random() * likeIncMax) : -1); data.like = Math.max(data.like, 0); } const preUpdateResp = await this.hook('preUpdate', { ...data, objectId: this.id, }); if (preUpdateResp) { return this.fail(preUpdateResp); } const newData = await this.modelInstance.update(data, { objectId: this.id, }); let cmtUser; if (!think.isEmpty(newData) && newData[0].user_id) { cmtUser = await this.getModel('Users').select({ objectId: newData[0].user_id, }); cmtUser = cmtUser[0]; } const cmtReturn = await formatCmt( newData[0], cmtUser ? [cmtUser] : [], { ...this.config(), deprecated: this.ctx.state.deprecated }, userInfo, ); if ( oldData.status === 'waiting' && data.status === 'approved' && oldData.pid ) { let pComment = await this.modelInstance.select({ objectId: oldData.pid, }); pComment = pComment[0]; let pUser; if (pComment.user_id) { pUser = await this.getModel('Users').select({ objectId: pComment.user_id, }); pUser = pUser[0]; } const notify = this.service('notify', this); const pcmtReturn = await formatCmt( pComment, pUser ? [pUser] : [], { ...this.config(), deprecated: this.ctx.state.deprecated }, userInfo, ); await notify.run( { ...cmtReturn, mail: newData[0].mail }, { ...pcmtReturn, mail: pComment.mail }, true, ); } await this.hook('postUpdate', data); return this.success(cmtReturn); } async deleteAction() { const preDeleteResp = await this.hook('preDelete', this.id); if (preDeleteResp) { return this.fail(preDeleteResp); } await this.modelInstance.delete({ _complex: { _logic: 'or', objectId: this.id, pid: this.id, rid: this.id, }, }); await this.hook('postDelete', this.id); return this.success(); } async getCommentList() { const { userInfo } = this.ctx.state; const { path: url, page, pageSize, sortBy } = this.get(); const where = { url }; if (think.isEmpty(userInfo) || this.config('storage') === 'deta') { where.status = ['NOT IN', ['waiting', 'spam']]; } else if (userInfo.type !== 'administrator') { where._complex = { _logic: 'or', status: ['NOT IN', ['waiting', 'spam']], user_id: userInfo.objectId, }; } const totalCount = await this.modelInstance.count(where); const pageOffset = Math.max((page - 1) * pageSize, 0); let comments = []; let rootComments = []; let rootCount = 0; const selectOptions = { field: [ 'status', 'comment', 'insertedAt', 'link', 'mail', 'nick', 'pid', 'rid', 'ua', 'ip', 'user_id', 'sticky', 'like', ], }; if (sortBy) { const [field, order] = sortBy.split('_'); if (order === 'desc') { selectOptions.desc = field; } else if (order === 'asc') { // do nothing because of ascending order is default behavior } } /** * most of case we have just little comments * while if we want get rootComments, rootCount, childComments with pagination * we have to query three times from storage service * That's so expensive for user, especially in the serverless. * so we have a comments length check * If you have less than 1000 comments, then we'll get all comments one time * then we'll compute rootComment, rootCount, childComments in program to reduce http request query * * Why we have limit and the limit is 1000? * Many serverless storages have fetch data limit, for example LeanCloud is 100, and CloudBase is 1000 * If we have much comments, We should use more request to fetch all comments * If we have 3000 comments, We have to use 30 http request to fetch comments, things go athwart. * And Serverless Service like vercel have execute time limit * if we have more http requests in a serverless function, it may timeout easily. * so we use limit to avoid it. */ if (totalCount < 1000) { comments = await this.modelInstance.select(where, selectOptions); rootCount = comments.filter(({ rid }) => !rid).length; rootComments = [ ...comments.filter(({ rid, sticky }) => !rid && sticky), ...comments.filter(({ rid, sticky }) => !rid && !sticky), ].slice(pageOffset, pageOffset + pageSize); const rootIds = {}; rootComments.forEach(({ objectId }) => { rootIds[objectId] = true; }); comments = comments.filter( (cmt) => rootIds[cmt.objectId] || rootIds[cmt.rid], ); } else { comments = await this.modelInstance.select( { ...where, rid: undefined }, { ...selectOptions }, ); rootCount = comments.length; rootComments = [ ...comments.filter(({ rid, sticky }) => !rid && sticky), ...comments.filter(({ rid, sticky }) => !rid && !sticky), ].slice(pageOffset, pageOffset + pageSize); const children = await this.modelInstance.select( { ...where, rid: ['IN', rootComments.map(({ objectId }) => objectId)], }, selectOptions, ); comments = [...rootComments, ...children]; } const userModel = this.getModel('Users'); const user_ids = Array.from( new Set(comments.map(({ user_id }) => user_id).filter((v) => v)), ); let users = []; if (user_ids.length) { users = await userModel.select( { objectId: ['IN', user_ids] }, { field: ['display_name', 'email', 'url', 'type', 'avatar', 'label'], }, ); } if (think.isArray(this.config('levels'))) { const countWhere = { status: ['NOT IN', ['waiting', 'spam']], _complex: {}, }; if (user_ids.length) { countWhere._complex.user_id = ['IN', user_ids]; } const mails = Array.from( new Set(comments.map(({ mail }) => mail).filter((v) => v)), ); if (mails.length) { countWhere._complex.mail = ['IN', mails]; } if (!think.isEmpty(countWhere._complex)) { countWhere._complex._logic = 'or'; } else { delete countWhere._complex; } const counts = await this.modelInstance.count(countWhere, { group: ['user_id', 'mail'], }); comments.forEach((cmt) => { const countItem = (counts || []).find(({ mail, user_id }) => cmt.user_id ? user_id === cmt.user_id : mail === cmt.mail, ); cmt.level = think.getLevel(countItem?.count); }); } return { page, totalPages: Math.ceil(rootCount / pageSize), pageSize, count: totalCount, data: await Promise.all( rootComments.map(async (comment) => { const cmt = await formatCmt( comment, users, { ...this.config(), deprecated: this.ctx.state.deprecated }, userInfo, ); cmt.children = await Promise.all( comments .filter(({ rid }) => rid === cmt.objectId) .map((cmt) => formatCmt( cmt, users, { ...this.config(), deprecated: this.ctx.state.deprecated, }, userInfo, ), ) .reverse(), ); const childCommentsMap = new Map(); childCommentsMap.set(cmt.objectId, cmt); cmt.children.forEach((c) => childCommentsMap.set(c.objectId, c)); cmt.children.forEach((c) => { const parent = childCommentsMap.get(c.pid); // fix https://github.com/walinejs/waline/issues/2518 avoid some abnormal comment data if (!parent) { return; } c.reply_user = { nick: parent?.nick, link: parent?.link, avatar: parent?.avatar, }; }); return cmt; }), ), }; } async getAdminCommentList() { const { userInfo } = this.ctx.state; const { page, pageSize, owner, status, keyword } = this.get(); const where = {}; if (owner === 'mine') { const { userInfo } = this.ctx.state; where.mail = userInfo.email; } if (status) { where.status = status; // compat with valine old data without status property if (status === 'approved') { where.status = ['NOT IN', ['waiting', 'spam']]; } } if (keyword) { where.comment = ['LIKE', `%${keyword}%`]; } const count = await this.modelInstance.count(where); const spamCount = await this.modelInstance.count({ status: 'spam' }); const waitingCount = await this.modelInstance.count({ status: 'waiting', }); const comments = await this.modelInstance.select(where, { desc: 'insertedAt', limit: pageSize, offset: Math.max((page - 1) * pageSize, 0), }); const userModel = this.getModel('Users'); const user_ids = Array.from( new Set(comments.map(({ user_id }) => user_id).filter((v) => v)), ); let users = []; if (user_ids.length) { users = await userModel.select( { objectId: ['IN', user_ids] }, { field: ['display_name', 'email', 'url', 'type', 'avatar', 'label'], }, ); } return { page, totalPages: Math.ceil(count / pageSize), pageSize, spamCount, waitingCount, data: await Promise.all( comments.map((cmt) => formatCmt( cmt, users, { ...this.config(), deprecated: this.ctx.state.deprecated }, userInfo, ), ), ), }; } async getRecentCommentList() { const { count } = this.get(); const { userInfo } = this.ctx.state; const where = {}; if (think.isEmpty(userInfo) || this.config('storage') === 'deta') { where.status = ['NOT IN', ['waiting', 'spam']]; } else { where._complex = { _logic: 'or', status: ['NOT IN', ['waiting', 'spam']], user_id: userInfo.objectId, }; } const comments = await this.modelInstance.select(where, { desc: 'insertedAt', limit: count, field: [ 'status', 'comment', 'insertedAt', 'link', 'mail', 'nick', 'url', 'pid', 'rid', 'ua', 'ip', 'user_id', 'sticky', 'like', ], }); const userModel = this.getModel('Users'); const user_ids = Array.from( new Set(comments.map(({ user_id }) => user_id).filter((v) => v)), ); let users = []; if (user_ids.length) { users = await userModel.select( { objectId: ['IN', user_ids] }, { field: ['display_name', 'email', 'url', 'type', 'avatar', 'label'], }, ); } return Promise.all( comments.map((cmt) => formatCmt( cmt, users, { ...this.config(), deprecated: this.ctx.state.deprecated }, userInfo, ), ), ); } async getCommentCount() { const { url } = this.get(); const { userInfo } = this.ctx.state; const where = Array.isArray(url) && url.length ? { url: ['IN', url] } : {}; if (think.isEmpty(userInfo) || this.config('storage') === 'deta') { where.status = ['NOT IN', ['waiting', 'spam']]; } else { where._complex = { _logic: 'or', status: ['NOT IN', ['waiting', 'spam']], user_id: userInfo.objectId, }; } if (Array.isArray(url) && (url.length > 1 || !this.ctx.state.deprecated)) { const data = await this.modelInstance.select(where, { field: ['url'], }); return url.map((u) => data.filter(({ url }) => url === u).length); } const data = await this.modelInstance.count(where); return data; } };