背景

本地化部署Twikoo后发现时不时闪退,鉴于前端水平太差定位问题太费时间,所以干脆更换了一个评论插件Waline

当前文章只说明CenterOS中直接部署,数据库使用的是mysql。并略做了更改,评论消息推送同时使用邮件及微信推送,默认头像修改为随机生成的头像。如果有其他需要移步:

部署

安装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;

image-20230421171505610

创建数据库及相关表

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显示、登录设备信息

涉及到的微信配置可以参考文章:

修改文件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使其起作用

展示效果

image-20220816115133767

image-20220816115200467

修改评论中的头像为随机头像

直接修改cdn文件(需要自己部署的直接拷贝内容即可)

实现方法

添加依赖

image-20220816115527994

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样式

image-20220816115736230

.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

效果

image-20220816131431085