背景
本地化部署Twikoo后发现时不时闪退,鉴于前端水平太差定位问题太费时间,所以干脆更换了一个评论插件Waline
当前文章只说明CenterOS中直接部署,数据库使用的是mysql。并略做了更改,评论消息推送同时使用邮件及微信推送,默认头像修改为随机生成的头像。如果有其他需要移步:
引用站外地址
一款基于 Valine 衍生的简洁、安全的评论系统
waline
部署
安装yarn(npm着实有点慢)
npm install -g yarn
安装Waline
yarn add @waline/vercel
docker安装
version: '3'
services:
waline:
container_name: waline
image: lizheming/waline:latest
restart: always
ports:
- 127.0.0.1:8360:8360
volumes:
- ${PWD}/data:/app/data
environment:
TZ: 'Asia/Shanghai'
MYSQL_HOST: mysql
MYSQL_PORT: 3306
MYSQL_DB: 数据库名
MYSQL_USER: 数据库账号
MYSQL_PASSWORD: 数据库密码
SQLITE_PATH: '/app/data'
JWT_TOKEN: 'Your token'
SITE_NAME: 'Your site name'
SITE_URL: 'https://example.com'
SECURE_DOMAINS: 'example.com'
AUTHOR_EMAIL: 'mail@example.com'
借助forever(保证后台启动并监控其状态)
安装
npm install forever -g
查看运行进程
forever list
配置环境变量(目前只使用了邮件及微信推送,mysql数据库。将下方中文内容修改为自己的实际信息后执行即可)
echo " ">>/etc/profile
echo "# Made for Waline env by chenqi on $(date +%F)">>/etc/profile
echo 'export MYSQL_DB=数据库名称'>>/etc/profile
echo 'export MYSQL_USER=数据库连接账号'>>/etc/profile
echo 'export MYSQL_PASSWORD=数据库连接密码'>>/etc/profile
echo 'export SMTP_SERVICE=邮件服务器'>>/etc/profile
echo 'export SMTP_USER=邮件服务器账号(一般为邮箱号)'>>/etc/profile
echo 'export SMTP_PASS=邮件服务器密码(多数需要开启邮箱中三方登录,使用其提供的密码)'>>/etc/profile
echo 'export SITE_NAME=网站名称'>>/etc/profile
echo 'export SITE_URL=网站链接'>>/etc/profile
echo 'export AUTHOR_EMAIL=接收邮件推送的邮箱'>>/etc/profile
echo 'export QYWX_AM=企业id,应用密码,需要推送的人(@all指所有人),应用id,推送消息缩略图(素材库的图片的media_id)'>>/etc/profile
echo 'export SENDER_NAME=发送邮件时显示的名称'>>/etc/profile
tail -4 /etc/profile
source /etc/profile
echo $PATH
高版本mysql客户端身份验证问题
请求出现ER_NOT_SUPPORTED_AUTH_MODE: Client does not support authentication protocol requested by server; consider upgrading MySQL client
# 连接对应的容器,尖括号内记得替换
docker exec -it <mysql_container_id_or_name> mysql -uroot -p
ALTER USER 'your_user'@'%' IDENTIFIED WITH mysql_native_password BY 'your_password';
FLUSH PRIVILEGES;
创建数据库及相关表
CREATE TABLE `wl_Comment` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(11) DEFAULT NULL,
`comment` text,
`insertedAt` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`ip` varchar(100) DEFAULT '',
`link` varchar(255) DEFAULT NULL,
`mail` varchar(255) DEFAULT NULL,
`nick` varchar(255) DEFAULT NULL,
`pid` int(11) DEFAULT NULL,
`rid` int(11) DEFAULT NULL,
`sticky` boolean DEFAULT NULL,
`status` varchar(50) NOT NULL DEFAULT '',
`like` int(11) DEFAULT NULL,
`ua` text,
`url` varchar(255) DEFAULT NULL,
`createdAt` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
# Dump of table wl_Counter
# ------------------------------------------------------------
CREATE TABLE `wl_Counter` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`time` int(11) DEFAULT NULL,
`url` varchar(255) NOT NULL DEFAULT '',
`createdAt` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
# Dump of table wl_Users
# ------------------------------------------------------------
CREATE TABLE `wl_Users` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`display_name` varchar(255) NOT NULL DEFAULT '',
`email` varchar(255) NOT NULL DEFAULT '',
`password` varchar(255) NOT NULL DEFAULT '',
`type` varchar(50) NOT NULL DEFAULT '',
`label` varchar(255) DEFAULT NULL,
`url` varchar(255) DEFAULT NULL,
`avatar` varchar(255) DEFAULT NULL,
`github` varchar(255) DEFAULT NULL,
`twitter` varchar(255) DEFAULT NULL,
`facebook` varchar(255) DEFAULT NULL,
`google` varchar(255) DEFAULT NULL,
`weibo` varchar(255) DEFAULT NULL,
`qq` varchar(255) DEFAULT NULL,
`2fa` varchar(32) DEFAULT NULL,
`createdAt` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
启动Waline(cd到安装目录下)
# 启动
forever start -l forever.log -o out.log -e err.log node_modules/@waline/vercel/vanilla.js
# 重启
forever restart -l forever.log -o out.log -e err.log node_modules/@waline/vercel/vanilla.js
# 停止
forever stop -l forever.log -o out.log -e err.log node_modules/@waline/vercel/vanilla.js
修改邮件推送和微信推送同时起作用,并添加ip显示、登录设备信息
涉及到的微信配置可以参考文章:
本站相关文章
博客评论系统Twikoo本地化部署
allbs
修改文件comment.js(路径node_modules/@waline/vercel/src/controller/comment.js)
const parser = require('ua-parser-js');
const BaseRest = require('./rest');
const akismet = require('../service/akismet');
const { getMarkdownParser } = require('../service/markdown');
const markdownParser = getMarkdownParser();
async function formatCmt(
{ ua, user_id, ip, ...comment },
users = [],
{ avatarProxy },
loginUser
) {
ua = parser(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 }) => 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 && 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 (!isAdmin) {
delete comment.mail;
} else {
comment.orig = comment.comment;
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;
return comment;
}
module.exports = class extends BaseRest {
constructor(ctx) {
super(ctx);
this.modelInstance = this.service(
`storage/${this.config('storage')}`,
'Comment'
);
}
async getAction() {
const { type } = this.get();
const { userInfo } = this.ctx.state;
switch (type) {
case 'recent': {
const { count } = this.get();
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.service(
`storage/${this.config('storage')}`,
'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 this.json(
await Promise.all(
comments.map((cmt) =>
formatCmt(cmt, users, this.config(), userInfo)
)
)
);
}
case 'count': {
const { url } = this.get();
const where = { 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,
};
}
const data = await this.modelInstance.select(where, { field: ['url'] });
const counts = url.map(
(u) => data.filter(({ url }) => url === u).length
);
return this.json(counts.length === 1 ? counts[0] : counts);
}
case 'list': {
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.service(
`storage/${this.config('storage')}`,
'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 this.success({
page,
totalPages: Math.ceil(count / pageSize),
pageSize,
spamCount,
waitingCount,
data: await Promise.all(
comments.map((cmt) =>
formatCmt(cmt, users, this.config(), userInfo)
)
),
});
}
default: {
const { path: url, page, pageSize } = 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 = {
desc: 'insertedAt',
field: [
'status',
'comment',
'insertedAt',
'link',
'mail',
'nick',
'pid',
'rid',
'ua',
'ip',
'user_id',
'sticky',
'like',
],
};
/**
* 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 commments, 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 excute 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 {
rootComments = await this.modelInstance.select(
{ ...where, rid: undefined },
{
...selectOptions,
offset: pageOffset,
limit: pageSize,
}
);
const children = await this.modelInstance.select(
{
...where,
rid: ['IN', rootComments.map(({ objectId }) => objectId)],
},
selectOptions
);
comments = [...rootComments, ...children];
rootCount = await this.modelInstance.count({
...where,
rid: undefined,
});
}
const userModel = this.service(
`storage/${this.config('storage')}`,
'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 }) => {
if (cmt.user_id) {
return user_id === cmt.user_id;
}
return mail === cmt.mail;
});
let level = 0;
if (countItem) {
const _level = think.findLastIndex(
this.config('levels'),
(l) => l <= countItem.count
);
if (_level !== -1) {
level = _level;
}
}
cmt.level = level;
});
}
return this.json({
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(),
userInfo
);
cmt.children = await Promise.all(
comments
.filter(({ rid }) => rid === cmt.objectId)
.map((cmt) => formatCmt(cmt, users, this.config(), userInfo))
.reverse()
);
return cmt;
})
),
});
}
}
}
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) {
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 Frequence */
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} seconeds.`);
return this.fail(this.locale('Comment too fast!'));
}
think.logger.debug(`Comment post frequence check OK!`);
/** Akismet */
const { COMMENT_AUDIT, AUTHOR_EMAIL, BLOGGER_EMAIL } = process.env;
const AUTHOR = AUTHOR_EMAIL || BLOGGER_EMAIL;
const isAuthorComment = AUTHOR
? data.mail.toLowerCase() === AUTHOR.toLowerCase()
: false;
data.status = COMMENT_AUDIT && !isAuthorComment ? '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.model('User').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(),
userInfo
);
const parentReturn = parentComment
? await formatCmt(
parentComment,
parentUser ? [parentUser] : [],
this.config(),
userInfo
)
: undefined;
if (comment.status !== 'spam') {
const notify = this.service('notify');
await notify.run(
{
...cmtReturn,
mail: resp.mail,
rawComment: comment,
ip: data.ip,
equip: data.ua,
},
parentReturn
? { ...parentReturn, mail: parentComment.mail }
: undefined,
false
);
}
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(), userInfo)
);
}
async putAction() {
const { userInfo } = this.ctx.state;
let data = this.post();
let oldData = await this.modelInstance.select({ objectId: this.id });
if (think.isEmpty(oldData)) {
return this.success();
}
oldData = oldData[0];
if (think.isEmpty(userInfo) || userInfo.type !== 'administrator') {
if (!think.isBoolean(data.like)) {
return this.success();
}
const likeIncMax = this.config('LIKE_INC_MAX') || 1;
data = {
like:
(Number(oldData.like) || 0) +
(data.like ? Math.ceil(Math.random() * likeIncMax) : -1),
};
}
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,
});
if (
oldData.status === 'waiting' &&
data.status === 'approved' &&
oldData.pid
) {
let cmtUser;
if (newData.user_id) {
cmtUser = await this.model('User').select({
objectId: newData.user_id,
});
cmtUser = cmtUser[0];
}
let pComment = await this.modelInstance.select({
objectId: oldData.pid,
});
pComment = pComment[0];
let pUser;
if (pComment.user_id) {
pUser = await this.model('User').select({
objectId: pComment.user_id,
});
pUser = pUser[0];
}
const notify = this.service('notify');
const cmtReturn = await formatCmt(
newData,
cmtUser ? [cmtUser] : [],
this.config(),
userInfo
);
const pcmtReturn = await formatCmt(
pComment,
pUser ? [pUser] : [],
this.config(),
userInfo
);
await notify.run(
{ ...cmtReturn, mail: newData.mail, ip: data.ip, equip: data.ua },
{ ...pcmtReturn, mail: pComment.mail },
true
);
}
await this.hook('postUpdate', data);
return this.success();
}
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();
}
};
修改文件notify.js(路径node_modules/@waline/vercel/src/service/notify.js)
const FormData = require('form-data');
const nodemailer = require('nodemailer');
const fetch = require('node-fetch');
const nunjucks = require('nunjucks');
module.exports = class extends think.Service {
constructor(...args) {
super(...args);
const {
SMTP_USER,
SMTP_PASS,
SMTP_HOST,
SMTP_PORT,
SMTP_SECURE,
SMTP_SERVICE,
} = process.env;
if (SMTP_HOST || SMTP_SERVICE) {
const config = {
auth: { user: SMTP_USER, pass: SMTP_PASS },
};
if (SMTP_SERVICE) {
config.service = SMTP_SERVICE;
} else {
config.host = SMTP_HOST;
config.port = parseInt(SMTP_PORT);
config.secure = SMTP_SECURE !== 'false';
}
this.transporter = nodemailer.createTransport(config);
}
}
async sleep(second) {
return new Promise((resolve) => setTimeout(resolve, second * 1000));
}
async mail({ to, title, content }, self, parent) {
if (!this.transporter) {
return;
}
try {
const success = await this.transporter.verify();
if (success) {
console.log('SMTP 邮箱配置正常');
}
} catch (error) {
throw new Error('SMTP 邮箱配置异常:', error);
}
const { SITE_NAME, SITE_URL, SMTP_USER, SENDER_EMAIL, SENDER_NAME } =
process.env;
const data = {
self,
parent,
site: {
name: SITE_NAME,
url: SITE_URL,
postUrl: SITE_URL + self.url + '#' + self.objectId,
},
};
title = nunjucks.renderString(title, data);
content = nunjucks.renderString(content, data);
let sendResult;
// eslint-disable-next-line no-empty
try {
sendResult = this.transporter.sendMail({
from:
SENDER_EMAIL && SENDER_NAME
? `"${SENDER_NAME}" <${SENDER_EMAIL}>`
: SMTP_USER,
to,
subject: title,
html: content,
});
} catch (e) {
sendResult = e;
}
console.log('邮件通知结果:', sendResult);
return sendResult;
}
async wechat({ title, content }, self, parent) {
const { SC_KEY, SITE_NAME, SITE_URL } = process.env;
if (!SC_KEY) {
return false;
}
const data = {
self,
parent,
site: {
name: SITE_NAME,
url: SITE_URL,
postUrl: SITE_URL + self.url + '#' + self.objectId,
},
};
title = nunjucks.renderString(title, data);
content = nunjucks.renderString(content, data);
const form = new FormData();
form.append('text', title);
form.append('desp', content);
return fetch(`https://sctapi.ftqq.com/${SC_KEY}.send`, {
method: 'POST',
headers: form.getHeaders(),
body: form,
}).then((resp) => resp.json());
}
async qywxAmWechat({ title, content }, self, parent) {
const { QYWX_AM, SITE_NAME, SITE_URL } = process.env;
if (!QYWX_AM) {
return false;
}
const QYWX_AM_AY = QYWX_AM.split(',');
const comment = self.comment
.replace(/<a href="(.*?)">(.*?)<\/a>/g, '\n[$2] $1\n')
.replace(/<[^>]+>/g, '');
const postName = self.url;
const data = {
self: {
...self,
comment,
},
postName,
parent,
site: {
name: SITE_NAME,
url: SITE_URL,
postUrl: SITE_URL + self.url + '#' + self.objectId,
},
};
const contentWechat =
think.config('WXTemplate') ||
`💬 网站{{site.name|safe}}中文章《{{postName}}》有新的评论
【评论者昵称】:{{self.nick}}
【评论者邮箱】:{{self.mail}}
【评论者IP】:{{self.ip}} ({{self.addr}})
【登陆设备】:{{self.browser}}
【内容】:{{self.comment}}
<a href='{{site.postUrl}}'>查看详情</a>`;
title = nunjucks.renderString(title, data);
const desp = nunjucks.renderString(contentWechat, data);
content = desp.replace(/\n/g, '<br/>');
const querystring = new URLSearchParams();
querystring.set('corpid', `${QYWX_AM_AY[0]}`);
querystring.set('corpsecret', `${QYWX_AM_AY[1]}`);
const { access_token } = await fetch(
`https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${QYWX_AM_AY[0]}&corpsecret=${QYWX_AM_AY[1]}`,
{
method: 'GET',
}
).then((resp) => resp.json());
// 发送消息
return fetch(
`https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${access_token}`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
touser: `${QYWX_AM_AY[2]}`,
agentid: `${QYWX_AM_AY[3]}`,
msgtype: 'mpnews',
mpnews: {
articles: [
{
title,
thumb_media_id: `${QYWX_AM_AY[4]}`,
author: `Waline Comment`,
content_source_url: `${data.site.postUrl}`,
content: `${content}`,
digest: `${desp}`,
},
],
},
text: { content: `${content}` },
}),
}
).then((resp) => resp.json());
}
async qq(self, parent) {
const { QMSG_KEY, QQ_ID, SITE_NAME, SITE_URL } = process.env;
if (!QMSG_KEY) {
return false;
}
const comment = self.comment
.replace(/<a href="(.*?)">(.*?)<\/a>/g, '')
.replace(/<[^>]+>/g, '');
const data = {
self: {
...self,
comment,
},
parent,
site: {
name: SITE_NAME,
url: SITE_URL,
postUrl: SITE_URL + self.url + '#' + self.objectId,
},
};
const contentQQ =
think.config('QQTemplate') ||
`💬 {{site.name|safe}} 有新评论啦
{{self.nick}} 评论道:
{{self.comment}}
仅供预览评论,请前往上述页面查看完整內容。`;
const form = new FormData();
form.append('msg', nunjucks.renderString(contentQQ, data));
form.append('qq', QQ_ID);
return fetch(`https://qmsg.zendee.cn/send/${QMSG_KEY}`, {
method: 'POST',
header: form.getHeaders(),
body: form,
}).then((resp) => resp.json());
}
async telegram(self, parent) {
const { TG_BOT_TOKEN, TG_CHAT_ID, SITE_NAME, SITE_URL } = process.env;
if (!TG_BOT_TOKEN || !TG_CHAT_ID) {
return false;
}
let commentLink = '';
const href = self.comment.match(/<a href="(.*?)">(.*?)<\/a>/g);
if (href !== null) {
for (var i = 0; i < href.length; i++) {
href[i] =
'[Link: ' +
href[i].replace(/<a href="(.*?)">(.*?)<\/a>/g, '$2') +
'](' +
href[i].replace(/<a href="(.*?)">(.*?)<\/a>/g, '$1') +
') ';
commentLink = commentLink + href[i];
}
}
if (commentLink !== '') {
commentLink = `\n` + commentLink + `\n`;
}
const comment = self.comment
.replace(/<a href="(.*?)">(.*?)<\/a>/g, '[Link:$2]')
.replace(/<[^>]+>/g, '');
const contentTG =
think.config('TGTemplate') ||
`💬 *[{{site.name}}]({{site.url}}) 有新评论啦*
*{{self.nick}}* 回复说:
\`\`\`
{{self.comment-}}
\`\`\`
{{-self.commentLink}}
*邮箱:*\`{{self.mail}}\`
*审核:*{{self.status}}
仅供评论预览,点击[查看完整內容]({{site.postUrl}})`;
const data = {
self: {
...self,
comment,
commentLink,
},
parent,
site: {
name: SITE_NAME,
url: SITE_URL,
postUrl: SITE_URL + self.url + '#' + self.objectId,
},
};
const form = new FormData();
form.append('text', nunjucks.renderString(contentTG, data));
form.append('chat_id', TG_CHAT_ID);
form.append('parse_mode', 'MarkdownV2');
return fetch(`https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage`, {
method: 'POST',
header: form.getHeaders(),
body: form,
}).then((resp) => resp.json());
}
async pushplus({ title, content }, self, parent) {
const {
PUSH_PLUS_KEY,
PUSH_PLUS_TOPIC: topic,
PUSH_PLUS_TEMPLATE: template,
PUSH_PLUS_CHANNEL: channel,
PUSH_PLUS_WEBHOOK: webhook,
PUSH_PLUS_CALLBACKURL: callbackUrl,
SITE_NAME,
SITE_URL,
} = process.env;
if (!PUSH_PLUS_KEY) {
return false;
}
const data = {
self,
parent,
site: {
name: SITE_NAME,
url: SITE_URL,
postUrl: SITE_URL + self.url + '#' + self.objectId,
},
};
title = nunjucks.renderString(title, data);
content = nunjucks.renderString(content, data);
const form = new FormData();
form.append('topic', topic);
form.append('template', template);
form.append('channel', channel);
form.append('webhook', webhook);
form.append('callbackUrl', callbackUrl);
form.append('title', title);
form.append('content', content);
return fetch(`http://www.pushplus.plus/send/${PUSH_PLUS_KEY}`, {
method: 'POST',
header: form.getHeaders(),
body: form,
}).then((resp) => resp.json());
}
async discord({ title, content }, self, parent) {
const { DISCORD_WEBHOOK, SITE_NAME, SITE_URL } = process.env;
if (!DISCORD_WEBHOOK) {
return false;
}
const data = {
self,
parent,
site: {
name: SITE_NAME,
url: SITE_URL,
postUrl: SITE_URL + self.url + '#' + self.objectId,
},
};
title = nunjucks.renderString(title, data);
content = nunjucks.renderString(
think.config('DiscordTemplate') ||
`💬 {{site.name|safe}} 有新评论啦
【评论者昵称】:{{self.nick}}
【评论者邮箱】:{{self.mail}}
【内容】:{{self.comment}}
【地址】:{{site.postUrl}}`,
data
);
const form = new FormData();
form.append('content', `${title}\n${content}`);
return fetch(DISCORD_WEBHOOK, {
method: 'POST',
header: form.getHeaders(),
body: form,
}).then((resp) => resp.json());
}
async run(comment, parent, disableAuthorNotify = false) {
const { AUTHOR_EMAIL, BLOGGER_EMAIL, DISABLE_AUTHOR_NOTIFY } = process.env;
const { mailSubject, mailTemplate, mailSubjectAdmin, mailTemplateAdmin } =
think.config();
const AUTHOR = AUTHOR_EMAIL || BLOGGER_EMAIL;
const mailList = [];
const isAuthorComment = AUTHOR
? comment.mail.toLowerCase() === AUTHOR.toLowerCase()
: false;
const isReplyAuthor = AUTHOR
? parent && parent.mail.toLowerCase() === AUTHOR.toLowerCase()
: false;
const title = mailSubjectAdmin || '{{site.name | safe}} 上有新评论了';
const content =
mailTemplateAdmin ||
`
<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 #DDD;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>上的文章有了来自{{self.ip}}({{self.addr}})的新评论,登陆设备{{self.browser}}
</h2>
<p><strong>{{self.nick}}({{self.mail}})</strong>回复说:</p>
<div style="background-color: #f5f5f5;padding: 10px 15px;margin:18px 0;word-wrap:break-word;">
{{self.comment | safe}}
</div>
<p>您可以点击<a style="text-decoration:none; color:#12addb" href="{{site.postUrl}}" target="_blank">查看回复的完整內容</a></p>
<br/>
</div>`;
if (!DISABLE_AUTHOR_NOTIFY && !isAuthorComment && !disableAuthorNotify) {
const wechat = await this.wechat({ title, content }, comment, parent);
const qywxAmWechat = await this.qywxAmWechat(
{ title, content },
comment,
parent
);
const qq = await this.qq(comment, parent);
const telegram = await this.telegram(comment, parent);
const pushplus = await this.pushplus({ title, content }, comment, parent);
const discord = await this.discord({ title, content }, comment, parent);
mailList.push({ to: AUTHOR, title, content });
if (
[wechat, qq, telegram, qywxAmWechat, pushplus, discord].every(
think.isEmpty
) &&
!isReplyAuthor
) {
mailList.push({ to: AUTHOR, title, content });
}
}
const disallowList = ['github', 'twitter', 'facebook'].map(
(social) => 'mail.' + social
);
const fakeMail = new RegExp(`@(${disallowList.join('|')})$`, 'i');
if (parent && !fakeMail.test(parent.mail) && comment.status !== 'waiting') {
mailList.push({
to: parent.mail,
title:
mailSubject ||
'{{parent.nick | safe}},『{{site.name | safe}}』上的评论收到了回复',
content:
mailTemplate ||
`
<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 #DDD;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 | safe}}</div>
<p><strong>{{self.nick}}</strong>回复说:</p>
<div style="background-color: #f5f5f5;padding: 10px 15px;margin:18px 0;word-wrap:break-word;">{{self.comment | safe}}</div>
<p>您可以点击<a style="text-decoration:none; color:#12addb" href="{{site.postUrl}}" target="_blank">查看回复的完整內容</a>,欢迎再次光临<a style="text-decoration:none; color:#12addb" href="{{site.url}}" target="_blank">{{site.name}}</a>。</p>
<br/>
</div>
</div>`
});
}
for (let i = 0; i < mailList.length; i++) {
try {
const response = await this.mail(mailList[i], comment, parent);
console.log('Notification mail send success: %s', response);
} catch (e) {
console.log('Mail send fail:', e);
}
}
}
};
重启Waline使其起作用
展示效果
修改评论中的头像为随机头像
直接修改cdn文件(需要自己部署的直接拷贝内容即可)
引用站外地址
Waline随机头像css
css
引用站外地址
Waline随机头像js
js
实现方法
添加依赖
npm install @multiavatar/multiavatar -S
更改CommentCard.vue
<template>
<div :id="comment.objectId" class="wl-item">
<div class="wl-user" aria-hidden="true">
<img
v-if="
comment.avatar &&
!comment.avatar.startsWith('https://seccdn.libravatar.org/')
"
:src="comment.avatar"
alt=""
/>
<div class="avatar" v-else>
<div v-html="avatar" class="avatar-block"></div>
</div>
<VerifiedIcon v-if="comment.type" />
</div>
<div class="wl-card">
<div class="wl-head">
<a
v-if="link"
class="wl-nick"
:href="link"
target="_blank"
rel="nofollow noreferrer"
>{{ comment.nick }}</a
>
<span v-else class="wl-nick">{{ comment.nick }}</span>
<span
v-if="comment.type === 'administrator'"
class="wl-badge"
v-text="locale.admin"
/>
<span v-if="comment.label" class="wl-badge" v-text="comment.label" />
<span v-if="comment.sticky" class="wl-badge" v-text="locale.sticky" />
<span
v-if="comment.level !== undefined && comment.level >= 0"
:class="`wl-badge level${comment.level}`"
v-text="locale[`level${comment.level}`] || `Level ${comment.level}`"
/>
<span class="wl-time" v-text="time" />
<div class="wl-comment-actions">
<button
v-if="isAdmin || isOwner"
class="wl-delete"
@click="$emit('delete', comment)"
>
<DeleteIcon />
</button>
<button
class="wl-like"
:title="like ? locale.cancelLike : locale.like"
@click="$emit('like', comment)"
>
<LikeIcon :active="like" />
<span v-if="'like' in comment" v-text="comment.like" />
</button>
<button
class="wl-reply"
:class="{ active: isReplyingCurrent }"
:title="isReplyingCurrent ? locale.cancelReply : locale.reply"
@click="$emit('reply', isReplyingCurrent ? null : comment)"
>
<ReplyIcon />
</button>
</div>
</div>
<div class="wl-meta" aria-hidden="true">
<span v-if="comment.addr" v-text="comment.addr" />
<span v-if="comment.browser" v-text="comment.browser" />
<span v-if="comment.os" v-text="comment.os" />
</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="wl-content" v-html="comment.comment" />
<div v-if="isAdmin" class="wl-admin-actions">
<span class="wl-comment-status">
<button
v-for="status in commentStatus"
:key="status"
:class="`wl-btn wl-${status}`"
:disabled="comment.status === status"
@click="$emit('status', { status, comment })"
v-text="locale[status]"
/>
</span>
<button
v-if="isAdmin && !comment.rid"
class="wl-btn wl-sticky"
@click="$emit('sticky', comment)"
>
{{ comment.sticky ? locale.unsticky : locale.sticky }}
</button>
</div>
<div v-if="isReplyingCurrent" class="wl-reply-wrapper">
<CommentBox
:reply-id="comment.objectId"
:reply-user="comment.nick"
:root-id="rootId"
@submit="$emit('submit', $event)"
@cancel-reply="$emit('reply', null)"
/>
</div>
<div v-if="comment.children" class="wl-quote">
<CommentCard
v-for="child in comment.children"
:key="child.objectId"
:comment="child"
:reply="reply"
:root-id="rootId"
@reply="$emit('reply', $event)"
@submit="$emit('submit', $event)"
@like="$emit('like', $event)"
@delete="$emit('delete', $event)"
@status="$emit('status', $event)"
@sticky="$emit('sticky', $event)"
/>
</div>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, inject } from 'vue';
import CommentBox from './CommentBox.vue';
import { DeleteIcon, LikeIcon, ReplyIcon, VerifiedIcon } from './Icons';
import { isLinkHttp } from '../utils';
import { useTimeAgo, useLikeStorage, useUserInfo } from '../composables';
import type { ComputedRef, PropType } from 'vue';
import type { WalineConfig } from '../utils';
import type { WalineComment, WalineCommentStatus } from '../typings';
import multiavatar from '@multiavatar/multiavatar';
const commentStatus: WalineCommentStatus[] = ['approved', 'waiting', 'spam'];
export default defineComponent({
components: {
CommentBox,
DeleteIcon,
LikeIcon,
ReplyIcon,
VerifiedIcon,
},
props: {
comment: {
type: Object as PropType<WalineComment>,
required: true,
},
rootId: {
type: String,
required: true,
},
reply: {
type: Object as PropType<WalineComment | null>,
default: null,
},
},
emits: ['submit', 'reply', 'like', 'delete', 'status', 'sticky'],
setup(props) {
const config = inject<ComputedRef<WalineConfig>>(
'config'
) as ComputedRef<WalineConfig>;
const likes = useLikeStorage();
const userInfo = useUserInfo();
const locale = computed(() => config.value.locale);
const link = computed(() => {
const { link } = props.comment;
return link ? (isLinkHttp(link) ? link : `https://${link}`) : '';
});
const like = computed(() => likes.value.includes(props.comment.objectId));
const time = useTimeAgo(props.comment.insertedAt, locale.value);
const isAdmin = computed(() => userInfo.value.type === 'administrator');
const isOwner = computed(
() =>
props.comment.user_id &&
userInfo.value.objectId === props.comment.user_id
);
const isReplyingCurrent = computed(
() => props.comment.objectId === props.reply?.objectId
);
// 根据用户输入邮箱账号生成唯一头像,如果没有输入邮箱则随机生成,所以下面排除空值生成的md5
const avatar = multiavatar(
!props.comment.avatar.startsWith(
'https://seccdn.libravatar.org/avatar/d41d8cd98f00b204e9800998ecf8427e'
)
? props.comment.avatar
: (((1 + Math.random()) * 0x100000000) | 0).toString(16).substring(1)
);
return {
config,
locale,
isReplyingCurrent,
link,
like,
time,
isAdmin,
isOwner,
commentStatus,
avatar,
};
},
});
</script>
添加css样式
.avatar {
text-align: center;
max-width: 100%;
max-height: 400px;
border: none;
.avatar-block {
height: var(--avatar-size);
width: var(--avatar-size);
border-radius: var(--waline-avatar-radius);
box-shadow: var(--waline-box-shadow);
}
}
打包发布(得到的dist中的Waline.css及Waline.js即上方的cdn文件内容)
pnpm run build
效果
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 allbs!
评论