UNPKG

gahotx-cdn

Version:
1,635 lines (1,562 loc) 49.9 kB
/*! * Twikoo cloudbase function v1.4.11 * (c) 2020-present iMaeGoo * Released under the MIT License. */ // 三方依赖 / 3rd party dependencies const tcb = require('@cloudbase/node-sdk') // 云开发 SDK const md5 = require('blueimp-md5') // MD5 加解密 const bowser = require('bowser') // UserAgent 格式化 const nodemailer = require('nodemailer') // 发送邮件 const axios = require('axios') // 发送 REST 请求 const qs = require('querystring') // URL 参数格式化 const $ = require('cheerio') // jQuery 服务器版 const { AkismetClient } = require('akismet-api') // 反垃圾 API const createDOMPurify = require('dompurify') // 反 XSS const { JSDOM } = require('jsdom') // document.window 服务器版 const xml2js = require('xml2js') // XML 解析 const marked = require('marked') // Markdown 解析 const CryptoJS = require('crypto-js') // 编解码 const tencentcloud = require('tencentcloud-sdk-nodejs') // 腾讯云 API NODEJS SDK // 云函数 SDK / tencent cloudbase sdk const app = tcb.init({ env: tcb.SYMBOL_CURRENT_ENV }) const auth = app.auth() const db = app.database() const _ = db.command // 初始化反 XSS const window = new JSDOM('').window const DOMPurify = createDOMPurify(window) // 常量 / constants const VERSION = '1.4.11' const RES_CODE = { SUCCESS: 0, FAIL: 1000, EVENT_NOT_EXIST: 1001, PASS_EXIST: 1010, CONFIG_NOT_EXIST: 1020, CREDENTIALS_NOT_EXIST: 1021, CREDENTIALS_INVALID: 1025, PASS_NOT_EXIST: 1022, PASS_NOT_MATCH: 1023, NEED_LOGIN: 1024, FORBIDDEN: 1403, AKISMET_ERROR: 1030 } const ADMIN_USER_ID = 'admin' // 全局变量 / variables // 警告:全局定义的变量,会被云函数缓存,请慎重定义全局变量 // 参考 https://docs.cloudbase.net/cloud-function/deep-principle.html 中的 “实例复用” let config let transporter // 云函数入口点 / entry point exports.main = async (event, context) => { console.log('请求方法:', event.event) console.log('请求参数:', event) let res = {} await readConfig() try { switch (event.event) { case 'GET_FUNC_VERSION': res = getFuncVersion() break case 'COMMENT_GET': res = await commentGet(event) break case 'COMMENT_GET_FOR_ADMIN': res = await commentGetForAdmin(event) break case 'COMMENT_SET_FOR_ADMIN': res = await commentSetForAdmin(event) break case 'COMMENT_DELETE_FOR_ADMIN': res = await commentDeleteForAdmin(event) break case 'COMMENT_IMPORT_FOR_ADMIN': res = await commentImportForAdmin(event) break case 'COMMENT_LIKE': res = await commentLike(event) break case 'COMMENT_SUBMIT': res = await commentSubmit(event) break case 'POST_SUBMIT': res = await postSubmit(event.comment, context) break case 'COUNTER_GET': res = await counterGet(event) break case 'GET_PASSWORD_STATUS': res = await getPasswordStatus() break case 'SET_PASSWORD': res = await setPassword(event) break case 'GET_CONFIG': res = getConfig() break case 'GET_CONFIG_FOR_ADMIN': res = await getConfigForAdmin() break case 'SET_CONFIG': res = await setConfig(event) break case 'LOGIN': res = await login(event.password) break case 'GET_COMMENTS_COUNT': // >= 0.2.7 res = await getCommentsCount(event) break case 'GET_RECENT_COMMENTS': // >= 0.2.7 res = await getRecentComments(event) break case 'EMAIL_TEST': // >= 1.4.6 res = await emailTest(event) break default: if (event.event) { res.code = RES_CODE.EVENT_NOT_EXIST res.message = '请更新 Twikoo 云函数至最新版本' } else { res.code = RES_CODE.SUCCESS res.message = 'Twikoo 云函数运行正常,请参考 https://twikoo.js.org/quick-start.html#%E5%89%8D%E7%AB%AF%E9%83%A8%E7%BD%B2 完成前端的配置' } } } catch (e) { console.error('Twikoo 遇到错误,请参考以下错误信息。如有疑问,请反馈至 https://github.com/imaegoo/twikoo/issues') console.error('请求参数:', event) console.error('错误信息:', e) res.code = RES_CODE.FAIL res.message = e.message } console.log('请求返回:', res) return res } // 获取 Twikoo 云函数版本 function getFuncVersion () { return { code: RES_CODE.SUCCESS, version: VERSION } } // 判断是否存在管理员密码 async function getPasswordStatus () { return { code: RES_CODE.SUCCESS, status: !!config.ADMIN_PASS, credentials: !!config.CREDENTIALS, version: VERSION } } // 写入管理密码 async function setPassword (event) { const isAdminUser = await isAdmin() // 如果数据库里没有密码,则写入密码 // 如果数据库里有密码,则只有管理员可以写入密码 if (config.ADMIN_PASS && !isAdminUser) { return { code: RES_CODE.PASS_EXIST, message: '请先登录再修改密码' } } if (!config.CREDENTIALS && !event.credentials) { return { code: RES_CODE.CREDENTIALS_NOT_EXIST, message: '未配置登录私钥' } } if (!config.CREDENTIALS && event.credentials) { const checkResult = await checkAndSaveCredentials(event.credentials) if (!checkResult) { return { code: RES_CODE.CREDENTIALS_INVALID, message: '无效的私钥文件' } } } const ADMIN_PASS = md5(event.password) await writeConfig({ ADMIN_PASS }) return { code: RES_CODE.SUCCESS } } async function checkAndSaveCredentials (credentials) { try { const ticket = getAdminTicket(JSON.parse(credentials)) if (!ticket) return false await writeConfig({ CREDENTIALS: credentials }) return true } catch (e) { console.error('私钥文件异常:', e) return false } } // 管理员登录 async function login (password) { if (!config) { return { code: RES_CODE.CONFIG_NOT_EXIST, message: '数据库无配置' } } if (!config.CREDENTIALS) { return { code: RES_CODE.CREDENTIALS_NOT_EXIST, message: '未配置登录私钥' } } if (!config.ADMIN_PASS) { return { code: RES_CODE.PASS_NOT_EXIST, message: '未配置管理密码' } } if (config.ADMIN_PASS !== md5(password)) { return { code: RES_CODE.PASS_NOT_MATCH, message: '密码错误' } } return { code: RES_CODE.SUCCESS, ticket: getAdminTicket(JSON.parse(config.CREDENTIALS)) } } // 获取管理员登录 ticket function getAdminTicket (credentials) { const adminApp = tcb.init({ env: tcb.SYMBOL_CURRENT_ENV, credentials }) const ticket = adminApp.auth().createTicket(ADMIN_USER_ID, { refresh: 3600 * 1000 // access_token的刷新时间 }) return ticket } // 读取评论 async function commentGet (event) { const res = {} try { validate(event, ['url']) const uid = await auth.getEndUserInfo().userInfo.uid const isAdminUser = await isAdmin() const limit = parseInt(config.COMMENT_PAGE_SIZE) || 8 let more = false let condition let query condition = { url: _.in(getUrlQuery(event.url)), rid: _.in(['', null]) } // 查询非垃圾评论 + 自己的评论 query = getCommentQuery({ condition, uid, isAdminUser }) // 读取总条数 const count = await db .collection('comment') .where(query) .count() // 读取主楼 if (event.before) { condition.created = _.lt(event.before) } // 不包含置顶 condition.top = _.neq(true) query = getCommentQuery({ condition, uid, isAdminUser }) const main = await db .collection('comment') .where(query) .orderBy('created', 'desc') // 流式分页,通过多读 1 条的方式,确认是否还有更多评论 .limit(limit + 1) .get() if (main.data.length > limit) { // 还有更多评论 more = true // 删除多读的 1 条 main.data.splice(limit, 1) } let top = [] if (!config.TOP_DISABLED && !event.before) { // 查询置顶评论 query = { ...condition, top: true } top = await db .collection('comment') .where(query) .orderBy('updated', 'desc') .get() // 合并置顶评论和非置顶评论 main.data = [ ...top.data, ...main.data ] } condition = { rid: _.in(main.data.map((item) => item._id)) } query = getCommentQuery({ condition, uid, isAdminUser }) // 读取回复楼 const reply = await db .collection('comment') .where(query) .get() res.data = parseComment([...main.data, ...reply.data], uid) res.more = more res.count = count.total } catch (e) { res.data = [] res.message = e.message } return res } function getCommentQuery ({ condition, uid, isAdminUser }) { return _.or( { ...condition, isSpam: _.neq(isAdminUser ? 'imaegoo' : true) }, { ...condition, uid } ) } // 同时查询 /path 和 /path/ 的评论 function getUrlQuery (url) { const variantUrl = url[url.length - 1] === '/' ? url.substring(0, url.length - 1) : `${url}/` return [url, variantUrl] } // 筛除隐私字段,拼接回复列表 function parseComment (comments, uid) { const result = [] for (const comment of comments) { if (!comment.rid) { const replies = comments .filter((item) => item.rid === comment._id) .map((item) => toCommentDto(item, uid, [], comments)) .sort((a, b) => a.created - b.created) result.push(toCommentDto(comment, uid, replies)) } } return result } // 将评论记录转换为前端需要的格式 function toCommentDto (comment, uid, replies = [], comments = []) { let displayOs = '' let displayBrowser = '' try { const ua = bowser.getParser(comment.ua) const os = ua.getOS() displayOs = [os.name, os.versionName ? os.versionName : os.version].join(' ') displayBrowser = [ua.getBrowserName(), ua.getBrowserVersion()].join(' ') } catch (e) { console.log('bowser 错误:', e) } return { id: comment._id, nick: comment.nick, avatar: comment.avatar, mailMd5: comment.mailMd5 || md5(comment.mail), link: comment.link, comment: comment.comment, os: displayOs, browser: displayBrowser, master: comment.master, like: comment.like ? comment.like.length : 0, liked: comment.like ? comment.like.findIndex((item) => item === uid) > -1 : false, replies: replies, rid: comment.rid, pid: comment.pid, ruser: ruser(comment.pid, comments), top: comment.top, isSpam: comment.isSpam, created: comment.created, updated: comment.updated } } // 获取回复人昵称 / Get replied user nick name function ruser (pid, comments = []) { const comment = comments.find((item) => item._id === pid) return comment ? comment.nick : null } // 管理员读取评论 async function commentGetForAdmin (event) { const res = {} const isAdminUser = await isAdmin() if (isAdminUser) { validate(event, ['per', 'page']) const collection = db .collection('comment') const condition = getCommentSearchCondition(event) const count = await collection .where(condition) .count() const data = await collection .where(condition) .orderBy('created', 'desc') .skip(event.per * (event.page - 1)) .limit(event.per) .get() res.code = RES_CODE.SUCCESS res.count = count.total res.data = parseCommentForAdmin(data.data) } else { res.code = RES_CODE.NEED_LOGIN res.message = '请先登录' } return res } function getCommentSearchCondition (event) { let condition = {} if (event.type) { switch (event.type) { case 'VISIBLE': condition = { isSpam: _.neq(true) } break case 'HIDDEN': condition = { isSpam: true } break } } if (event.keyword) { const regExp = new db.RegExp({ regexp: event.keyword, options: 'i' }) condition = _.or( { ...condition, nick: regExp }, { ...condition, mail: regExp }, { ...condition, link: regExp }, { ...condition, ip: regExp }, { ...condition, comment: regExp }, { ...condition, url: regExp }, { ...condition, href: regExp } ) } return condition } function parseCommentForAdmin (comments) { for (const comment of comments) { comment.commentText = $(comment.comment).text() } return comments } // 管理员修改评论 async function commentSetForAdmin (event) { const res = {} const isAdminUser = await isAdmin() if (isAdminUser) { validate(event, ['id', 'set']) const data = await db .collection('comment') .doc(event.id) .update({ ...event.set, updated: Date.now() }) res.code = RES_CODE.SUCCESS res.updated = data.updated } else { res.code = RES_CODE.NEED_LOGIN res.message = '请先登录' } return res } // 管理员删除评论 async function commentDeleteForAdmin (event) { const res = {} const isAdminUser = await isAdmin() if (isAdminUser) { validate(event, ['id']) const data = await db .collection('comment') .doc(event.id) .delete() res.code = RES_CODE.SUCCESS res.deleted = data.deleted } else { res.code = RES_CODE.NEED_LOGIN res.message = '请先登录' } return res } // 管理员导入评论 async function commentImportForAdmin (event) { const res = {} let logText = '' const log = (message) => { logText += `${new Date().toLocaleString()} ${message}\n` } const isAdminUser = await isAdmin() if (isAdminUser) { try { validate(event, ['source', 'fileId']) log(`开始导入 ${event.source}`) switch (event.source) { case 'valine': { const valineDb = await readFile(event.fileId, 'json', log) await commentImportValine(valineDb, log) break } case 'disqus': { const disqusDb = await readFile(event.fileId, 'xml', log) await commentImportDisqus(disqusDb, log) break } case 'artalk': { const artalkDb = await readFile(event.fileId, 'json', log) await commentImportArtalk(artalkDb, log) break } case 'twikoo': { const twikooDb = await readFile(event.fileId, 'json', log) await commentImportTwikoo(twikooDb, log) break } default: throw new Error(`不支持 ${event.source} 的导入,请更新 Twikoo 云函数至最新版本`) } // 删除导入完成的文件 await app.deleteFile({ fileList: [event.fileId] }) } catch (e) { log(e.message) } res.code = RES_CODE.SUCCESS res.log = logText console.log(logText) } else { res.code = RES_CODE.NEED_LOGIN res.message = '请先登录' } return res } // 读取云存储中的文件并转为 js object async function readFile (fileId, type, log) { try { const result = await app.downloadFile({ fileID: fileId }) log('评论文件下载成功') let content = result.fileContent.toString('utf8') log('评论文件读取成功') if (type === 'json') { content = jsonParse(content) log('评论文件 JSON 解析成功') } else if (type === 'xml') { content = await xml2js.parseStringPromise(content) log('评论文件 XML 解析成功') } return content } catch (e) { log(`评论文件读取失败:${e.message}`) } } // 兼容 Leancloud 两种 JSON 导出格式 function jsonParse (content) { try { return JSON.parse(content) } catch (e1) { const results = [] const lines = content.split('\n') for (const line of lines) { // 逐行 JSON.parse try { results.push(JSON.parse(line)) } catch (e2) {} } return { results } } } // Valine 导入 async function commentImportValine (valineDb, log) { let arr if (valineDb instanceof Array) { arr = valineDb } else if (valineDb && valineDb.results) { arr = valineDb.results } if (!arr) { log('Valine 评论文件格式有误') return } const comments = [] log(`共 ${arr.length} 条评论`) for (const comment of arr) { try { const parsed = { _id: comment.objectId, nick: comment.nick, ip: comment.ip, mail: comment.mail, mailMd5: comment.mailMd5, isSpam: comment.isSpam, ua: comment.ua || '', link: comment.link, pid: comment.pid, rid: comment.rid, master: false, comment: comment.comment, url: comment.url, created: new Date(comment.createdAt).getTime(), updated: new Date(comment.updatedAt).getTime() } comments.push(parsed) log(`${comment.objectId} 解析成功`) } catch (e) { log(`${comment.objectId} 解析失败:${e.message}`) } } log(`解析成功 ${comments.length} 条评论`) const ids = await bulkSaveComments(comments) log(`导入成功 ${ids.length} 条评论`) return comments } // Disqus 导入 async function commentImportDisqus (disqusDb, log) { if (!disqusDb || !disqusDb.disqus || !disqusDb.disqus.thread || !disqusDb.disqus.post) { log('Disqus 评论文件格式有误') return } const comments = [] const getParent = (post) => { return post.parent ? disqusDb.disqus.post.find((item) => item.$['dsq:id'] === post.parent[0].$['dsq:id']) : null } let threads = [] try { threads = disqusDb.disqus.thread.map((thread) => { return { id: thread.$['dsq:id'], title: thread.title[0], url: thread.id[0], href: thread.link[0] } }) } catch (e) { log(`无法读取 thread:${e.message}`) return } log(`共 ${disqusDb.disqus.post.length} 条评论`) for (const post of disqusDb.disqus.post) { try { const threadId = post.thread[0].$['dsq:id'] const thread = threads.find((item) => item.id === threadId) const parent = getParent(post) let root if (parent) { let grandParent = parent while (true) { if (grandParent) root = grandParent else break grandParent = getParent(grandParent) } } comments.push({ _id: post.$['dsq:id'], nick: post.author[0].name[0], mail: '', link: '', url: thread.url ? thread.url.indexOf('http') === 0 ? getRelativeUrl(thread.url) : thread.url : getRelativeUrl(thread.href), href: thread.href, comment: post.message[0], ua: '', ip: '', isSpam: post.isSpam[0] === 'true' || post.isDeleted[0] === 'true', master: false, pid: parent ? parent.$['dsq:id'] : null, rid: root ? root.$['dsq:id'] : null, created: new Date(post.createdAt[0]).getTime(), updated: Date.now() }) log(`${post.$['dsq:id']} 解析成功`) } catch (e) { log(`${post.$['dsq:id']} 解析失败:${e.message}`) } } log(`解析成功 ${comments.length} 条评论`) const ids = await bulkSaveComments(comments) log(`导入成功 ${ids.length} 条评论`) return comments } function getRelativeUrl (url) { let x = url.indexOf('/') for (let i = 0; i < 2; i++) { x = url.indexOf('/', x + 1) } return url.substring(x) } // Artalk 导入 async function commentImportArtalk (artalkDb, log) { const comments = [] if (!artalkDb || !artalkDb.length) { log('Artalk 评论文件格式有误') return } marked.setOptions({ renderer: new marked.Renderer(), gfm: true, tables: true, breaks: true, pedantic: false, sanitize: true, smartLists: true, smartypants: true }) log(`共 ${artalkDb.length} 条评论`) for (const comment of artalkDb) { try { const parsed = { _id: `artalk${comment.id}`, nick: comment.nick, ip: comment.ip, mail: comment.email, mailMd5: md5(comment.email), isSpam: false, ua: comment.ua || '', link: comment.link, pid: comment.rid ? `artalk${comment.rid}` : '', rid: comment.rid ? `artalk${comment.rid}` : '', master: false, comment: DOMPurify.sanitize(marked(comment.content)), url: getRelativeUrl(comment.page_key), href: comment.page_key, created: new Date(comment.date).getTime(), updated: Date.now() } comments.push(parsed) log(`${comment.id} 解析成功`) } catch (e) { log(`${comment.id} 解析失败:${e.message}`) } } log(`解析成功 ${comments.length} 条评论`) const ids = await bulkSaveComments(comments) log(`导入成功 ${ids.length} 条评论`) return comments } // Twikoo 导入 async function commentImportTwikoo (twikooDb, log) { let arr if (twikooDb instanceof Array) { arr = twikooDb } else if (twikooDb && twikooDb.results) { arr = twikooDb.results } if (!arr) { log('Valine 评论文件格式有误') return } const comments = [] log(`共 ${arr.length} 条评论`) for (const comment of arr) { try { const parsed = comment if (comment._id.$oid) { // 解决 id 历史数据问题 parsed._id = comment._id.$oid } comments.push(parsed) log(`${comment.id} 解析成功`) } catch (e) { log(`${comment.id} 解析失败:${e.message}`) } } log(`解析成功 ${comments.length} 条评论`) const ids = await bulkSaveComments(comments) log(`导入成功 ${ids.length} 条评论`) return comments } // 批量导入评论 async function bulkSaveComments (comments) { const batchRes = await db .collection('comment') .add(comments) return batchRes.ids } // 点赞 / 取消点赞 async function commentLike (event) { const res = {} let uid try { validate(event, ['id']) uid = await auth.getEndUserInfo().userInfo.uid } catch (e) { res.message = e.message return res } res.updated = await like(event.id, uid) return res } // 点赞 / 取消点赞 async function like (id, uid) { const record = db .collection('comment') .where({ _id: id }) const comment = await record.get() let likes = comment.data[0] && comment.data[0].like ? comment.data[0].like : [] if (likes.findIndex((item) => item === uid) === -1) { // 赞 likes.push(uid) } else { // 取消赞 likes = likes.filter((item) => item !== uid) } const result = await record.update({ like: likes }) return result.updated } /** * 提交评论。分为多个步骤 * 1. 参数校验 * 2. 预检测垃圾评论(包括限流、人工审核、违禁词检测等) * 3. 保存到数据库 * 4. 触发异步任务(包括 IM 通知、邮件通知、第三方垃圾评论检测 * 等,因为这些任务比较耗时,所以要放在另一个线程进行) * @param {String} event.nick 昵称 * @param {String} event.mail 邮箱 * @param {String} event.link 网址 * @param {String} event.ua UserAgent * @param {String} event.url 评论页地址 * @param {String} event.comment 评论内容 * @param {String} event.pid 回复的 ID * @param {String} event.rid 评论楼 ID */ async function commentSubmit (event) { const res = {} // 参数校验 validate(event, ['url', 'ua', 'comment']) // 限流 await limitFilter() // 预检测、转换 const data = await parse(event) // 保存 const comment = await save(data) res.id = comment.id // 异步垃圾检测、发送评论通知 try { await app.callFunction({ name: 'twikoo', data: { event: 'POST_SUBMIT', comment } }, { timeout: 300 }) // 设置较短的 timeout 来实现异步 } catch (e) { console.log('开始异步垃圾检测、发送评论通知') } return res } // 保存评论 async function save (data) { const result = await db .collection('comment') .add(data) data.id = result.id return data } // 异步垃圾检测、发送评论通知 async function postSubmit (comment, context) { if (!isRecursion(context)) return { code: RES_CODE.FORBIDDEN } // 垃圾检测 await postCheckSpam(comment) // 发送通知 await sendNotice(comment) return { code: RES_CODE.SUCCESS } } // 发送通知 async function sendNotice (comment) { if (comment.isSpam && config.NOTIFY_SPAM === 'false') return await Promise.all([ noticeMaster(comment), noticeReply(comment), noticeWeChat(comment), noticePushPlus(comment), noticeWeComPush(comment), noticeQQ(comment) ]).catch(err => { console.error('邮件通知异常:', err) }) } // 初始化邮件插件 async function initMailer ({ throwErr = false } = {}) { try { if (!config || !config.SMTP_USER || !config.SMTP_PASS) { throw new Error('数据库配置不存在') } const transportConfig = { auth: { user: config.SMTP_USER, pass: config.SMTP_PASS } } if (config.SMTP_SERVICE) { transportConfig.service = config.SMTP_SERVICE } else if (config.SMTP_HOST) { transportConfig.host = config.SMTP_HOST transportConfig.port = parseInt(config.SMTP_PORT) transportConfig.secure = config.SMTP_SECURE === 'true' } else { throw new Error('SMTP 服务器没有配置') } transporter = nodemailer.createTransport(transportConfig) try { const success = await transporter.verify() if (success) console.log('SMTP 邮箱配置正常') } catch (error) { throw new Error('SMTP 邮箱配置异常:', error) } return true } catch (e) { console.error('邮件初始化异常:', e.message) if (throwErr) throw e return false } } // 博主通知 async function noticeMaster (comment) { if (!transporter) if (!await initMailer()) return if (config.BLOGGER_EMAIL === comment.mail) return const IM_PUSH_CONFIGS = [ 'SC_SENDKEY', 'QM_SENDKEY', 'PUSH_PLUS_TOKEN', 'WECOM_API_URL' ] // 判断是否存在即时消息推送配置 const hasIMPushConfig = IM_PUSH_CONFIGS.some(item => !!config[item]) // 存在即时消息推送配置,则默认不发送邮件给博主 if (hasIMPushConfig && config.SC_MAIL_NOTIFY !== 'true') return const SITE_NAME = config.SITE_NAME const NICK = comment.nick const IMG = getAvatar(comment) const IP = comment.ip const MAIL = comment.mail const COMMENT = comment.comment const SITE_URL = config.SITE_URL const POST_URL = appendHashToUrl(comment.href || SITE_URL + comment.url, comment.id) const emailSubject = config.MAIL_SUBJECT_ADMIN || `${SITE_NAME}上有新评论了` let emailContent if (config.MAIL_TEMPLATE_ADMIN) { emailContent = config.MAIL_TEMPLATE_ADMIN .replace(/\${SITE_URL}/g, SITE_URL) .replace(/\${SITE_NAME}/g, SITE_NAME) .replace(/\${NICK}/g, NICK) .replace(/\${IMG}/g, IMG) .replace(/\${IP}/g, IP) .replace(/\${MAIL}/g, MAIL) .replace(/\${COMMENT}/g, COMMENT) .replace(/\${POST_URL}/g, POST_URL) } else { emailContent = ` <div style="border-top:2px solid #12addb;box-shadow:0 1px 3px #aaaaaa;line-height:180%;padding:0 15px 12px;margin:50px auto;font-size:12px;"> <h2 style="border-bottom:1px solid #dddddd;font-size:14px;font-weight:normal;padding:13px 0 10px 8px;"> 您在<a style="text-decoration:none;color: #12addb;" href="${SITE_URL}" target="_blank">${SITE_NAME}</a>上的文章有了新的评论 </h2> <p><strong>${NICK}</strong>回复说:</p> <div style="background-color: #f5f5f5;padding: 10px 15px;margin:18px 0;word-wrap:break-word;">${COMMENT}</div> <p>您可以点击<a style="text-decoration:none; color:#12addb" href="${POST_URL}" target="_blank">查看回复的完整內容</a><br></p> </div>` } let sendResult try { sendResult = await transporter.sendMail({ from: `"${config.SENDER_NAME}" <${config.SENDER_EMAIL}>`, to: config.BLOGGER_EMAIL || config.SENDER_EMAIL, subject: emailSubject, html: emailContent }) } catch (e) { sendResult = e } console.log('博主通知结果:', sendResult) return sendResult } // 微信通知 async function noticeWeChat (comment) { if (!config.SC_SENDKEY) { console.log('没有配置 server 酱,放弃微信通知') return } if (config.BLOGGER_EMAIL === comment.mail) return const pushContent = getIMPushContent(comment) let scApiUrl = 'https://sc.ftqq.com' let scApiParam = { text: pushContent.subject, desp: pushContent.content } if (config.SC_SENDKEY.substring(0, 3).toLowerCase() === 'sct') { // 兼容 server 酱测试专版 scApiUrl = 'https://sctapi.ftqq.com' scApiParam = { title: pushContent.subject, desp: pushContent.content } } const sendResult = await axios.post(`${scApiUrl}/${config.SC_SENDKEY}.send`, qs.stringify(scApiParam), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }) console.log('微信通知结果:', sendResult) } // pushplus 通知 async function noticePushPlus (comment) { if (!config.PUSH_PLUS_TOKEN) { console.log('没有配置 pushplus,放弃通知') return } if (config.BLOGGER_EMAIL === comment.mail) return const pushContent = getIMPushContent(comment) const ppApiUrl = 'http://pushplus.hxtrip.com/send' const ppApiParam = { token: config.PUSH_PLUS_TOKEN, title: pushContent.subject, content: pushContent.content } const sendResult = await axios.post(ppApiUrl, ppApiParam) console.log('pushplus 通知结果:', sendResult) } // 自定义WeCom企业微信api通知 async function noticeWeComPush (comment) { if (!config.WECOM_API_URL) { console.log('未配置 WECOM_API_URL,跳过企业微信推送') return } if (config.BLOGGER_EMAIL === comment.mail) return const SITE_URL = config.SITE_URL const WeComContent = config.SITE_NAME + '有新评论啦!🎉🎉' + '\n\n' + '@' + comment.nick + '说:' + $(comment.comment).text() + '\n' + 'E-mail: ' + comment.mail + '\n' + 'IP: ' + comment.ip + '\n' + '点此查看完整内容:' + appendHashToUrl(comment.href || SITE_URL + comment.url, comment.id) const WeComApiContent = encodeURIComponent(WeComContent) const WeComApiUrl = config.WECOM_API_URL const sendResult = await axios.get(WeComApiUrl + WeComApiContent) console.log('WinxinPush 通知结果:', sendResult) } // QQ通知 async function noticeQQ (comment) { if (!config.QM_SENDKEY) { console.log('没有配置 qmsg 酱,放弃QQ通知') return } if (config.BLOGGER_EMAIL === comment.mail) return const pushContent = getIMPushContent(comment) const qmApiUrl = 'https://qmsg.zendee.cn' const qmApiParam = { msg: pushContent.subject + '\n' + pushContent.content.replace(/<br>/g, '\n') } const sendResult = await axios.post(`${qmApiUrl}/send/${config.QM_SENDKEY}`, qs.stringify(qmApiParam), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }) console.log('QQ通知结果:', sendResult) } // 即时消息推送内容获取 function getIMPushContent (comment) { const SITE_NAME = config.SITE_NAME const NICK = comment.nick const MAIL = comment.mail const IP = comment.ip const COMMENT = $(comment.comment).text() const SITE_URL = config.SITE_URL const POST_URL = appendHashToUrl(comment.href || SITE_URL + comment.url, comment.id) const subject = config.MAIL_SUBJECT_ADMIN || `${SITE_NAME}有新评论了` const content = `评论人:${NICK}(${MAIL})<br>评论人IP:${IP}<br>评论内容:${COMMENT}<br>您可以点击 ${POST_URL} 查看回复的完整內容` return { subject, content } } // 回复通知 async function noticeReply (currentComment) { if (!currentComment.pid) return if (!transporter) if (!await initMailer()) return let parentComment = await db .collection('comment') .where({ _id: currentComment.pid }) .get() parentComment = parentComment.data[0] // 回复给博主,因为会发博主通知邮件,所以不再重复通知 if (config.BLOGGER_EMAIL === parentComment.mail) return // 回复自己的评论,不邮件通知 if (currentComment.mail === parentComment.mail) return const PARENT_NICK = parentComment.nick const IMG = getAvatar(currentComment) const PARENT_IMG = getAvatar(parentComment) const SITE_NAME = config.SITE_NAME const NICK = currentComment.nick const COMMENT = currentComment.comment const PARENT_COMMENT = parentComment.comment const POST_URL = appendHashToUrl(currentComment.href || config.SITE_URL + currentComment.url, currentComment.id) const SITE_URL = config.SITE_URL const emailSubject = config.MAIL_SUBJECT || `${PARENT_NICK},您在『${SITE_NAME}』上的评论收到了回复` let emailContent if (config.MAIL_TEMPLATE) { emailContent = config.MAIL_TEMPLATE .replace(/\${IMG}/g, IMG) .replace(/\${PARENT_IMG}/g, PARENT_IMG) .replace(/\${SITE_URL}/g, SITE_URL) .replace(/\${SITE_NAME}/g, SITE_NAME) .replace(/\${PARENT_NICK}/g, PARENT_NICK) .replace(/\${PARENT_COMMENT}/g, PARENT_COMMENT) .replace(/\${NICK}/g, NICK) .replace(/\${COMMENT}/g, COMMENT) .replace(/\${POST_URL}/g, POST_URL) } else { emailContent = ` <div style="border-top:2px solid #12ADDB;box-shadow:0 1px 3px #AAAAAA;line-height:180%;padding:0 15px 12px;margin:50px auto;font-size:12px;"> <h2 style="border-bottom:1px solid #dddddd;font-size:14px;font-weight:normal;padding:13px 0 10px 8px;"> 您在<a style="text-decoration:none;color: #12ADDB;" href="${SITE_URL}" target="_blank">${SITE_NAME}</a>上的评论有了新的回复 </h2> ${PARENT_NICK} 同学,您曾发表评论: <div style="padding:0 12px 0 12px;margin-top:18px"> <div style="background-color: #f5f5f5;padding: 10px 15px;margin:18px 0;word-wrap:break-word;">${PARENT_COMMENT}</div> <p><strong>${NICK}</strong>回复说:</p> <div style="background-color: #f5f5f5;padding: 10px 15px;margin:18px 0;word-wrap:break-word;">${COMMENT}</div> <p> 您可以点击<a style="text-decoration:none; color:#12addb" href="${POST_URL}" target="_blank">查看回复的完整內容</a>, 欢迎再次光临<a style="text-decoration:none; color:#12addb" href="${SITE_URL}" target="_blank">${SITE_NAME}</a>。<br> </p> </div> </div>` } let sendResult try { sendResult = await transporter.sendMail({ from: `"${config.SENDER_NAME}" <${config.SENDER_EMAIL}>`, to: parentComment.mail, subject: emailSubject, html: emailContent }) } catch (e) { sendResult = e } console.log('回复通知结果:', sendResult) return sendResult } function appendHashToUrl (url, hash) { if (url.indexOf('#') === -1) { return `${url}#${hash}` } else { return `${url.substring(0, url.indexOf('#'))}#${hash}` } } // 将评论转为数据库存储格式 async function parse (comment) { const timestamp = Date.now() const isAdminUser = await isAdmin() const isBloggerMail = comment.mail && comment.mail === config.BLOGGER_EMAIL if (isBloggerMail && !isAdminUser) throw new Error('请先登录管理面板,再使用博主身份发送评论') const commentDo = { uid: await getUid(), nick: comment.nick ? comment.nick : '匿名', mail: comment.mail ? comment.mail : '', mailMd5: comment.mail ? md5(comment.mail) : '', link: comment.link ? comment.link : '', ua: comment.ua, ip: auth.getClientIP(), master: isBloggerMail, url: comment.url, href: comment.href, comment: DOMPurify.sanitize(comment.comment, { FORBID_TAGS: ['style'], FORBID_ATTR: ['style'] }), pid: comment.pid ? comment.pid : comment.rid, rid: comment.rid, isSpam: isAdminUser ? false : preCheckSpam(comment.comment), created: timestamp, updated: timestamp } if (isQQ(comment.mail)) { commentDo.mail = addQQMailSuffix(comment.mail) commentDo.mailMd5 = md5(commentDo.mail) commentDo.avatar = await getQQAvatar(comment.mail) } return commentDo } // 限流 async function limitFilter () { // 限制每个 IP 每 10 分钟发表的评论数量 const limitPerMinute = parseInt(config.LIMIT_PER_MINUTE) if (limitPerMinute) { let count = await db .collection('comment') .where({ ip: auth.getClientIP(), created: _.gt(Date.now() - 600000) }) .count() count = count.total if (count > limitPerMinute) { throw new Error('发言频率过高') } } // 限制所有 IP 每 10 分钟发表的评论数量 const limitPerMinuteAll = parseInt(config.LIMIT_PER_MINUTE_ALL) if (limitPerMinuteAll) { let count = await db .collection('comment') .where({ created: _.gt(Date.now() - 600000) }) .count() count = count.total if (count > limitPerMinuteAll) { throw new Error('评论太火爆啦 >_< 请稍后再试') } } } // 预垃圾评论检测 function preCheckSpam (comment) { if (config.AKISMET_KEY === 'MANUAL_REVIEW') { // 人工审核 console.log('已使用人工审核模式,评论审核后才会发表~') return true } else if (config.FORBIDDEN_WORDS) { // 违禁词检测 for (const forbiddenWord of config.FORBIDDEN_WORDS.split(',')) { if (comment.indexOf(forbiddenWord.trim()) !== -1) { console.log('包含违禁词,直接标记为垃圾评论~') return true } } } return false } // 后垃圾评论检测 async function postCheckSpam (comment) { try { let isSpam if (comment.isSpam) { // 预检测没过的,就不再检测了 isSpam = true } else if (config.QCLOUD_SECRET_ID && config.QCLOUD_SECRET_KEY) { // 腾讯云内容安全 const client = new tencentcloud.tms.v20200713.Client({ credential: { secretId: config.QCLOUD_SECRET_ID, secretKey: config.QCLOUD_SECRET_KEY }, region: 'ap-shanghai', profile: { httpProfile: { endpoint: 'tms.tencentcloudapi.com' } } }) const checkResult = await client.TextModeration({ Content: CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(comment.comment)), Device: { IP: comment.ip }, User: { Nickname: comment.nick } }) console.log('腾讯云返回结果:', checkResult) isSpam = checkResult.EvilFlag !== 0 } else if (config.AKISMET_KEY) { // Akismet const akismetClient = new AkismetClient({ key: config.AKISMET_KEY, blog: config.SITE_URL }) const isValid = await akismetClient.verifyKey() if (!isValid) { console.log('Akismet key 不可用:', config.AKISMET_KEY) return } isSpam = await akismetClient.checkSpam({ user_ip: comment.ip, user_agent: comment.ua, permalink: comment.href, comment_type: comment.rid ? 'reply' : 'comment', comment_author: comment.nick, comment_author_email: comment.mail, comment_author_url: comment.link, comment_content: comment.comment }) } console.log('垃圾评论检测结果:', isSpam) comment.isSpam = isSpam if (isSpam) { await db .collection('comment') .doc(comment.id) .update({ isSpam, updated: Date.now() }) } } catch (err) { console.error('垃圾评论检测异常:', err) } } /** * 获取文章点击量 * @param {String} event.url 文章地址 */ async function counterGet (event) { const res = {} try { validate(event, ['url']) const record = await readCounter(event.url) res.data = record.data[0] ? record.data[0] : {} res.time = res.data ? res.data.time : 0 res.updated = await incCounter(event) } catch (e) { res.message = e.message return res } return res } // 读取阅读数 async function readCounter (url) { return await db .collection('counter') .where({ url }) .get() } /** * 更新阅读数 * @param {String} event.url 文章地址 * @param {String} event.title 文章标题 */ async function incCounter (event) { let result result = await db .collection('counter') .where({ url: event.url }) .update({ title: event.title, time: _.inc(1), updated: Date.now() }) if (result.updated === 0) { result = await db .collection('counter') .add({ url: event.url, title: event.title, time: 1, created: Date.now(), updated: Date.now() }) } return result.updated || result.inserted } /** * 批量获取文章评论数 API * @param {Array} event.urls 不包含协议和域名的文章路径列表,必传参数 * @param {Boolean} event.includeReply 评论数是否包括回复,默认:false */ async function getCommentsCount (event) { const res = {} try { validate(event, ['urls']) const query = {} query.isSpam = _.neq(true) query.url = _.in(getUrlsQuery(event.urls)) if (!event.includeReply) { query.rid = _.in(['', null]) } const result = await db .collection('comment') .aggregate() .match(query) .group({ _id: '$url', count: _.aggregate.sum(1) }) .end() res.data = [] for (const url of event.urls) { const record = result.data.find((item) => item._id === url) res.data.push({ url, count: record ? record.count : 0 }) } } catch (e) { res.message = e.message return res } return res } function getUrlsQuery (urls) { const query = [] for (const url of urls) { if (url) query.push(...getUrlQuery(url)) } return query } /** * 获取最新评论 API * @param {Boolean} event.includeReply 评论数是否包括回复,默认:false */ async function getRecentComments (event) { const res = {} try { const query = {} query.isSpam = _.neq(true) if (!event.includeReply) query.rid = _.in(['', null]) if (event.pageSize > 100) event.pageSize = 100 const result = await db .collection('comment') .where(query) .orderBy('created', 'desc') .limit(event.pageSize || 10) .get() res.data = result.data.map((comment) => { return { id: comment._id, url: comment.url, href: comment.href, nick: comment.nick, avatar: getAvatar(comment), mailMd5: comment.mailMd5 || md5(comment.mail), link: comment.link, comment: comment.comment, commentText: $(comment.comment).text(), created: comment.created } }) } catch (e) { res.message = e.message return res } return res } async function emailTest (event) { const res = {} const isAdminUser = await isAdmin() if (isAdminUser) { try { if (!transporter) { await initMailer({ throwErr: true }) } const sendResult = await transporter.sendMail({ from: config.SENDER_EMAIL, to: event.mail || config.BLOGGER_EMAIL || config.SENDER_EMAIL, subject: 'Twikoo 邮件通知测试邮件', html: '如果您收到这封邮件,说明 Twikoo 邮件功能配置正确' }) res.result = sendResult } catch (e) { res.message = e.message } } else { res.code = RES_CODE.NEED_LOGIN res.message = '请先登录' } return res } function getAvatar (comment) { if (comment.avatar) { return comment.avatar } else { const gravatarCdn = config.GRAVATAR_CDN || 'cn.gravatar.com' const defaultGravatar = config.DEFAULT_GRAVATAR || 'identicon' const mailMd5 = comment.mailMd5 || md5(comment.mail) return `https://${gravatarCdn}/avatar/${mailMd5}?d=${defaultGravatar}` } } function isQQ (mail) { return /^[1-9][0-9]{4,10}$/.test(mail) || /^[1-9][0-9]{4,10}@qq.com$/i.test(mail) } function addQQMailSuffix (mail) { if (/^[1-9][0-9]{4,10}$/.test(mail)) return `${mail}@qq.com` else return mail } async function getQQAvatar (qq) { try { const qqNum = qq.replace(/@qq.com/ig, '') const result = await axios.get(`https://ptlogin2.qq.com/getface?imgtype=4&uin=${qqNum}`) if (result && result.data) { const start = result.data.indexOf('http') const end = result.data.indexOf('"', start) if (start === -1 || end === -1) return null return result.data.substring(start, end) } } catch (e) { console.error('获取 QQ 头像失败:', e) } } function getConfig () { return { code: RES_CODE.SUCCESS, config: { VERSION, SITE_NAME: config.SITE_NAME, SITE_URL: config.SITE_URL, MASTER_TAG: config.MASTER_TAG, COMMENT_BG_IMG: config.COMMENT_BG_IMG, GRAVATAR_CDN: config.GRAVATAR_CDN, DEFAULT_GRAVATAR: config.DEFAULT_GRAVATAR, SHOW_IMAGE: config.SHOW_IMAGE || 'true', IMAGE_CDN: config.IMAGE_CDN, SHOW_EMOTION: config.SHOW_EMOTION || 'true', EMOTION_CDN: config.EMOTION_CDN, COMMENT_PLACEHOLDER: config.COMMENT_PLACEHOLDER, REQUIRED_FIELDS: config.REQUIRED_FIELDS, HIDE_ADMIN_CRYPT: config.HIDE_ADMIN_CRYPT, HIGHLIGHT: config.HIGHLIGHT || 'true', HIGHLIGHT_THEME: config.HIGHLIGHT_THEME } } } async function getConfigForAdmin () { const isAdminUser = await isAdmin() if (isAdminUser) { delete config.CREDENTIALS return { code: RES_CODE.SUCCESS, config } } else { return { code: RES_CODE.NEED_LOGIN, message: '请先登录' } } } // 修改配置 async function setConfig (event) { const isAdminUser = await isAdmin() if (isAdminUser) { writeConfig(event.config) return { code: RES_CODE.SUCCESS } } else { return { code: RES_CODE.NEED_LOGIN, message: '请先登录' } } } // 读取配置 async function readConfig () { try { const res = await db .collection('config') .limit(1) .get() config = res.data[0] || {} return config } catch (e) { console.error('读取配置失败:', e) await createCollections() config = {} return config } } // 写入配置 async function writeConfig (newConfig) { console.log('写入配置:', newConfig) try { let updated let res = await db .collection('config') .where({}) // 不加 where 会报错 Error: param should have required property 'query' .limit(1) .update(newConfig) updated = res.updated if (updated === 0) { res = await db .collection('config') .add(newConfig) updated = res.id ? 1 : 0 } // 更新后重置配置缓存 if (updated > 0) config = null return updated } catch (e) { console.error('写入配置失败:', e) return null } } // 获取用户 ID async function getUid () { const { userInfo } = await auth.getEndUserInfo() return userInfo.uid } // 判断用户是否管理员 async function isAdmin () { const userInfo = await auth.getEndUserInfo() return ADMIN_USER_ID === userInfo.userInfo.customUserId } // 判断是否为递归调用(即云函数调用自身) function isRecursion (context) { const envObj = tcb.getCloudbaseContext(context) return envObj.TCB_SOURCE.substr(-3, 3) === 'scf' } // 建立数据库 collections async function createCollections () { const collections = ['comment', 'config', 'counter'] const res = {} for (const collection of collections) { try { res[collection] = await db.createCollection(collection) } catch (e) { console.error('建立数据库失败:', e) } } return res } // 请求参数校验 function validate (event = {}, requiredParams = []) { for (const requiredParam of requiredParams) { if (!event[requiredParam]) { throw new Error(`参数"${requiredParam}"不合法`) } } }