├── .eslintignore ├── .dockerignore ├── .npmrc ├── . dockerignore ├── app ├── lib │ └── plugin │ │ ├── egg-mailer │ │ ├── package.json │ │ ├── app.js │ │ ├── agent.js │ │ ├── config │ │ │ └── config.default.js │ │ └── lib │ │ │ └── mailer.js │ │ └── egg-akismet │ │ ├── package.json │ │ ├── app.js │ │ ├── config │ │ └── config.default.js │ │ ├── agent.js │ │ └── lib │ │ └── akismet.js ├── service │ ├── moment.js │ ├── sentry.js │ ├── tag.js │ ├── category.js │ ├── mail.js │ ├── seo.js │ ├── auth.js │ ├── github.js │ ├── akismet.js │ ├── proxy.js │ ├── setting.js │ ├── user.js │ ├── comment.js │ ├── notification.js │ ├── article.js │ ├── stat.js │ └── agent.js ├── utils │ ├── encode.js │ ├── share.js │ ├── gravatar.js │ ├── validate.js │ └── markdown.js ├── schedule │ ├── links.js │ ├── voice.js │ ├── personal.js │ ├── music.js │ └── backup.js ├── model │ ├── tag.js │ ├── category.js │ ├── moment.js │ ├── user.js │ ├── stat.js │ ├── notification.js │ ├── comment.js │ ├── setting.js │ └── article.js ├── router.js ├── middleware │ ├── error.js │ ├── gzip.js │ ├── headers.js │ └── auth.js ├── controller │ ├── agent.js │ ├── stat.js │ ├── user.js │ ├── setting.js │ ├── moment.js │ ├── tag.js │ ├── auth.js │ ├── category.js │ ├── notification.js │ ├── article.js │ └── comment.js ├── router │ ├── frontend.js │ └── backend.js └── extend │ ├── context.js │ └── application.js ├── .gitignore ├── .editorconfig ├── .travis.yml ├── config ├── plugin.prod.js ├── config.prod.js ├── config.local.js ├── plugin.js └── config.default.js ├── appveyor.yml ├── .eslintrc ├── Dockerfile ├── init.d └── mongo │ └── init.js ├── .autod.conf.js ├── docker-compose.dev.yml ├── app.js ├── docker-compose.yml ├── .release-it.json ├── test └── app │ └── service │ ├── tag.test.js │ └── category.test.js ├── package.json ├── emails ├── comment.pug └── markdown.css └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .git/ 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry = https://registry.npm.taobao.org -------------------------------------------------------------------------------- /. dockerignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | npm-debug.log* 3 | .nuxt/ 4 | 5 | .vscode 6 | .idea 7 | node_modules -------------------------------------------------------------------------------- /app/lib/plugin/egg-mailer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "eggPlugin": { 3 | "name": "mailer" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /app/lib/plugin/egg-akismet/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "eggPlugin": { 3 | "name": "akismet" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /app/lib/plugin/egg-mailer/app.js: -------------------------------------------------------------------------------- 1 | module.exports = app => { 2 | if (app.config.mailer.app) require('./lib/mailer')(app) 3 | } 4 | -------------------------------------------------------------------------------- /app/lib/plugin/egg-akismet/app.js: -------------------------------------------------------------------------------- 1 | module.exports = app => { 2 | if (app.config.akismet.app) require('./lib/akismet')(app) 3 | } 4 | -------------------------------------------------------------------------------- /app/lib/plugin/egg-akismet/config/config.default.js: -------------------------------------------------------------------------------- 1 | exports.akismet = { 2 | app: true, 3 | agent: false, 4 | client: {} 5 | } 6 | -------------------------------------------------------------------------------- /app/lib/plugin/egg-mailer/agent.js: -------------------------------------------------------------------------------- 1 | module.exports = agent => { 2 | if (agent.config.mailer.agent) require('./lib/mailer')(agent) 3 | } 4 | -------------------------------------------------------------------------------- /app/lib/plugin/egg-mailer/config/config.default.js: -------------------------------------------------------------------------------- 1 | exports.mailer = { 2 | app: true, 3 | agent: false, 4 | client: {} 5 | } 6 | -------------------------------------------------------------------------------- /app/lib/plugin/egg-akismet/agent.js: -------------------------------------------------------------------------------- 1 | module.exports = agent => { 2 | if (agent.config.akismet.agent) require('./lib/akismet')(agent) 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | logs/ 2 | npm-debug.log 3 | yarn-error.log 4 | node_modules/ 5 | package-lock.json 6 | coverage/ 7 | .idea/ 8 | run/ 9 | .DS_Store 10 | *.sw* 11 | *.un~ 12 | ecosystem.config.js 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - '8' 5 | install: 6 | - npm i npminstall && npminstall 7 | script: 8 | - npm run ci 9 | after_script: 10 | - npminstall codecov && codecov 11 | -------------------------------------------------------------------------------- /config/plugin.prod.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | exports.sentry = { 4 | enable: true, 5 | package: 'egg-sentry', 6 | } 7 | 8 | exports['alinode-async'] = { 9 | enable: true, 10 | package: 'egg-alinode-async' 11 | } 12 | -------------------------------------------------------------------------------- /app/service/moment.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 说说 Services 3 | */ 4 | 5 | const ProxyService = require('./proxy') 6 | 7 | module.exports = class MomentService extends ProxyService { 8 | get model () { 9 | return this.app.model.Moment 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | matrix: 3 | - nodejs_version: '8' 4 | 5 | install: 6 | - ps: Install-Product node $env:nodejs_version 7 | - npm i npminstall && node_modules\.bin\npminstall 8 | 9 | test_script: 10 | - node --version 11 | - npm --version 12 | - npm run test 13 | 14 | build: off 15 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-egg", 3 | "rules": { 4 | "indent": ["error", 4], 5 | "semi": 0, 6 | "space-before-function-paren": [ "error", "always"], 7 | "strict": 0, 8 | "comma-dangle": 0, 9 | "array-bracket-spacing": 0, 10 | "no-use-before-define": 0, 11 | "no-constant-condition": 0 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ## SEE: https://github.com/eggjs/egg/issues/1431 2 | FROM node:8.12.0-alpine 3 | 4 | RUN mkdir -p /usr/src/app 5 | 6 | WORKDIR /usr/src/app 7 | 8 | COPY package.json /usr/src/app/package.json 9 | 10 | RUN yarn config set registry 'https://registry.npm.taobao.org' 11 | 12 | RUN yarn install 13 | 14 | COPY . /usr/src/app 15 | 16 | EXPOSE 7001 17 | 18 | CMD npm run docker 19 | -------------------------------------------------------------------------------- /init.d/mongo/init.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | /** 4 | * 1. create custom user 5 | * 2. create collection (Before MongoDB can save your new database, a collection name must also be specified at the time of creation.) 6 | */ 7 | db.createUser({ 8 | user: 'node-server', 9 | pwd: 'node-server', 10 | roles: [{ 11 | role: 'readWrite', 12 | db: 'node-server' 13 | }] 14 | }) 15 | -------------------------------------------------------------------------------- /config/config.prod.js: -------------------------------------------------------------------------------- 1 | module.exports = () => { 2 | const config = exports = {} 3 | 4 | config.session = { 5 | domain: '.jooger.me' 6 | } 7 | 8 | config.console = { 9 | debug: false, 10 | error: false 11 | } 12 | 13 | config.sentry = { 14 | dsn: 'https://43ea4130c7684fb3aa86404172cf67a1@sentry.io/1272403' 15 | } 16 | 17 | return config 18 | } 19 | -------------------------------------------------------------------------------- /app/utils/encode.js: -------------------------------------------------------------------------------- 1 | const bcrypt = require('bcryptjs') 2 | 3 | // hash 加密 4 | exports.bhash = (str = '') => bcrypt.hashSync(str, 8) 5 | 6 | // 对比 7 | exports.bcompare = bcrypt.compareSync 8 | 9 | // 随机字符串 10 | exports.randomString = (length = 8) => { 11 | const chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz' 12 | let id = '' 13 | for (let i = 0; i < length; i++) { 14 | id += chars[Math.floor(Math.random() * chars.length)] 15 | } 16 | return id 17 | } 18 | -------------------------------------------------------------------------------- /config/config.local.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = () => { 4 | const config = exports = {} 5 | 6 | config.security = { 7 | domainWhiteList: ['*'] 8 | } 9 | 10 | config.logger = { 11 | level: 'DEBUG', 12 | consoleLevel: 'DEBUG', 13 | } 14 | 15 | // 本地开发调试用 16 | config.github = { 17 | clientId: '5b4d4a7945347d0fd2e2', 18 | clientSecret: '8771bd9ae52749cc15b0c9e2c6cb4ecd7f39d9da' 19 | } 20 | 21 | return config 22 | } 23 | -------------------------------------------------------------------------------- /app/schedule/links.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 友链更新定时任务 3 | */ 4 | 5 | const { Subscription } = require('egg') 6 | 7 | module.exports = class UpdateSiteLinks extends Subscription { 8 | static get schedule () { 9 | return { 10 | // 每天0点更新一次 11 | cron: '0 0 * * * *', 12 | type: 'worker' 13 | } 14 | } 15 | 16 | async subscribe () { 17 | this.logger.info('开始更新友链') 18 | await this.service.setting.updateLinks() 19 | this.logger.info('结束更新友链') 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.autod.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | write: true, 5 | prefix: '^', 6 | plugin: 'autod-egg', 7 | test: [ 8 | 'test', 9 | 'benchmark', 10 | ], 11 | dep: [ 12 | 'egg', 13 | 'egg-scripts', 14 | ], 15 | devdep: [ 16 | 'egg-ci', 17 | 'egg-bin', 18 | 'egg-mock', 19 | 'autod', 20 | 'autod-egg', 21 | 'eslint', 22 | 'eslint-config-egg', 23 | 'webstorm-disable-index', 24 | ], 25 | exclude: [ 26 | './test/fixtures', 27 | './dist', 28 | ], 29 | }; 30 | 31 | -------------------------------------------------------------------------------- /app/schedule/voice.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 获取Voice定时任务 3 | */ 4 | 5 | const { Subscription } = require('egg') 6 | 7 | module.exports = class GetVoice extends Subscription { 8 | static get schedule () { 9 | return { 10 | // 每5分钟更新一次 11 | interval: '5m', 12 | type: 'worker' 13 | } 14 | } 15 | 16 | async subscribe () { 17 | this.logger.info('开始请求远端Voice') 18 | await this.service.agent.fetchRemoteVoice() 19 | this.logger.info('结束请求远端Voice') 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/schedule/personal.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 个人信息更新定时任务 3 | */ 4 | 5 | const { Subscription } = require('egg') 6 | 7 | module.exports = class UpdatePersonalGithubInfo extends Subscription { 8 | static get schedule () { 9 | return { 10 | // 每小时更新一次 11 | interval: '1h', 12 | type: 'worker' 13 | } 14 | } 15 | 16 | async subscribe () { 17 | this.logger.info('开始更新个人Github信息') 18 | await this.service.setting.updateGithubInfo() 19 | this.logger.info('结束更新个人Github信息') 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/utils/share.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | 3 | exports.lodash = require('lodash') 4 | 5 | exports.noop = function () {} 6 | 7 | // 首字母大写 8 | exports.firstUpperCase = (str = '') => str.toLowerCase().replace(/( |^)[a-z]/g, L => L.toUpperCase()) 9 | 10 | exports.createObjectId = (id = '') => { 11 | return id ? mongoose.Types.ObjectId(id) : mongoose.Types.ObjectId() 12 | } 13 | 14 | exports.getMonthFromNum = (num = 1) => { 15 | return ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'][num - 1] || '' 16 | } 17 | -------------------------------------------------------------------------------- /app/model/tag.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 标签模型 3 | */ 4 | 5 | module.exports = app => { 6 | const { mongoose } = app 7 | const { Schema } = mongoose 8 | 9 | const TagSchema = new Schema({ 10 | // 名称 11 | name: { type: String, required: true }, 12 | // 描述 13 | description: { type: String, default: '' }, 14 | // 扩展属性 15 | extends: [{ 16 | key: { type: String, validate: /\S+/ }, 17 | value: { type: String, validate: /\S+/ } 18 | }] 19 | }) 20 | 21 | return mongoose.model('Tag', app.processSchema(TagSchema)) 22 | } 23 | -------------------------------------------------------------------------------- /app/model/category.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 分类模型 3 | */ 4 | 5 | module.exports = app => { 6 | const { mongoose } = app 7 | const { Schema } = mongoose 8 | 9 | const CategorySchema = new Schema({ 10 | // 名称 11 | name: { type: String, required: true }, 12 | // 描述 13 | description: { type: String, default: '' }, 14 | // 扩展属性 15 | extends: [{ 16 | key: { type: String, validate: /\S+/ }, 17 | value: { type: String, validate: /\S+/ } 18 | }] 19 | }) 20 | 21 | return mongoose.model('Category', app.processSchema(CategorySchema)) 22 | } 23 | -------------------------------------------------------------------------------- /app/model/moment.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 说说模型 3 | */ 4 | 5 | module.exports = app => { 6 | const { mongoose } = app 7 | const { Schema } = mongoose 8 | 9 | const MomentSchema = new Schema({ 10 | // 内容 11 | content: { type: String, required: true }, 12 | // 地点 13 | location: Object, 14 | // 扩展属性 15 | extends: [{ 16 | key: { type: String, validate: /\S+/ }, 17 | value: { type: String, validate: /\S+/ } 18 | }] 19 | }) 20 | 21 | return mongoose.model('Moment', app.processSchema(MomentSchema, { 22 | paginate: true 23 | })) 24 | } 25 | -------------------------------------------------------------------------------- /app/router.js: -------------------------------------------------------------------------------- 1 | module.exports = app => { 2 | const { router, config } = app 3 | 4 | router.get('/', async ctx => { 5 | ctx.body = { 6 | name: config.name, 7 | version: config.version, 8 | author: config.pkg.author, 9 | github: 'https://github.com/jo0ger', 10 | site: config.author.url, 11 | poweredBy: ['Egg', 'Koa2', 'MongoDB', 'Nginx', 'Redis'] 12 | } 13 | }) 14 | 15 | require('./router/backend')(app) 16 | require('./router/frontend')(app) 17 | router.all('*', ctx => { 18 | const code = 404 19 | ctx.fail(code, app.config.codeMap[code]) 20 | }) 21 | } 22 | 23 | -------------------------------------------------------------------------------- /app/utils/gravatar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc gravatar头像 3 | */ 4 | 5 | const gravatar = require('gravatar') 6 | 7 | module.exports = app => { 8 | return (email = '', opt = {}) => { 9 | if (!app.utils.validate.isEmail(email)) { 10 | return app.config.defaultAvatar 11 | } 12 | const protocol = `http${app.config.isProd ? 's' : ''}` 13 | const url = gravatar.url(email, Object.assign({ 14 | s: '100', 15 | r: 'x', 16 | d: 'retro', 17 | protocol 18 | }, opt)) 19 | return url && url.replace(`${protocol}://`, `${app.config.author.url}/proxy/`) || app.config.defaultAvatar 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/middleware/error.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 统一错误处理 3 | */ 4 | 5 | module.exports = (opt, app) => { 6 | return async (ctx, next) => { 7 | try { 8 | await next() 9 | } catch (err) { 10 | // 所有的异常都在 app 上触发一个 error 事件,框架会记录一条错误日志 11 | ctx.app.emit('error', err, ctx) 12 | let code = err.status || 500 13 | if (code === 200) code = -1 14 | let message = '' 15 | if (app.config.isProd) { 16 | message = app.config.codeMap[code] 17 | } else { 18 | message = err.message 19 | } 20 | ctx.fail(code, message, err.errors) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/lib/plugin/egg-akismet/lib/akismet.js: -------------------------------------------------------------------------------- 1 | const akismet = require('akismet-api') 2 | 3 | module.exports = app => { 4 | app.addSingleton('akismet', createClient) 5 | app.beforeStart(() => { 6 | app.akismet.verifyKey().then(valid => { 7 | if (valid) { 8 | app.coreLogger.info('[egg-akismet] 服务启动成功') 9 | app._akismetValid = true 10 | } else { 11 | app.coreLogger.error('[egg-akismet] 服务启动失败:无效的Apikey') 12 | } 13 | }).catch(err => { 14 | app.coreLogger.error('[egg-akismet] ' + err.message) 15 | }) 16 | }) 17 | } 18 | 19 | function createClient (config) { 20 | return akismet.client(config) 21 | } 22 | -------------------------------------------------------------------------------- /app/middleware/gzip.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc gzip response body 3 | */ 4 | 5 | const isJSON = require('koa-is-json') 6 | const zlib = require('zlib') 7 | 8 | module.exports = options => { 9 | return async function gzip (ctx, next) { 10 | await next() 11 | 12 | // 后续中间件执行完成后将响应体转换成 gzip 13 | let body = ctx.body 14 | if (!body) return 15 | 16 | // 支持 options.threshold 17 | if (options.threshold && ctx.length < options.threshold) return 18 | 19 | if (isJSON(body)) body = JSON.stringify(body) 20 | 21 | // 设置 gzip body,修正响应头 22 | const stream = zlib.createGzip() 23 | stream.end(body) 24 | ctx.body = stream 25 | ctx.set('Content-Encoding', 'gzip') 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/controller/agent.js: -------------------------------------------------------------------------------- 1 | const { Controller } = require('egg') 2 | 3 | module.exports = class AgentController extends Controller { 4 | async voice () { 5 | this.ctx.success(await this.service.agent.getVoice()) 6 | } 7 | 8 | async ip () { 9 | const { ctx } = this 10 | ctx.validate({ 11 | ip: { type: 'string', required: true } 12 | }, ctx.query) 13 | this.ctx.success(await this.service.agent.lookupIp(ctx.query.ip), 'IP查询成功') 14 | } 15 | 16 | async musicList () { 17 | this.ctx.success(await this.service.agent.getMusicList()) 18 | } 19 | 20 | async musicSong () { 21 | const params = this.ctx.validateParams({ 22 | id: { 23 | type: 'string', 24 | required: true 25 | } 26 | }) 27 | this.ctx.success(await this.service.agent.getMusicSong(params.id)) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/service/sentry.js: -------------------------------------------------------------------------------- 1 | const { Service } = require('egg') 2 | 3 | module.exports = class SentryService extends Service { 4 | /** 5 | * filter errors need to be submitted to sentry 6 | * 7 | * @param {any} err error 8 | * @return {boolean} true for submit, default true 9 | * @memberof SentryService 10 | */ 11 | judgeError (err) { 12 | // ignore HTTP Error 13 | return !(err.status && err.status >= 500) 14 | } 15 | 16 | // user information 17 | get user () { 18 | return this.app._admin 19 | } 20 | 21 | get extra () { 22 | return { 23 | ip: this.ctx.ip, 24 | payload: this.ctx.request.body, 25 | query: this.ctx.query, 26 | params: this.ctx.params 27 | } 28 | } 29 | 30 | get tags () { 31 | return { 32 | url: this.ctx.request.url 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /config/plugin.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | 5 | // had enabled by egg 6 | // exports.static = true 7 | 8 | exports.cors = { 9 | enable: true, 10 | package: 'egg-cors' 11 | } 12 | 13 | exports.mongoose = { 14 | enable: true, 15 | package: 'egg-mongoose' 16 | } 17 | 18 | exports.validate = { 19 | enable: true, 20 | package: 'egg-validate', 21 | } 22 | 23 | exports.console = { 24 | enable: true, 25 | package: 'egg-console' 26 | } 27 | 28 | exports.redis = { 29 | enable: true, 30 | package: 'egg-redis' 31 | } 32 | 33 | exports.routerPlus = { 34 | enable: true, 35 | package: 'egg-router-plus' 36 | } 37 | 38 | exports.akismet = { 39 | enable: true, 40 | path: path.join(__dirname, '../app/lib/plugin/egg-akismet') 41 | } 42 | 43 | exports.mailer = { 44 | enable: true, 45 | path: path.join(__dirname, '../app/lib/plugin/egg-mailer') 46 | } 47 | -------------------------------------------------------------------------------- /app/utils/validate.js: -------------------------------------------------------------------------------- 1 | const lodash = require('lodash') 2 | const mongoose = require('mongoose') 3 | const validator = require('validator') 4 | 5 | Object.keys(lodash).forEach(key => { 6 | if (key.startsWith('is')) { 7 | exports[key] = lodash[key] 8 | } 9 | }) 10 | 11 | exports.isEmptyObject = obj => { 12 | if (typeof obj !== 'object') { 13 | return false 14 | } 15 | /* eslint-disable */ 16 | for (let key in obj) { 17 | return false 18 | } 19 | return true 20 | } 21 | 22 | exports.isObjectId = (str = '') => mongoose.Types.ObjectId.isValid(str) 23 | 24 | Object.keys(validator).forEach(key => { 25 | exports[key] = function () { 26 | return validator[key].apply(validator, arguments) 27 | } 28 | }) 29 | 30 | exports.isUrl = (site = '') => { 31 | if (!site) return true 32 | return validator.isURL(site, { 33 | protocols: ['http', 'https'], 34 | require_protocol: true 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /app/schedule/music.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc Music 定时任务 3 | */ 4 | 5 | const { Subscription } = require('egg') 6 | 7 | module.exports = class UpdateMusic extends Subscription { 8 | static get schedule () { 9 | return { 10 | // 每小时更新一次 11 | interval: '15s', 12 | type: 'worker', 13 | immediate: true, 14 | env: ['prod'] 15 | } 16 | } 17 | 18 | async subscribe () { 19 | this.logger.info('开始更新Music') 20 | // 先不缓存到redis中 21 | let list = await this.service.agent.fetchRemoteMusicList(false) 22 | list = await Promise.all((list || []).map(async item => { 23 | const song = await this.service.agent.fetchRemoteMusicSong(item.id, false) 24 | if (song) { 25 | return Object.assign({}, item, song) 26 | } 27 | return item 28 | })) 29 | this.service.agent.setMusicListToStore(list) 30 | this.logger.info('结束更新Music') 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/model/user.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 用户模型 3 | */ 4 | 5 | module.exports = app => { 6 | const { mongoose, config } = app 7 | const { Schema } = mongoose 8 | const userValidateConfig = config.modelEnum.user 9 | 10 | const UserSchema = new Schema({ 11 | name: { type: String, required: true }, 12 | email: { type: String, required: true, validate: app.utils.validate.isEmail }, 13 | avatar: { type: String, required: true }, 14 | site: { type: String, validate: app.utils.validate.isUrl }, 15 | // 角色 0 管理员 | 1 普通用户 16 | role: { 17 | type: Number, 18 | default: userValidateConfig.role.default, 19 | validate: val => Object.values(userValidateConfig.role.optional).includes(val) 20 | }, 21 | // role = 0的时候才有该项 22 | password: { type: String }, 23 | // 是否被禁言 24 | mute: { type: Boolean, default: false } 25 | }) 26 | 27 | return mongoose.model('User', app.processSchema(UserSchema)) 28 | } 29 | 30 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | redis: 4 | container_name: redis 5 | image: redis:4.0.11-alpine 6 | command: redis-server --appendonly yes --requirepass node-server 7 | volumes: 8 | - egg-redis:/data 9 | networks: 10 | - docker-node-server 11 | ports: 12 | - 6378:6379 13 | 14 | mongodb: 15 | container_name: mongodb 16 | image: mongo:3.6.7 17 | restart: always 18 | environment: 19 | MONGO_INITDB_ROOT_USERNAME: root 20 | MONGO_INITDB_ROOT_PASSWORD: mongodb 21 | MONGO_INITDB_DATABASE: node-server 22 | volumes: 23 | - egg-mongo:/data/db 24 | - ./init.d/mongo/:/docker-entrypoint-initdb.d 25 | networks: 26 | - docker-node-server 27 | ports: 28 | - 27016:27017 29 | 30 | volumes: 31 | egg-mongo: 32 | egg-redis: 33 | 34 | networks: 35 | docker-node-server: 36 | driver: bridge 37 | -------------------------------------------------------------------------------- /app/service/tag.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 标签 Services 3 | */ 4 | 5 | const ProxyService = require('./proxy') 6 | 7 | module.exports = class TagService extends ProxyService { 8 | get model () { 9 | return this.app.model.Tag 10 | } 11 | 12 | async getList (query, select = null, opt) { 13 | opt = this.app.merge({ 14 | sort: '-createdAt' 15 | }, opt) 16 | let tag = await this.model.find(query, select, opt).exec() 17 | if (tag.length) { 18 | const PUBLISH = this.app.config.modelEnum.article.state.optional.PUBLISH 19 | tag = await Promise.all( 20 | tag.map(async item => { 21 | item = item.toObject() 22 | const articles = await this.service.article.getList({ 23 | tag: item._id, 24 | state: PUBLISH 25 | }) 26 | item.count = articles.length 27 | return item 28 | }) 29 | ) 30 | } 31 | return tag 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/middleware/headers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 设置相应头 3 | */ 4 | 5 | module.exports = (opt, app) => { 6 | return async (ctx, next) => { 7 | const { request, response } = ctx 8 | const allowedOrigins = app.config.allowedOrigins 9 | const origin = request.get('origin') || '' 10 | const allowed = request.query._DEV_ || 11 | origin.includes('localhost') || 12 | origin.includes('127.0.0.1') || 13 | allowedOrigins.find(item => origin.includes(item)) 14 | if (allowed) { 15 | response.set('Access-Control-Allow-Origin', origin) 16 | } 17 | response.set('Access-Control-Allow-Headers', 'token, Authorization, Origin, No-Cache, X-Requested-With, If-Modified-Since, Pragma, Last-Modified, Cache-Control, Expires, Content-Type, X-E4M-With') 18 | response.set('Content-Type', 'application/json;charset=utf-8') 19 | response.set('X-Powered-By', `${app.config.name}/${app.config.version}`) 20 | 21 | if (request.method === 'OPTIONS') { 22 | return ctx.success('ok') 23 | } 24 | await next() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/service/category.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 分类 Services 3 | */ 4 | 5 | const ProxyService = require('./proxy') 6 | 7 | module.exports = class CategoryService extends ProxyService { 8 | get model () { 9 | return this.app.model.Category 10 | } 11 | 12 | async getList (query, select = null, opt) { 13 | opt = this.app.merge({ 14 | sort: 'createdAt' 15 | }, opt) 16 | let categories = await this.model.find(query, select, opt).exec() 17 | if (categories.length) { 18 | const PUBLISH = this.app.config.modelEnum.article.state.optional.PUBLISH 19 | categories = await Promise.all( 20 | categories.map(async item => { 21 | item = item.toObject() 22 | const articles = await this.service.article.getList({ 23 | category: item._id, 24 | state: PUBLISH 25 | }) 26 | item.count = articles.length 27 | return item 28 | }) 29 | ) 30 | } 31 | return categories 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/model/stat.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 统计模型 3 | */ 4 | 5 | module.exports = app => { 6 | const { mongoose, config } = app 7 | const { Schema } = mongoose 8 | const statValidateConfig = config.modelEnum.stat 9 | 10 | const StatSchema = new Schema({ 11 | // 类型 12 | type: { 13 | type: Number, 14 | required: true, 15 | validate: val => Object.values(statValidateConfig.type.optional).includes(val) 16 | }, 17 | // 统计目标 18 | target: { 19 | keyword: { type: String, required: false }, 20 | article: { type: mongoose.Schema.Types.ObjectId, ref: 'Article', required: false }, 21 | category: { type: mongoose.Schema.Types.ObjectId, ref: 'Category', required: false }, 22 | tag: { type: mongoose.Schema.Types.ObjectId, ref: 'Tag', required: false }, 23 | user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: false } 24 | }, 25 | // 统计项 26 | stat: { 27 | count: { type: Number, required: false, default: 0 } 28 | } 29 | }) 30 | 31 | return mongoose.model('Stat', app.processSchema(StatSchema)) 32 | } 33 | -------------------------------------------------------------------------------- /app/lib/plugin/egg-mailer/lib/mailer.js: -------------------------------------------------------------------------------- 1 | const nodemailer = require('nodemailer') 2 | 3 | module.exports = app => { 4 | app.addSingleton('mailer', createClient) 5 | } 6 | 7 | function createClient (config, app) { 8 | return { 9 | client: null, 10 | getClient (opt) { 11 | if (!this.client) { 12 | try { 13 | this.client = nodemailer.createTransport(Object.assign({}, config, opt)) 14 | } catch (err) { 15 | app.coreLogger.error('[egg-mailer] 邮件客户端初始化失败,错误:' + err.message) 16 | } 17 | } 18 | return this.client 19 | }, 20 | async verify () { 21 | await new Promise((resolve, reject) => { 22 | if (!this.client) { 23 | return resolve() 24 | } 25 | this.client.verify(err => { 26 | if (err) { 27 | app.coreLogger.error('[egg-mailer] ' + err.message) 28 | reject(err) 29 | } else { 30 | resolve() 31 | } 32 | }) 33 | }) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | 5 | module.exports = app => { 6 | app.loader.loadToApp(path.join(app.config.baseDir, 'app/utils'), 'utils') 7 | addValidateRule(app) 8 | 9 | app.beforeStart(async () => { 10 | const ctx = app.createAnonymousContext() 11 | // 初始化管理员(如果有必要) 12 | await ctx.service.auth.seed() 13 | // 初始化配置(如果有必要) 14 | const setting = await ctx.service.setting.seed() 15 | // prod异步启动alinode 16 | if (app.config.isProd) { 17 | app.messenger.sendToAgent('alinode-run', setting.keys.alinode) 18 | } 19 | }) 20 | } 21 | 22 | function addValidateRule (app) { 23 | app.validator.addRule('objectId', (rule, val) => { 24 | const valid = app.utils.validate.isObjectId(val) 25 | if (!valid) { 26 | return 'must be objectId' 27 | } 28 | }) 29 | app.validator.addRule('email', (rule, val) => { 30 | const valid = app.utils.validate.isEmail(val) 31 | if (!valid) { 32 | return 'must be email' 33 | } 34 | }) 35 | app.validator.addRule('url', (rule, val) => { 36 | const valid = app.utils.validate.isUrl(val) 37 | if (!valid) { 38 | return 'must be url' 39 | } 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /app/middleware/auth.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc jwt 校验 3 | */ 4 | 5 | const compose = require('koa-compose') 6 | 7 | module.exports = app => { 8 | return compose([ 9 | verifyToken(app), 10 | async (ctx, next) => { 11 | if (!ctx.session._verify) { 12 | return ctx.fail(401) 13 | } 14 | const userId = ctx.cookies.get(app.config.userCookieKey, app.config.session.signed) 15 | const user = await ctx.service.user.getItemById(userId, '-password') 16 | if (!user) { 17 | return ctx.fail(401, '用户不存在') 18 | } 19 | ctx.session._user = user 20 | ctx.session._isAuthed = true 21 | await next() 22 | } 23 | ]) 24 | } 25 | 26 | // 验证登录token 27 | function verifyToken (app) { 28 | const { config, logger } = app 29 | return async (ctx, next) => { 30 | ctx.session._verify = false 31 | const token = ctx.cookies.get(config.session.key, app.config.session.signed) 32 | if (!token) return ctx.fail('请先登录') 33 | const verify = await app.verifyToken(token) 34 | if (!verify) return ctx.fail(401, '登录失效,请重新登录') 35 | ctx.session._verify = true 36 | ctx.session._token = token 37 | logger.info('Token校验成功') 38 | await next() 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/model/notification.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 通告模型 3 | */ 4 | 5 | module.exports = app => { 6 | const { mongoose, config } = app 7 | const { Schema } = mongoose 8 | const notificationValidateConfig = config.modelEnum.notification 9 | 10 | const NotificationSchema = new Schema({ 11 | // 通知类型 0 系统通知 | 1 评论通知 | 2 点赞通知 | 3 用户操作通知 12 | type: { 13 | type: Number, 14 | required: true, 15 | validate: val => Object.values(notificationValidateConfig.type.optional).includes(val) 16 | }, 17 | // 类型细化分类 18 | classify: { 19 | type: String, 20 | required: true, 21 | validate: val => Object.values(notificationValidateConfig.classify.optional).includes(val) 22 | }, 23 | // 是否已读 24 | viewed: { type: Boolean, default: false, required: true }, 25 | // 操作简语 26 | verb: { type: String, required: true, default: '' }, 27 | target: { 28 | // article user comment 根据情况是否包含 29 | article: { type: Schema.Types.ObjectId, ref: 'Article' }, 30 | user: { type: Schema.Types.ObjectId, ref: 'User' }, 31 | comment: { type: Schema.Types.ObjectId, ref: 'Comment' }, 32 | }, 33 | actors: { 34 | from: { type: Schema.Types.ObjectId, ref: 'User' }, 35 | to: { type: Schema.Types.ObjectId, ref: 'User' } 36 | } 37 | }) 38 | 39 | return mongoose.model('Notification', app.processSchema(NotificationSchema, { 40 | paginate: true 41 | })) 42 | } 43 | -------------------------------------------------------------------------------- /app/service/mail.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc Mail Services 3 | */ 4 | 5 | const { Service } = require('egg') 6 | 7 | let mailerClient = null 8 | 9 | module.exports = class MailService extends Service { 10 | // 发送邮件 11 | async send (type, data, toAdmin = false) { 12 | let client = mailerClient 13 | const keys = this.app.setting.keys 14 | if (!client) { 15 | mailerClient = client = this.app.mailer.getClient({ 16 | auth: keys.mail 17 | }) 18 | await this.app.mailer.verify().catch(err => { 19 | this.service.notification.recordGeneral('MAIL', 'VERIFY_FAIL', err) 20 | }) 21 | } 22 | const opt = Object.assign({ 23 | from: `${this.config.author.name} <${keys.mail.user}>` 24 | }, data) 25 | if (toAdmin) { 26 | opt.to = keys.mail.user 27 | } 28 | type = type ? `[${type}]` : '' 29 | toAdmin = toAdmin ? '管理员' : '' 30 | await new Promise((resolve, reject) => { 31 | client.sendMail(opt, (err, info) => { 32 | if (err) { 33 | this.logger.error(type + toAdmin + '邮件发送失败,TO:' + opt.to + ',错误:' + err.message) 34 | this.service.notification.recordGeneral('MAIL', 'SEND_FAIL', err) 35 | return reject(err) 36 | } 37 | this.logger.info(type + toAdmin + '邮件发送成功,TO:' + opt.to) 38 | resolve(info) 39 | }) 40 | }) 41 | } 42 | 43 | sendToAdmin (type, data) { 44 | return this.send(type, data, true) 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /app/controller/stat.js: -------------------------------------------------------------------------------- 1 | const { Controller } = require('egg') 2 | 3 | module.exports = class StatController extends Controller { 4 | get rules () { 5 | return { 6 | trend: { 7 | startDate: { type: 'string', required: true }, 8 | endDate: { type: 'string', required: true }, 9 | dimension: { 10 | type: 'enum', 11 | values: this.service.stat.dimensionsValidate, 12 | required: true 13 | }, 14 | target: { type: 'string', required: true } 15 | } 16 | } 17 | } 18 | 19 | async count () { 20 | // 文章浏览量 文章点赞数 文章评论量 站内留言量 用户数 21 | const [pv, up, comment, message, user] = await Promise.all( 22 | ['pv', 'up', 'comment', 'message', 'user'].map(type => { 23 | return this.service.stat.getCount(type) 24 | }) 25 | ) 26 | this.ctx.success({ 27 | pv, 28 | up, 29 | comment, 30 | message, 31 | user 32 | }, '获取数量统计成功') 33 | } 34 | 35 | async trend () { 36 | const { ctx } = this 37 | ctx.validate(this.rules.trend, ctx.query) 38 | const { startDate, endDate, dimension, target } = ctx.query 39 | const trend = await this.service.stat.trendRange( 40 | startDate, 41 | endDate, 42 | dimension, 43 | target 44 | ) 45 | this.ctx.success({ 46 | target, 47 | dimension, 48 | startDate, 49 | endDate, 50 | trend 51 | }, '获取趋势统计成功') 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/service/seo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc SEO 相关 3 | */ 4 | 5 | const axios = require('axios') 6 | const { Service } = require('egg') 7 | 8 | module.exports = class SeoService extends Service { 9 | get baiduSeoClient () { 10 | return axios.create({ 11 | baseURL: 'http://data.zz.baidu.com', 12 | headers: { 13 | 'Content-Type': 'text/plain' 14 | }, 15 | params: { 16 | site: this.config.author.url, 17 | token: this.baiduSeoToken 18 | } 19 | }) 20 | } 21 | 22 | get baiduSeoToken () { 23 | try { 24 | return this.app.setting.keys.baiduSeo.token 25 | } catch (e) { 26 | return '' 27 | } 28 | } 29 | 30 | // 百度seo push 31 | async baiduSeo (type = '', urls = []) { 32 | if (!this.baiduSeoToken) { 33 | return this.logger.warn('未找到百度SEO token') 34 | } 35 | const actionMap = { 36 | push: { url: '/urls', title: '推送' }, 37 | update: { url: '/update', title: '更新' }, 38 | delete: { url: '/del', title: '删除' } 39 | } 40 | const action = actionMap[type] 41 | if (!action) return 42 | const res = await axios.post( 43 | `http://data.zz.baidu.com${action.url}?site=${this.config.author.url}&token=${this.baiduSeoToken}`, 44 | urls, 45 | { 46 | headers: { 47 | 'Content-Type': 'text/plain' 48 | } 49 | } 50 | ) 51 | if (res && res.status === 200) { 52 | this.logger.info(`百度SEO${action.title}成功:${JSON.stringify(res.data)}`) 53 | } else { 54 | this.logger.error(`百度SEO${action.title}失败:${res.data && res.data.message}`) 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | node-server: 4 | container_name: node-server 5 | # 阿里云容器代理 6 | image: registry.cn-beijing.aliyuncs.com/jooger/node-server:latest 7 | # 环境变量 8 | environment: 9 | NODE_ENV: production 10 | EGG_SERVER_ENV: prod 11 | EGG_MONGODB_URL: mongodb://node-server:node-server@mongodb:27017/node-server 12 | EGG_REDIS_HOST: redis 13 | EGG_REDIS_PORT: 6379 14 | EGG_REDIS_PASSWORD: node-server 15 | # 依赖项,会在redis和mongo启动之后再启动 16 | depends_on: 17 | - redis 18 | - mongodb 19 | volumes: 20 | - /root/logs:/root/logs 21 | networks: 22 | - docker-node-server 23 | # 端口映射 24 | ports: 25 | - 7001:7001 26 | 27 | redis: 28 | container_name: redis 29 | image: redis:4.0.11-alpine 30 | # appendonly 数据持久化 31 | command: redis-server --appendonly yes --requirepass node-server 32 | volumes: 33 | - egg-redis:/data 34 | networks: 35 | - docker-node-server 36 | ports: 37 | - 6378:6379 38 | 39 | mongodb: 40 | container_name: mongodb 41 | image: mongo:3.6.7 42 | restart: always 43 | environment: 44 | MONGO_INITDB_ROOT_USERNAME: root 45 | MONGO_INITDB_ROOT_PASSWORD: mongodb 46 | MONGO_INITDB_DATABASE: node-server 47 | volumes: 48 | - egg-mongo:/data/db 49 | - ./init.d/mongo/:/docker-entrypoint-initdb.d 50 | - /root/backup/data/db:/root/backup 51 | networks: 52 | - docker-node-server 53 | ports: 54 | - 27016:27017 55 | 56 | volumes: 57 | egg-mongo: 58 | egg-redis: 59 | 60 | networks: 61 | docker-node-server: 62 | driver: bridge 63 | -------------------------------------------------------------------------------- /app/router/frontend.js: -------------------------------------------------------------------------------- 1 | module.exports = app => { 2 | const frontendRouter = app.router.namespace('') 3 | const { controller } = app 4 | 5 | // Article 6 | frontendRouter.get('/articles', controller.article.list) 7 | frontendRouter.get('/articles/archives', controller.article.archives) 8 | frontendRouter.get('/articles/hot', controller.article.hot) 9 | frontendRouter.get('/articles/:id', controller.article.item) 10 | frontendRouter.patch('/articles/:id', controller.article.like) 11 | frontendRouter.patch('/articles/:id/like', controller.article.like) 12 | frontendRouter.patch('/articles/:id/unlike', controller.article.unlike) 13 | 14 | // Category 15 | frontendRouter.get('/categories', controller.category.list) 16 | frontendRouter.get('/categories/:id', controller.category.item) 17 | 18 | // Tag 19 | frontendRouter.get('/tags', controller.tag.list) 20 | frontendRouter.get('/tags/:id', controller.tag.item) 21 | 22 | // Comment 23 | frontendRouter.get('/comments', controller.comment.list) 24 | frontendRouter.get('/comments/:id', controller.comment.item) 25 | frontendRouter.post('/comments', controller.comment.create) 26 | frontendRouter.patch('/comments/:id/like', controller.comment.like) 27 | frontendRouter.patch('/comments/:id/unlike', controller.comment.unlike) 28 | 29 | // User 30 | frontendRouter.get('/users/:id', controller.user.item) 31 | frontendRouter.get('/users/admin/check', controller.user.checkAdmin) 32 | 33 | // Setting 34 | frontendRouter.get('/setting', controller.setting.index) 35 | 36 | // Agent 37 | frontendRouter.get('/agent/voice', controller.agent.voice) 38 | frontendRouter.get('/agent/ip', controller.agent.ip) 39 | frontendRouter.get('/agent/music', controller.agent.musicList) 40 | frontendRouter.get('/agent/music/song/:id', controller.agent.musicSong) 41 | 42 | // Moment 43 | frontendRouter.get('/moments', controller.moment.list) 44 | } 45 | -------------------------------------------------------------------------------- /app/service/auth.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc Auth Services 3 | */ 4 | 5 | const jwt = require('jsonwebtoken') 6 | const { Service } = require('egg') 7 | 8 | module.exports = class AuthService extends Service { 9 | sign (app, payload = {}, isLogin = true) { 10 | return jwt.sign(payload, app.config.secrets, { expiresIn: isLogin ? app.config.session.maxAge : 0 }) 11 | } 12 | 13 | /** 14 | * @desc 设置cookie,用于登录和退出 15 | * @param {User} user 登录用户 16 | * @param {Boolean} isLogin 是否是登录操作 17 | * @return {String} token 用户token 18 | */ 19 | setCookie (user, isLogin = true) { 20 | const { key, domain, maxAge, signed } = this.app.config.session 21 | const token = this.sign(this.app, { 22 | id: user._id, 23 | name: user.name 24 | }, isLogin) 25 | const payload = { 26 | signed, 27 | domain, 28 | maxAge: 29 | isLogin ? maxAge : 0, 30 | httpOnly: false 31 | } 32 | this.ctx.cookies.set(key, token, payload) 33 | this.ctx.cookies.set(this.app.config.userCookieKey, user._id, payload) 34 | return token 35 | } 36 | 37 | /** 38 | * @desc 创建管理员,用于server初始化时 39 | */ 40 | async seed () { 41 | const ADMIN = this.config.modelEnum.user.role.optional.ADMIN 42 | let admin = await this.service.user.getItem({ role: ADMIN }) 43 | if (!admin) { 44 | const defaultAdmin = this.config.defaultAdmin 45 | admin = await this.service.user.create(Object.assign({}, defaultAdmin, { 46 | role: ADMIN, 47 | password: this.app.utils.encode.bhash(defaultAdmin.password), 48 | avatar: this.app.utils.gravatar(defaultAdmin.email) 49 | })) 50 | } 51 | // 挂载在session上 52 | this.app._admin = admin 53 | } 54 | 55 | // 更新session 56 | async updateSessionUser (admin) { 57 | this.ctx.session._user = admin || await this.service.user.getItemById(this.ctx.session._user._id, '-password') 58 | this.logger.info('Session管理员信息更新成功') 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /app/schedule/backup.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 数据库,日志备份上传 3 | */ 4 | 5 | const fs = require('fs-extra') 6 | const moment = require('moment') 7 | const { Subscription } = require('egg') 8 | const OSS = require('ali-oss') 9 | 10 | const BACKUP_ROOT = '/root' 11 | const BACKUP_DIR = '/backup/' 12 | const FILE_NAME = 'backup' 13 | const FILE_EXT = '.tar.gz' 14 | const BACKUP_FILE = BACKUP_ROOT + BACKUP_DIR + FILE_NAME + FILE_EXT 15 | 16 | module.exports = class BackupUpload extends Subscription { 17 | static get schedule () { 18 | return { 19 | // 每天2点更新一次 20 | cron: '0 2 * * * *', 21 | type: 'worker', 22 | env: ['prod'] 23 | } 24 | } 25 | 26 | async subscribe () { 27 | this.logger.info('开始上传数据备份') 28 | const yesterday = moment().subtract(1, 'days').format('YYYYMMDD') 29 | const dir = BACKUP_DIR + FILE_NAME + '-' + yesterday + FILE_EXT 30 | const BACKUP_UPDATE_FILE = BACKUP_ROOT + dir 31 | const OSS_FILE = dir 32 | try { 33 | await fs.ensureFile(BACKUP_FILE) 34 | await fs.move(BACKUP_FILE, BACKUP_UPDATE_FILE, { overwrite: true }) 35 | await fs.remove(BACKUP_FILE) 36 | const ossClient = this.getClient() 37 | const result = await ossClient.put(OSS_FILE, BACKUP_UPDATE_FILE) 38 | if (result.res.status === 200) { 39 | this.logger.info('上传数据备份成功', result.url) 40 | // 上传成功后清空 41 | await fs.remove(BACKUP_UPDATE_FILE) 42 | } 43 | } catch (error) { 44 | this.logger.error('上传数据备份失败', error) 45 | const title = '博客上传数据备份失败' 46 | this.service.mail.sendToAdmin(title, { 47 | subject: title, 48 | html: `
错误原因:${error.stack}
` 49 | }) 50 | } 51 | } 52 | 53 | getClient () { 54 | try { 55 | const config = this.app.setting.keys.aliyun 56 | if (!config) return null 57 | return new OSS(config) 58 | } catch (error) { 59 | this.logger.error(error) 60 | return null 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /app/controller/user.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 用户Controller 3 | */ 4 | 5 | const { Controller } = require('egg') 6 | 7 | module.exports = class UserController extends Controller { 8 | get rules () { 9 | return { 10 | update: { 11 | mute: { type: 'boolean', required: false } 12 | }, 13 | checkAdmin: { 14 | userId: { type: 'objectId', required: true }, 15 | token: { type: 'string', required: true } 16 | } 17 | } 18 | } 19 | 20 | async list () { 21 | const { ctx } = this 22 | let select = '-password' 23 | if (!ctx.session._isAuthed) { 24 | select += ' -createdAt -updatedAt -role' 25 | } 26 | const query = { 27 | $nor: [ 28 | { 29 | role: this.config.modelEnum.user.role.optional.ADMIN 30 | } 31 | ] 32 | } 33 | const data = await this.service.user.getListWithComments(query, select) 34 | data 35 | ? ctx.success(data, '用户列表获取成功') 36 | : ctx.fail('用户列表获取失败') 37 | } 38 | 39 | async item () { 40 | const { ctx } = this 41 | const { id } = ctx.validateParamsObjectId() 42 | let select = '-password' 43 | if (!ctx.session._isAuthed) { 44 | select += ' -createdAt -updatedAt -github' 45 | } 46 | const data = await this.service.user.getItemById(id, select) 47 | data 48 | ? ctx.success(data, '用户详情获取成功') 49 | : ctx.fail('用户详情获取失败') 50 | } 51 | 52 | async update () { 53 | const { ctx } = this 54 | const { id } = ctx.validateParamsObjectId() 55 | const body = this.ctx.validateBody(this.rules.update) 56 | const data = await this.service.user.updateItemById(id, body, '-password') 57 | data 58 | ? ctx.success(data, '用户更新成功') 59 | : ctx.fail('用户更新失败') 60 | } 61 | 62 | async checkAdmin () { 63 | const { ctx } = this 64 | ctx.validate(this.rules.checkAdmin, ctx.query) 65 | const { userId, token } = ctx.query 66 | let isAdmin = false 67 | const verify = await this.app.verifyToken(token) 68 | if (verify) { 69 | const user = await this.service.user.getItemById(userId) 70 | if (user.role === this.config.modelEnum.user.role.optional.ADMIN) { 71 | isAdmin = true 72 | } 73 | } 74 | ctx.success(isAdmin, '校验管理员成功') 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "non-interactive": false, 3 | "dry-run": false, 4 | "verbose": false, 5 | "force": false, 6 | "pkgFiles": ["package.json"], 7 | "increment": "patch", 8 | "preReleaseId": null, 9 | "buildCommand": false, 10 | "safeBump": true, 11 | "beforeChangelogCommand": false, 12 | "changelogCommand": "git log --pretty=format:\"* %s (%h)\" [REV_RANGE]", 13 | "requireCleanWorkingDir": true, 14 | "requireUpstream": true, 15 | "src": { 16 | "commit": true, 17 | "commitMessage": "chore: release %s", 18 | "commitArgs": "", 19 | "tag": true, 20 | "tagName": "release-v%s", 21 | "tagAnnotation": "Release %s", 22 | "push": true, 23 | "pushArgs": "", 24 | "pushRepo": "origin", 25 | "beforeStartCommand": false, 26 | "afterReleaseCommand": false, 27 | "addUntrackedFiles": false 28 | }, 29 | "npm": { 30 | "publish": false, 31 | "publishPath": ".", 32 | "tag": "latest", 33 | "private": false, 34 | "access": null, 35 | "otp": null 36 | }, 37 | "github": { 38 | "release": false, 39 | "releaseName": "Release %s", 40 | "preRelease": false, 41 | "draft": false, 42 | "tokenRef": "GITHUB_TOKEN", 43 | "assets": null, 44 | "host": null, 45 | "timeout": 0, 46 | "proxy": false 47 | }, 48 | "dist": { 49 | "repo": false, 50 | "stageDir": ".stage", 51 | "baseDir": "dist", 52 | "files": ["**/*"], 53 | "pkgFiles": null, 54 | "commit": true, 55 | "commitMessage": "Release %s", 56 | "commitArgs": "", 57 | "tag": true, 58 | "tagName": "%s", 59 | "tagAnnotation": "Release %s", 60 | "push": true, 61 | "pushArgs": "", 62 | "beforeStageCommand": false, 63 | "afterReleaseCommand": false, 64 | "addUntrackedFiles": false, 65 | "github": { 66 | "release": false 67 | }, 68 | "npm": { 69 | "publish": false 70 | } 71 | }, 72 | "prompt": { 73 | "src": { 74 | "status": false, 75 | "commit": true, 76 | "tag": true, 77 | "push": true, 78 | "release": true, 79 | "publish": true 80 | }, 81 | "dist": { 82 | "status": false, 83 | "commit": true, 84 | "tag": false, 85 | "push": true, 86 | "release": false, 87 | "publish": false 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /app/service/github.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc Github api 3 | */ 4 | 5 | const { Service } = require('egg') 6 | 7 | module.exports = class GithubService extends Service { 8 | /** 9 | * @desc GitHub fetcher 10 | * @param {String} url url 11 | * @param {Object} opt 配置 12 | * @return {Object} 抓取的结果 13 | */ 14 | async fetch (url, opt) { 15 | url = 'https://api.github.com' + url 16 | try { 17 | const res = await this.app.curl(url, this.app.merge({ 18 | dataType: 'json', 19 | timeout: 30000, 20 | headers: { 21 | Accept: 'application/json' 22 | } 23 | }, opt)) 24 | if (res && res.status === 200) { 25 | return res.data 26 | } 27 | } catch (error) { 28 | this.logger.error(error) 29 | } 30 | return null 31 | } 32 | 33 | /** 34 | * @desc 获取GitHub用户信息 35 | * @param {String} username 用户名(GitHub login) 36 | * @return {Object} 用户信息 37 | */ 38 | async getUserInfo (username) { 39 | if (!username) return null 40 | let gayhub = {} 41 | if (this.config.isLocal) { 42 | // 测试环境下 用测试配置 43 | gayhub = this.config.github 44 | } else { 45 | const { keys } = this.app.setting 46 | if (!keys || !keys.github) { 47 | this.logger.warn('未找到GitHub配置') 48 | return null 49 | } 50 | gayhub = keys.github 51 | } 52 | const { clientID, clientSecret } = gayhub 53 | const data = await this.fetch(`/users/${username}?client_id=${clientID}&client_secret=${clientSecret}`) 54 | if (data) { 55 | this.logger.info(`GitHub用户信息抓取成功:${username}`) 56 | } else { 57 | this.logger.warn(`GitHub用户信息抓取失败:${username}`) 58 | } 59 | return data 60 | } 61 | 62 | /** 63 | * @desc 批量获取GitHub用户信息 64 | * @param {Array} usernames username array 65 | * @return {Array} 返回数据 66 | */ 67 | async getUsersInfo (usernames = []) { 68 | if (!Array.isArray(usernames) || !usernames.length) return [] 69 | return await Promise.all(usernames.map(name => this.getUserInfo(name))) 70 | } 71 | 72 | async getAuthUserInfo (access_token) { 73 | const data = await this.fetch(`/user?access_token=${access_token}`) 74 | if (data) { 75 | this.logger.warn('Github用户信息抓取成功') 76 | } else { 77 | this.logger.warn('Github用户信息抓取失败') 78 | } 79 | return data 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /test/app/service/tag.test.js: -------------------------------------------------------------------------------- 1 | const { app, assert } = require('egg-mock/bootstrap') 2 | 3 | describe('test/app/service/tag.test.js', () => { 4 | let ctx, 5 | tagService, 6 | tag 7 | 8 | before(() => { 9 | ctx = app.mockContext() 10 | tagService = ctx.service.tag 11 | }) 12 | 13 | it('create pass', async () => { 14 | const name = '测试标签' 15 | const description = '测试标签描述' 16 | const exts = [{ key: 'icon', value: 'fa-fuck' }] 17 | const data = await tagService.create({ name, description, extends: exts }) 18 | assert(data.name === name) 19 | assert(data.description === description) 20 | assert(data.extends.length === exts.length && data.extends[0].key === exts[0].key && data.extends[0].value === exts[0].value) 21 | tag = data 22 | }) 23 | 24 | it('getList pass', async () => { 25 | const query = {} 26 | const data = await tagService.getList(query) 27 | assert.equal(data.every(item => 'count' in item), true) 28 | }) 29 | 30 | it('getItem pass', async () => { 31 | const find = await tagService.getItem({ name: tag.name }) 32 | assert.equal(find._id.toString(), tag._id.toString()) 33 | assert.equal(find.name, tag.name) 34 | assert.equal(find.description, tag.description) 35 | }) 36 | 37 | it('getItemById pass', async () => { 38 | const find = await tagService.getItemById(tag._id) 39 | assert.equal(find._id.toString(), tag._id.toString()) 40 | assert.equal(find.name, tag.name) 41 | assert.equal(find.description, tag.description) 42 | }) 43 | 44 | it('updateItemById pass', async () => { 45 | const update = { 46 | name: '测试标签修改', 47 | description: '测试标签描述修改', 48 | extends: [{ key: 'icon', value: 'fa-fuck-m' }] 49 | } 50 | const data = await tagService.updateItemById(tag._id, update) 51 | assert.equal(data._id.toString(), tag._id.toString()) 52 | assert.equal(data.name, update.name) 53 | assert.equal(data.description, update.description) 54 | assert(data.extends.length === update.extends.length && data.extends[0].key === update.extends[0].key && data.extends[0].value === update.extends[0].value) 55 | assert.notEqual(data.name, tag.name) 56 | assert.notEqual(data.description, tag.description) 57 | assert(data.extends[0].key === tag.extends[0].key && data.extends[0].value !== tag.extends[0].value) 58 | }) 59 | 60 | it('deleteItemById pass', async () => { 61 | const data = await tagService.deleteItemById(tag._id) 62 | assert.equal(data._id.toString(), tag._id.toString()) 63 | const find = await tagService.getItemById(tag._id) 64 | assert.equal(find, null) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-server", 3 | "version": "2.2.3", 4 | "description": "", 5 | "private": true, 6 | "dependencies": { 7 | "akismet-api": "^4.2.0", 8 | "ali-oss": "^6.0.1", 9 | "axios": "^0.18.0", 10 | "bcryptjs": "^2.4.3", 11 | "egg": "^2.14.1", 12 | "egg-alinode-async": "^2.1.2", 13 | "egg-console": "^2.0.1", 14 | "egg-cors": "^2.1.0", 15 | "egg-mongoose": "^3.1.0", 16 | "egg-redis": "^2.0.0", 17 | "egg-router-plus": "^1.2.2", 18 | "egg-scripts": "^2.5.0", 19 | "egg-sentry": "^1.0.0", 20 | "egg-validate": "^1.1.1", 21 | "email-templates": "^5.0.1", 22 | "geoip-lite": "^1.3.2", 23 | "gravatar": "^1.6.0", 24 | "highlight.js": "^9.12.0", 25 | "jsonwebtoken": "^8.3.0", 26 | "koa-compose": "^4.1.0", 27 | "koa-is-json": "^1.0.0", 28 | "lodash": "^4.17.10", 29 | "marked": "^0.5.0", 30 | "merge": "^1.2.0", 31 | "moment": "^2.22.2", 32 | "mongoose": "5.2.8", 33 | "mongoose-paginate-v2": "^1.0.12", 34 | "nodemailer": "^4.6.8", 35 | "pug": "^2.0.3", 36 | "simple-netease-cloud-music": "^0.4.0", 37 | "validator": "^10.6.0", 38 | "zlib": "^1.0.5" 39 | }, 40 | "devDependencies": { 41 | "autod": "^3.0.1", 42 | "autod-egg": "^1.0.0", 43 | "egg-bin": "^4.3.5", 44 | "egg-ci": "^1.8.0", 45 | "egg-mock": "^3.14.0", 46 | "eslint": "^4.11.0", 47 | "eslint-config-egg": "^6.0.0", 48 | "pre-git": "^3.17.1", 49 | "release-it": "^7.6.1", 50 | "webstorm-disable-index": "^1.2.0" 51 | }, 52 | "engines": { 53 | "node": ">=8.9.0" 54 | }, 55 | "scripts": { 56 | "start": "egg-scripts start --daemon --title=node-server", 57 | "stop": "egg-scripts stop --title=node-server", 58 | "docker": "egg-scripts start --title=node-server", 59 | "dev": "egg-bin dev", 60 | "debug": "egg-bin debug", 61 | "test": "npm run lint -- --fix && npm run test-local", 62 | "test-local": "egg-bin test", 63 | "cov": "egg-bin cov", 64 | "lint": "eslint . --fix", 65 | "ci": "npm run lint && npm run cov", 66 | "autod": "autod", 67 | "rc": "release-it", 68 | "commit": "commit-wizard" 69 | }, 70 | "ci": { 71 | "version": "8" 72 | }, 73 | "repository": { 74 | "type": "git", 75 | "url": "git@github.com:jo0ger/node-server.git" 76 | }, 77 | "author": { 78 | "name": "jo0ger", 79 | "email": "iamjooger@gmail.com", 80 | "url": "https://jooger.me" 81 | }, 82 | "license": "MIT", 83 | "release": { 84 | "analyzeCommits": "simple-commit-message" 85 | }, 86 | "config": { 87 | "pre-git": { 88 | "commit-msg": "simple", 89 | "pre-commit": [ 90 | "yarn lint" 91 | ], 92 | "pre-push": [], 93 | "post-commit": [], 94 | "post-checkout": [], 95 | "post-merge": [] 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /emails/comment.pug: -------------------------------------------------------------------------------- 1 | .container(style=` 2 | --dark-color: #000; 3 | --dark-color-light: rgba(0, 0, 0, 0.2); 4 | --light-color: #fff; 5 | --light-color-light: hsla(0, 0%, 100%, 0.2); 6 | --body-color: #f2f2f2; 7 | --primary-color: #302e31; 8 | --primary-color-light: rgba(48, 46, 49, 0.2); 9 | --overlay-color: rgba(0, 0, 0, 0.6); 10 | --overlay-color-dark: rgba(0, 0, 0, 0.8); 11 | --heading-color: rgba(0, 0, 0, 0.85); 12 | --text-color: rgba(0, 0, 0, 0.65); 13 | --text-color-secondary: rgba(0, 0, 0, 0.43); 14 | --disabled-color: rgba(0, 0, 0, 0.25); 15 | --link-color: rgba(0, 0, 0, 0.65); 16 | --link-color-hover: rgba(0, 0, 0, 0.85); 17 | --card-color: hsla(0, 0%, 100%, 0.8); 18 | --border-color: #e6e6e6; 19 | --border-color-dark: #cfcfcf; 20 | --keyword-color: #f56a00; 21 | --keyword-color-light: rgba(245, 106, 0, 0.2); 22 | --button-color: #f2f2f2; 23 | --button-color-hover: #dadada; 24 | --selection-color: #add8f7; 25 | --code-color: #ebebeb; 26 | --code-color-light: #f2f2f2; 27 | --code-color-dark: #e6e6e6; 28 | --box-shadow-color: #ebebeb; 29 | --markdown-color: rgba(0, 0, 0, 0.85) 30 | width: 100%; 31 | max-width: 500px; 32 | margin: 0 auto; 33 | padding: 24px; 34 | background-color: #fff; 35 | border-radius: 4px; 36 | border: 1px solid #e6e6e6; 37 | color: var(--text-color); 38 | font-size: 14px; 39 | `) 40 | .header(style=` 41 | text-align: center 42 | `) 43 | p.title #{title} 44 | .main 45 | .comment 46 | .title(style=` 47 | position: relative; 48 | height: 24px; 49 | line-height: 24px; 50 | `) 51 | img.avatar( 52 | src=author.avatar 53 | style=` 54 | position: absolute; 55 | top: 0; 56 | left: 0; 57 | width: 24px; 58 | height: 24px; 59 | border-radius: 50%; 60 | ` 61 | ) 62 | h3.name(style=` 63 | margin: 0; 64 | margin-left: 32px; 65 | font-size: 16px; 66 | `) #{author.name} 67 | .time(style=` 68 | position: absolute; 69 | top: 0; 70 | right: 0; 71 | color: rgba(0, 0, 0, 0.43); 72 | `) #{createdAt} 73 | .content.markdown-body(style=` 74 | margin: 8px 0; 75 | `) 76 | != renderedContent 77 | .footer(style=` 78 | margin-top: 32px; 79 | text-align: center; 80 | `) 81 | if showReplyBtn 82 | a( 83 | href=link 84 | target="_blank" 85 | style=` 86 | padding: 8px 16px; 87 | background: #f2f2f2; 88 | border-radius: 4px; 89 | color: rgba(0, 0, 0, 0.65); 90 | text-decoration: none; 91 | ` 92 | ) 去回复 93 | 94 | -------------------------------------------------------------------------------- /app/service/akismet.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc Akismet Services 3 | */ 4 | 5 | const { Service } = require('egg') 6 | 7 | module.exports = class AkismetService extends Service { 8 | checkSpam (opt = {}) { 9 | this.app.coreLogger.info('验证评论中...') 10 | return new Promise(resolve => { 11 | if (this.app._akismetValid) { 12 | this.app.akismet.checkSpam(opt, (err, spam) => { 13 | if (err) { 14 | this.app.coreLogger.error('评论验证失败,将跳过Spam验证,错误:', err.message) 15 | this.service.notification.recordGeneral('AKISMET', 'CHECK_FAIL', err) 16 | return resolve(false) 17 | } 18 | if (spam) { 19 | this.app.coreLogger.warn('评论验证不通过,疑似垃圾评论') 20 | resolve(true) 21 | } else { 22 | this.app.coreLogger.info('评论验证通过') 23 | resolve(false) 24 | } 25 | }) 26 | } else { 27 | this.app.coreLogger.warn('Apikey未认证,将跳过Spam验证') 28 | resolve(false) 29 | } 30 | }) 31 | } 32 | 33 | // 提交被误检为spam的正常评论 34 | submitSpam (opt = {}) { 35 | this.app.coreLogger.info('误检Spam垃圾评论报告提交中...') 36 | return new Promise((resolve, reject) => { 37 | if (this.app._akismetValid) { 38 | this.app.akismet.submitSpam(opt, err => { 39 | if (err) { 40 | this.app.coreLogger.error('误检Spam垃圾评论报告提交失败') 41 | this.service.notification.recordGeneral('AKISMET', 'CHECK_FAIL', err) 42 | return reject(err) 43 | } 44 | this.app.coreLogger.info('误检Spam垃圾评论报告提交成功') 45 | resolve() 46 | }) 47 | } else { 48 | this.app.coreLogger.warn('Apikey未认证,误检Spam垃圾评论报告提交失败') 49 | resolve() 50 | } 51 | }) 52 | } 53 | 54 | // 提交被误检为正常评论的spam 55 | submitHam (opt = {}) { 56 | this.app.coreLogger.info('误检正常评论报告提交中...') 57 | return new Promise((resolve, reject) => { 58 | if (this.app._akismetValid) { 59 | this.app.akismet.submitSpam(opt, err => { 60 | if (err) { 61 | this.app.coreLogger.error('误检正常评论报告提交失败') 62 | this.service.notification.recordGeneral('AKISMET', 'CHECK_FAIL', err) 63 | return reject(err) 64 | } 65 | this.app.coreLogger.info('误检正常评论报告提交成功') 66 | resolve() 67 | }) 68 | } else { 69 | this.app.coreLogger.warn('Apikey未认证,误检正常评论报告提交失败') 70 | resolve() 71 | } 72 | }) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /test/app/service/category.test.js: -------------------------------------------------------------------------------- 1 | const { app, assert } = require('egg-mock/bootstrap') 2 | 3 | describe('test/app/service/category.test.js', () => { 4 | let ctx, 5 | categoryService, 6 | category 7 | 8 | before(() => { 9 | ctx = app.mockContext() 10 | categoryService = ctx.service.category 11 | }) 12 | 13 | it('create pass', async () => { 14 | const name = '测试分类' 15 | const description = '测试分类描述' 16 | const exts = [{ key: 'icon', value: 'fa-fuck' }] 17 | const data = await categoryService.create({ name, description, extends: exts }) 18 | assert(data.name === name) 19 | assert(data.description === description) 20 | assert(data.extends.length === exts.length && data.extends[0].key === exts[0].key && data.extends[0].value === exts[0].value) 21 | category = data 22 | }) 23 | 24 | it('getList pass', async () => { 25 | const query = {} 26 | const data = await categoryService.getList(query) 27 | assert.equal(data.every(item => 'count' in item), true) 28 | }) 29 | 30 | it('getItem pass', async () => { 31 | const find = await categoryService.getItem({ name: category.name }) 32 | assert.equal(find._id.toString(), category._id.toString()) 33 | assert.equal(find.name, category.name) 34 | assert.equal(find.description, category.description) 35 | }) 36 | 37 | it('getItemById pass', async () => { 38 | const find = await categoryService.getItemById(category._id) 39 | assert.equal(find._id.toString(), category._id.toString()) 40 | assert.equal(find.name, category.name) 41 | assert.equal(find.description, category.description) 42 | }) 43 | 44 | it('updateItemById pass', async () => { 45 | const update = { 46 | name: '测试分类修改', 47 | description: '测试分类描述修改', 48 | extends: [{ key: 'icon', value: 'fa-fuck-m' }] 49 | } 50 | const data = await categoryService.updateItemById(category._id, update) 51 | assert.equal(data._id.toString(), category._id.toString()) 52 | assert.equal(data.name, update.name) 53 | assert.equal(data.description, update.description) 54 | assert(data.extends.length === update.extends.length && data.extends[0].key === update.extends[0].key && data.extends[0].value === update.extends[0].value) 55 | assert.notEqual(data.name, category.name) 56 | assert.notEqual(data.description, category.description) 57 | assert(data.extends[0].key === category.extends[0].key && data.extends[0].value !== category.extends[0].value) 58 | }) 59 | 60 | it('deleteItemById pass', async () => { 61 | const data = await categoryService.deleteItemById(category._id) 62 | assert.equal(data._id.toString(), category._id.toString()) 63 | const find = await categoryService.getItemById(category._id) 64 | assert.equal(find, null) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /app/model/comment.js: -------------------------------------------------------------------------------- 1 | module.exports = app => { 2 | const { mongoose, config } = app 3 | const { Schema } = mongoose 4 | const commentValidateConfig = config.modelEnum.comment 5 | 6 | const CommentSchema = new Schema({ 7 | // ******* 评论通用项 ************ 8 | // 评论内容 9 | content: { type: String, required: true, validate: /\S+/ }, 10 | // marked渲染后的内容 11 | renderedContent: { type: String, required: true, validate: /\S+/ }, 12 | // 状态 -2 垃圾评论 | -1 隐藏 | 0 待审核 | 1 通过 13 | state: { 14 | type: Number, 15 | default: commentValidateConfig.state.default, 16 | validate: val => Object.values(commentValidateConfig.state.optional).includes(val) 17 | }, 18 | // Akismet判定是否是垃圾评论,方便后台check 19 | spam: { type: Boolean, default: false }, 20 | // 评论发布者 21 | author: { type: Schema.Types.ObjectId, ref: 'User' }, 22 | // 点赞数 23 | ups: { type: Number, default: 0, validate: /^\d*$/ }, 24 | // 是否置顶 25 | sticky: { type: Boolean, default: false }, 26 | // 类型 0 文章评论 | 1 站内留言 | 2 其他(保留) 27 | type: { 28 | type: Number, 29 | default: commentValidateConfig.type.default, 30 | validate: val => Object.values(commentValidateConfig.type.optional).includes(val) 31 | }, 32 | // type为0时此项存在 33 | article: { type: Schema.Types.ObjectId, ref: 'Article' }, 34 | meta: { 35 | // 用户IP 36 | ip: String, 37 | // IP所在地 38 | location: Object, 39 | // user agent 40 | ua: { type: String, validate: /\S+/ }, 41 | // refer 42 | referer: { type: String, default: '' } 43 | }, 44 | // ******** 子评论具备项 ************ 45 | // 父评论 46 | parent: { type: mongoose.Schema.Types.ObjectId, ref: 'Comment' }, // 父评论 parent和forward二者必须同时存在 47 | // 评论的上一级 48 | forward: { type: mongoose.Schema.Types.ObjectId, ref: 'Comment' } // 前一条评论ID,可以是parent的id, 比如 B评论 是 A评论的回复,则B.forward._id = A._id,主要是为了查看评论对话时的评论树构建 49 | }) 50 | 51 | return mongoose.model('Comment', app.processSchema(CommentSchema, { 52 | paginate: true 53 | }, { 54 | pre: { 55 | save (next) { 56 | if (this.content) { 57 | this.renderedContent = app.utils.markdown.render(this.content, true) 58 | } 59 | next() 60 | }, 61 | async findOneAndUpdate () { 62 | delete this._update.updatedAt 63 | const { content } = this._update 64 | const find = await this.model.findOne(this._conditions) 65 | if (find) { 66 | if (content && content !== find.content) { 67 | this._update.renderedContent = app.utils.markdown.render(content, true) 68 | this._update.updatedAt = Date.now() 69 | } 70 | } 71 | } 72 | } 73 | })) 74 | } 75 | -------------------------------------------------------------------------------- /app/service/proxy.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 公共的model proxy service 3 | */ 4 | 5 | const { Service } = require('egg') 6 | 7 | module.exports = class ProxyService extends Service { 8 | init () { 9 | return this.model.init() 10 | } 11 | 12 | getList (query, select = null, opt, populate = []) { 13 | const Q = this.model.find(query, select, opt) 14 | if (populate) { 15 | [].concat(populate).forEach(item => Q.populate(item)) 16 | } 17 | return Q.exec() 18 | } 19 | 20 | async getLimitListByQuery (query, opt) { 21 | opt = Object.assign({ lean: true }, opt) 22 | const data = await this.model.paginate(query, opt) 23 | return this.app.getDocsPaginationData(data) 24 | } 25 | 26 | getItem (query, select = null, opt, populate = []) { 27 | opt = this.app.merge({ 28 | lean: true 29 | }, opt) 30 | let Q = this.model.findOne(query, select, opt) 31 | if (populate) { 32 | [].concat(populate).forEach(item => { 33 | Q = Q.populate(item) 34 | }) 35 | } 36 | return Q.exec() 37 | } 38 | 39 | getItemById (id, select = null, opt, populate = []) { 40 | opt = this.app.merge({ 41 | lean: true 42 | }, opt) 43 | const Q = this.model.findById(id, select, opt) 44 | if (populate) { 45 | [].concat(populate).forEach(item => Q.populate(item)) 46 | } 47 | return Q.exec() 48 | } 49 | 50 | create (payload) { 51 | return this.model.create(payload) 52 | } 53 | 54 | newAndSave (payload) { 55 | return new this.model(payload).save() 56 | } 57 | 58 | updateItem (query = {}, data, opt, populate = []) { 59 | opt = this.app.merge({ 60 | lean: true, 61 | new: true 62 | }) 63 | const Q = this.model.findOneAndUpdate(query, data, opt) 64 | if (populate) { 65 | [].concat(populate).forEach(item => Q.populate(item)) 66 | } 67 | return Q.exec() 68 | } 69 | 70 | updateItemById (id, data, opt, populate = []) { 71 | opt = this.app.merge({ 72 | lean: true, 73 | new: true 74 | }) 75 | const Q = this.model.findByIdAndUpdate(id, data, opt) 76 | if (populate) { 77 | [].concat(populate).forEach(item => Q.populate(item)) 78 | } 79 | return Q.exec() 80 | } 81 | 82 | updateMany (query, data, opt) { 83 | return this.model.updateMany(query, data, opt) 84 | } 85 | 86 | updateManyById (id, data, opt) { 87 | return this.updateMany({ _id: id }, data, opt) 88 | } 89 | 90 | deleteItemById (id, opt) { 91 | return this.model.findByIdAndDelete(id, opt).exec() 92 | } 93 | 94 | aggregate (pipeline = []) { 95 | return this.model.aggregate(pipeline) 96 | } 97 | 98 | count (filter) { 99 | return this.model.count(filter).exec() 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /app/model/setting.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 设置参数模型 3 | */ 4 | 5 | module.exports = app => { 6 | const { mongoose } = app 7 | const { Schema } = mongoose 8 | 9 | const SettingSchema = new Schema({ 10 | // 站点设置 11 | site: { 12 | logo: { type: String, validate: app.utils.validate.isUrl }, 13 | welcome: { type: String, default: '' }, 14 | links: [{ 15 | id: { type: Schema.Types.ObjectId, required: true }, 16 | name: { type: String, required: true }, 17 | github: { type: String, default: '' }, 18 | avatar: { type: String, default: '' }, 19 | slogan: { type: String, default: '' }, 20 | site: { type: String, required: true } 21 | }], 22 | musicId: { type: String, default: '' } 23 | }, 24 | // 个人信息 25 | personal: { 26 | slogan: { type: String, default: '' }, 27 | description: { type: String, default: '' }, 28 | tag: [{ type: String }], 29 | hobby: [{ type: String }], 30 | skill: [{ type: String }], 31 | location: { type: String, default: '' }, 32 | company: { type: String, default: '' }, 33 | user: { type: Schema.Types.ObjectId, ref: 'User' }, 34 | github: { type: Object, default: {} } 35 | }, 36 | // 第三方插件的参数 37 | keys: { 38 | // 阿里云oss 39 | aliyun: { 40 | accessKeyId: { type: String, default: '' }, 41 | accessKeySecret: { type: String, default: '' }, 42 | bucket: { type: String, default: '' }, 43 | region: { type: String, default: '' } 44 | }, 45 | // 阿里node平台 46 | alinode: { 47 | appid: { type: String, default: '' }, 48 | secret: { type: String, default: '' } 49 | }, 50 | aliApiGateway: { 51 | // 查询IP 52 | ip: { 53 | appCode: { type: String, default: '' } 54 | } 55 | }, 56 | // 163邮箱 57 | mail: { 58 | user: { type: String, default: '' }, 59 | pass: { type: String, default: '' } 60 | }, 61 | // gayhub 62 | github: { 63 | clientID: { type: String, default: '' }, 64 | clientSecret: { type: String, default: '' } 65 | }, 66 | // 百度seo token 67 | baiduSeo: { 68 | token: { type: String, default: '' } 69 | } 70 | }, 71 | limit: { 72 | articleCount: { type: Number, default: 10 }, 73 | commentCount: { type: Number, default: 20 }, 74 | relatedArticleCount: { type: Number, default: 10 }, 75 | hotArticleCount: { type: Number, default: 7 }, 76 | commentSpamMaxCount: { type: Number, default: 3 }, 77 | momentCount: { type: Number, default: 10 } 78 | } 79 | }) 80 | 81 | return mongoose.model('Setting', app.processSchema(SettingSchema)) 82 | } 83 | -------------------------------------------------------------------------------- /app/controller/setting.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc Setting Controller 3 | */ 4 | 5 | const { Controller } = require('egg') 6 | 7 | module.exports = class SettingController extends Controller { 8 | get rules () { 9 | return { 10 | index: { 11 | filter: { type: 'string', required: false } 12 | }, 13 | update: { 14 | site: { type: 'object', required: false }, 15 | personal: { type: 'object', required: false }, 16 | keys: { type: 'object', required: false }, 17 | limit: { type: 'object', required: false } 18 | } 19 | } 20 | } 21 | 22 | async index () { 23 | const { ctx } = this 24 | ctx.validate(this.rules.index, ctx.query) 25 | let select = null 26 | if (ctx.query.filter) { 27 | select = ctx.query.filter 28 | } 29 | let populate = null 30 | if (!ctx.session._isAuthed) { 31 | select = '-keys' 32 | populate = [ 33 | { 34 | path: 'personal.user', 35 | select: 'name email site avatar' 36 | } 37 | ] 38 | } else { 39 | populate = [ 40 | { 41 | path: 'personal.user', 42 | select: '-password' 43 | } 44 | ] 45 | } 46 | const data = await this.service.setting.getItem( 47 | {}, 48 | select, 49 | null, 50 | populate 51 | ) 52 | if (data) { 53 | if (!data.personal.github) { 54 | data.personal.github = {} 55 | } 56 | ctx.success(data, '配置获取成功') 57 | } else { 58 | ctx.fail('配置获取失败') 59 | } 60 | } 61 | 62 | async update () { 63 | const { ctx } = this 64 | const body = ctx.validateBody(this.rules.update) 65 | const exist = await this.service.setting.getItem() 66 | if (!exist) { 67 | return ctx.fail('配置未找到') 68 | } 69 | const update = this.app.merge({}, exist, body) 70 | // 先不更新友链,在下方更新 71 | update.site.links = exist.site.links 72 | let data = await this.service.setting.updateItemById( 73 | exist._id, 74 | update, 75 | null, 76 | [ 77 | { 78 | path: 'personal.user', 79 | select: '-password' 80 | } 81 | ] 82 | ) 83 | if (!data) { 84 | return ctx.fail('配置更新失败') 85 | } 86 | 87 | if (body.site && body.site.links) { 88 | // 抓取友链 89 | data = await this.service.setting.updateLinks(body.site.links) 90 | } 91 | 92 | if (body.personal && body.personal.github) { 93 | // 更新github信息 94 | data = await this.service.setting.updateGithubInfo() 95 | } 96 | 97 | this.service.setting.mountToApp(data) 98 | 99 | if (body.site && body.site.musicId && body.site.musicId !== exist.site.musicId) { 100 | // 更新music缓存 101 | this.app.runSchedule('music') 102 | } 103 | 104 | ctx.success(data, '配置更新成功') 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /app/extend/context.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | processPayload (payload) { 3 | if (!payload) return null 4 | const result = {} 5 | for (const key in payload) { 6 | if (payload.hasOwnProperty(key)) { 7 | const value = payload[key] 8 | if (value !== undefined) { 9 | result[key] = value 10 | } 11 | } 12 | } 13 | return result 14 | }, 15 | validateParams (rules) { 16 | this.validate(rules, this.params) 17 | return this.params 18 | }, 19 | validateBody (rules, body, dry = true) { 20 | if (typeof body === 'number') { 21 | dry = body 22 | body = this.request.body 23 | } else { 24 | body = body || this.request.body 25 | } 26 | this.validate(rules, body) 27 | return dry && Object.keys(rules).reduce((res, key) => { 28 | if (body.hasOwnProperty(key)) { 29 | res[key] = body[key] 30 | } 31 | return res 32 | }, {}) || body 33 | }, 34 | validateParamsObjectId () { 35 | return this.validateParams({ 36 | id: { 37 | type: 'objectId', 38 | required: true 39 | } 40 | }) 41 | }, 42 | validateCommentAuthor (author) { 43 | author = author || this.request.body.author 44 | const { isObjectId, isObject } = this.app.utils.validate 45 | if (isObject(author)) { 46 | this.validate({ 47 | name: 'string', 48 | email: 'string' 49 | }, author) 50 | } else if (!isObjectId(author)) { 51 | this.throw(422, '发布人不存在') 52 | } 53 | }, 54 | getCtxIp () { 55 | const req = this.req 56 | return (req.headers['x-forwarded-for'] 57 | || req.headers['x-real-ip'] 58 | || req.connection.remoteAddress 59 | || req.socket.remoteAddress 60 | || req.connection.socket.remoteAddress 61 | || req.ip 62 | || req.ips[0] 63 | || '' 64 | ).replace('::ffff:', '') 65 | }, 66 | async getLocation () { 67 | const ip = this.getCtxIp() 68 | return await this.service.agent.lookupIp(ip) 69 | }, 70 | success (data = null, message) { 71 | const { codeMap } = this.app.config 72 | const successMsg = codeMap[200] 73 | message = message || successMsg 74 | if (this.app.utils.validate.isString(data)) { 75 | message = data 76 | data = null 77 | } 78 | this.status = 200 79 | this.body = { 80 | code: 200, 81 | success: true, 82 | message, 83 | data 84 | } 85 | }, 86 | fail (code = -1, message = '', error = null) { 87 | const { codeMap } = this.app.config 88 | const failMsg = codeMap[-1] 89 | if (this.app.utils.validate.isString(code)) { 90 | error = message || null 91 | message = code 92 | code = -1 93 | } 94 | const body = { 95 | code, 96 | success: false, 97 | message: message || codeMap[code] || failMsg 98 | } 99 | if (error) body.error = error 100 | this.status = code === -1 ? 200 : code 101 | this.body = body 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /app/extend/application.js: -------------------------------------------------------------------------------- 1 | const mongoosePaginate = require('mongoose-paginate-v2') 2 | const lodash = require('lodash') 3 | const merge = require('merge') 4 | const jwt = require('jsonwebtoken') 5 | 6 | const prefix = 'http://' 7 | const STORE = Symbol('Application#store') 8 | 9 | module.exports = { 10 | // model schema处理 11 | processSchema (schema, options = {}, middlewares = {}) { 12 | if (!schema) { 13 | return null 14 | } 15 | schema.set('versionKey', false) 16 | schema.set('toObject', { getters: true, virtuals: false }) 17 | schema.set('toJSON', { getters: true, virtuals: false }) 18 | schema.add({ 19 | // 创建日期 20 | createdAt: { type: Date, default: Date.now }, 21 | // 更新日期 22 | updatedAt: { type: Date, default: Date.now } 23 | }) 24 | if (options && options.paginate) { 25 | schema.plugin(mongoosePaginate) 26 | } 27 | schema.pre('findOneAndUpdate', function (next) { 28 | this._update.updatedAt = Date.now() 29 | next() 30 | }) 31 | Object.keys(middlewares).forEach(key => { 32 | const fns = middlewares[key] 33 | Object.keys(fns).forEach(action => { 34 | schema[key](action, fns[action]) 35 | }) 36 | }) 37 | return schema 38 | }, 39 | merge () { 40 | return merge.recursive.apply(null, [true].concat(Array.prototype.slice.call(arguments))) 41 | }, 42 | proxyUrl (url) { 43 | if (lodash.isString(url) && url.startsWith(prefix)) { 44 | return url.replace(prefix, `${this.config.author.url}/proxy/`) 45 | } 46 | return url 47 | }, 48 | // 获取分页请求的响应数据 49 | getDocsPaginationData (docs) { 50 | if (!docs) return null 51 | return { 52 | list: docs.docs, 53 | pageInfo: { 54 | total: docs.totalDocs, 55 | current: docs.page > docs.totalPages ? docs.totalPages : docs.page, 56 | pages: docs.totalPages, 57 | limit: docs.limit 58 | } 59 | } 60 | }, 61 | async verifyToken (token) { 62 | if (token) { 63 | let decodedToken = null 64 | try { 65 | decodedToken = await jwt.verify(token, this.config.secrets) 66 | } catch (err) { 67 | this.logger.warn('Token校验出错,错误:' + err.message) 68 | return false 69 | } 70 | if (decodedToken && decodedToken.exp > Math.floor(Date.now() / 1000)) { 71 | // 已校验权限 72 | this.logger.info('Token校验成功') 73 | return true 74 | } 75 | } 76 | return false 77 | }, 78 | get store () { 79 | if (!this[STORE]) { 80 | const app = this 81 | this[STORE] = { 82 | async get (key) { 83 | const res = await app.redis.get(key) 84 | if (!res) return null 85 | return JSON.parse(res) 86 | }, 87 | async set (key, value, maxAge) { 88 | if (!maxAge) maxAge = 24 * 60 * 60 * 1000; 89 | value = JSON.stringify(value); 90 | await app.redis.set(key, value, 'PX', maxAge); 91 | }, 92 | async destroy (key) { 93 | await app.redis.del(key) 94 | } 95 | } 96 | } 97 | return this[STORE] 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /app/model/article.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 文章模型 3 | */ 4 | 5 | module.exports = app => { 6 | const { mongoose, config } = app 7 | const { Schema } = mongoose 8 | const articleValidateConfig = config.modelEnum.article 9 | 10 | const ArticleSchema = new Schema({ 11 | // 文章标题 12 | title: { type: String, required: true }, 13 | // 文章关键字(FOR SEO) 14 | keywords: [{ type: String }], 15 | // 文章摘要 (FOR SEO) 16 | description: { type: String, default: '' }, 17 | // 文章原始markdown内容 18 | content: { type: String, required: true, validate: /\S+/ }, 19 | // markdown渲染后的htmln内容 20 | renderedContent: { type: String, required: false, validate: /\S+/ }, 21 | // 分类 22 | category: { type: Schema.Types.ObjectId, ref: 'Category' }, 23 | // 标签 24 | tag: [{ type: Schema.Types.ObjectId, ref: 'Tag' }], 25 | // 缩略图 (图片uid, 图片名称,图片URL, 图片大小) 26 | thumb: { type: String, validate: app.utils.validate.isUrl }, 27 | // 来源 0 原创 | 1 转载 | 2 混撰 | 3 翻译 28 | source: { 29 | type: Number, 30 | default: articleValidateConfig.source.default, 31 | validate: val => Object.values(articleValidateConfig.source.optional).includes(val) 32 | }, 33 | // source为1的时候的原文链接 34 | from: { type: String, validate: app.utils.validate.isUrl }, 35 | // 文章状态 ( 0 草稿 | 1 已发布 ) 36 | state: { 37 | type: Number, 38 | default: articleValidateConfig.state.default, 39 | validate: val => Object.values(articleValidateConfig.state.optional).includes(val) 40 | }, 41 | // 发布日期 42 | publishedAt: { type: Date, default: Date.now }, 43 | // 文章元数据 (浏览量, 喜欢数, 评论数) 44 | meta: { 45 | pvs: { type: Number, default: 0, validate: /^\d*$/ }, 46 | ups: { type: Number, default: 0, validate: /^\d*$/ }, 47 | comments: { type: Number, default: 0, validate: /^\d*$/ } 48 | } 49 | }) 50 | 51 | return mongoose.model('Article', app.processSchema(ArticleSchema, { 52 | paginate: true 53 | }, { 54 | pre: { 55 | save (next) { 56 | if (this.content) { 57 | this.renderedContent = app.utils.markdown.render(this.content) 58 | } 59 | next() 60 | }, 61 | async findOneAndUpdate () { 62 | // HACK: 这里this指向的是query,而不是这个model 63 | delete this._update.updatedAt 64 | const { content, state } = this._update 65 | const find = await this.model.findOne(this._conditions) 66 | if (find) { 67 | if (content && content !== find.content) { 68 | this._update.renderedContent = app.utils.markdown.render(content) 69 | } 70 | if (['title', 'content'].some(key => { 71 | return this._update.hasOwnProperty(key) 72 | && this._update[key] !== find[key] 73 | })) { 74 | // 只有内容和标题不一样时才更新updatedAt 75 | this._update.updatedAt = Date.now() 76 | } 77 | if (state !== find.state) { 78 | // 更新发布日期 79 | if (state === articleValidateConfig.state.optional.PUBLISH) { 80 | this._update.publishedAt = Date.now() 81 | } else { 82 | this._update.publishedAt = find.updatedAt 83 | } 84 | } 85 | } 86 | } 87 | } 88 | })) 89 | } 90 | -------------------------------------------------------------------------------- /app/controller/moment.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 说说 Controller 3 | */ 4 | 5 | const { Controller } = require('egg') 6 | 7 | module.exports = class MomentController extends Controller { 8 | get rules () { 9 | return { 10 | list: { 11 | page: { type: 'int', required: false, min: 1 }, 12 | limit: { type: 'int', required: false, min: 1 } 13 | }, 14 | create: { 15 | content: { type: 'string', required: true }, 16 | extends: { 17 | type: 'array', 18 | required: false, 19 | itemType: 'object', 20 | rule: { 21 | key: 'string', 22 | value: 'string' 23 | } 24 | } 25 | }, 26 | update: { 27 | content: { type: 'string', required: false }, 28 | extends: { 29 | type: 'array', 30 | required: false, 31 | itemType: 'object', 32 | rule: { 33 | key: 'string', 34 | value: 'string' 35 | } 36 | } 37 | } 38 | } 39 | } 40 | 41 | async list () { 42 | const { ctx } = this 43 | ctx.query.page = Number(ctx.query.page || 1) 44 | if (ctx.query.limit) { 45 | ctx.query.limit = Number(ctx.query.limit) 46 | } 47 | ctx.validate(this.rules.list, ctx.query) 48 | const { page, limit, keyword } = ctx.query 49 | const options = { 50 | sort: { 51 | createdAt: -1 52 | }, 53 | page, 54 | limit: limit || this.app.setting.limit.momentCount || 10 55 | } 56 | if (!ctx.session._isAuthed) { 57 | options.select = '-location.ip -location.isp -location.isp_id' 58 | } 59 | const query = {} 60 | // 搜索关键词 61 | if (keyword) { 62 | const keywordReg = new RegExp(keyword) 63 | query.$or = [ 64 | { content: keywordReg } 65 | ] 66 | } 67 | const data = await this.service.moment.getLimitListByQuery(query, options) 68 | data 69 | ? ctx.success(data, '列表获取成功') 70 | : ctx.fail('列表获取失败') 71 | } 72 | 73 | async item () { 74 | const { ctx } = this 75 | const params = ctx.validateParamsObjectId() 76 | const data = await this.service.service.getItemById(params.id) 77 | data 78 | ? ctx.success(data, '详情获取成功') 79 | : ctx.fail('详情获取失败') 80 | } 81 | 82 | async create () { 83 | const { ctx } = this 84 | const body = ctx.validateBody(this.rules.create) 85 | const { location } = await ctx.getLocation() 86 | body.location = location 87 | const data = await this.service.moment.create(body) 88 | data 89 | ? ctx.success(data, '创建成功') 90 | : ctx.fail('创建失败') 91 | } 92 | 93 | async update () { 94 | const { ctx } = this 95 | const params = ctx.validateParamsObjectId() 96 | const body = ctx.validateBody(this.rules.update) 97 | const data = await this.service.moment.updateItemById(params.id, body) 98 | data 99 | ? ctx.success(data, '更新成功') 100 | : ctx.fail('更新失败') 101 | } 102 | 103 | async delete () { 104 | const { ctx } = this 105 | const params = ctx.validateParamsObjectId() 106 | const data = await this.service.moment.deleteItemById(params.id) 107 | data 108 | ? ctx.success('删除成功') 109 | : ctx.fail('删除失败') 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /app/router/backend.js: -------------------------------------------------------------------------------- 1 | module.exports = app => { 2 | const backendRouter = app.router.namespace('/backend') 3 | const { controller, middlewares } = app 4 | const auth = middlewares.auth(app) 5 | 6 | // Article 7 | backendRouter.get('/articles', auth, controller.article.list) 8 | backendRouter.get('/articles/archives', auth, controller.article.archives) 9 | backendRouter.get('/articles/:id', auth, controller.article.item) 10 | backendRouter.post('/articles', auth, controller.article.create) 11 | backendRouter.patch('/articles/:id', auth, controller.article.update) 12 | backendRouter.patch('/articles/:id/like', auth, controller.article.like) 13 | backendRouter.patch('/articles/:id/unlike', auth, controller.article.unlike) 14 | backendRouter.delete('/articles/:id', auth, controller.article.delete) 15 | 16 | // Category 17 | backendRouter.get('/categories', auth, controller.category.list) 18 | backendRouter.get('/categories/:id', auth, controller.category.item) 19 | backendRouter.post('/categories', auth, controller.category.create) 20 | backendRouter.patch('/categories/:id', auth, controller.category.update) 21 | backendRouter.delete('/categories/:id', auth, controller.category.delete) 22 | 23 | // Tag 24 | backendRouter.get('/tags', auth, controller.tag.list) 25 | backendRouter.get('/tags/:id', auth, controller.tag.item) 26 | backendRouter.post('/tags', auth, controller.tag.create) 27 | backendRouter.patch('/tags/:id', auth, controller.tag.update) 28 | backendRouter.delete('/tags/:id', auth, controller.tag.delete) 29 | 30 | // Comment 31 | backendRouter.get('/comments', auth, controller.comment.list) 32 | backendRouter.get('/comments/:id', auth, controller.comment.item) 33 | backendRouter.post('/comments', auth, controller.comment.create) 34 | backendRouter.patch('/comments/:id', auth, controller.comment.update) 35 | backendRouter.patch('/comments/:id/like', auth, controller.comment.like) 36 | backendRouter.patch('/comments/:id/unlike', auth, controller.comment.unlike) 37 | backendRouter.delete('/comments/:id', auth, controller.comment.delete) 38 | 39 | // User 40 | backendRouter.get('/users', auth, controller.user.list) 41 | backendRouter.get('/users/:id', auth, controller.user.item) 42 | backendRouter.patch('/users/:id', auth, controller.user.update) 43 | 44 | // Setting 45 | backendRouter.get('/setting', auth, controller.setting.index) 46 | backendRouter.patch('/setting', auth, controller.setting.update) 47 | 48 | // Auth 49 | backendRouter.post('/auth/login', controller.auth.login) 50 | backendRouter.get('/auth/logout', auth, controller.auth.logout) 51 | backendRouter.get('/auth/info', auth, controller.auth.info) 52 | backendRouter.patch('/auth/info', auth, controller.auth.update) 53 | backendRouter.patch('/auth/password', auth, controller.auth.password) 54 | 55 | // Notification 56 | backendRouter.get('/notifications', auth, controller.notification.list) 57 | backendRouter.get('/notifications/count/unviewed', auth, controller.notification.unviewedCount) 58 | backendRouter.patch('/notifications/view', auth, controller.notification.viewAll) 59 | backendRouter.patch('/notifications/:id/view', auth, controller.notification.view) 60 | backendRouter.delete('/notifications/:id', auth, controller.notification.delete) 61 | 62 | // Stat 63 | backendRouter.get('/stat/count', auth, controller.stat.count) 64 | backendRouter.get('/stat/trend', auth, controller.stat.trend) 65 | 66 | // Moment 67 | backendRouter.get('/moments', auth, controller.moment.list) 68 | backendRouter.get('/moments/:id', auth, controller.moment.item) 69 | backendRouter.post('/moments', auth, controller.moment.create) 70 | backendRouter.patch('/moments/:id', auth, controller.moment.update) 71 | backendRouter.delete('/moments/:id', auth, controller.moment.delete) 72 | } 73 | -------------------------------------------------------------------------------- /app/service/setting.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc Setting Services 3 | */ 4 | 5 | const ProxyService = require('./proxy') 6 | 7 | module.exports = class SettingService extends ProxyService { 8 | get model () { 9 | return this.app.model.Setting 10 | } 11 | 12 | /** 13 | * @desc 初始化配置数据,用于server初始化时 14 | * @return {Setting} 配置数据 15 | */ 16 | async seed () { 17 | let data = await this.getItem() 18 | if (!data) { 19 | // TIP: 这里不能用create,create如果不传model,是不会创建的 20 | const model = new this.model() 21 | if (this.app._admin) { 22 | model.personal.user = this.app._admin._id 23 | } 24 | data = await model.save() 25 | if (data) { 26 | this.logger.info('Setting初始化成功') 27 | } else { 28 | this.logger.info('Setting初始化失败') 29 | } 30 | } 31 | this.mountToApp(data) 32 | return data 33 | } 34 | 35 | /** 36 | * @desc 抓取并生成友链 37 | * @param {Array} links 需要更新的友链 38 | * @return {Array} 抓取后的友链 39 | */ 40 | async generateLinks (links = []) { 41 | if (!links || !links.length) return [] 42 | links = await Promise.all( 43 | links.map(async link => { 44 | if (link) { 45 | link.id = link.id || this.app.utils.share.createObjectId() 46 | const userInfo = await this.service.github.getUserInfo(link.github) 47 | if (userInfo) { 48 | link.name = link.name || userInfo.name 49 | link.avatar = this.app.proxyUrl(link.avatar || userInfo.avatar_url) 50 | link.slogan = link.slogan || userInfo.bio 51 | link.site = link.site || userInfo.blog || userInfo.url 52 | } 53 | return link 54 | } 55 | return null 56 | }) 57 | ) 58 | this.logger.info('友链抓取成功') 59 | return links.filter(item => !!item) 60 | } 61 | 62 | /** 63 | * @desc 更新友链 64 | * @param {Array} links 需要更新的友链 65 | * @return {Setting} 更新友链后的配置数据 66 | */ 67 | async updateLinks (links) { 68 | let setting = await this.getItem() 69 | if (!setting) return null 70 | const update = await this.generateLinks(Array.isArray(links) && links || setting.site.links) 71 | if (!update.length) return 72 | setting = await this.updateItemById(setting._id, { 73 | $set: { 74 | 'site.links': update 75 | } 76 | }) 77 | this.logger.info('友链更新成功') 78 | // 更新后挂载到app上 79 | this.mountToApp(setting) 80 | return setting 81 | } 82 | 83 | /** 84 | * @desc 更新personal的github信息 85 | * @return {Setting} 更新后的setting 86 | */ 87 | async updateGithubInfo () { 88 | let setting = await this.getItem() 89 | if (!setting) return null 90 | const github = setting.personal.github 91 | if (!github || !github.login) return 92 | const user = await this.service.github.getUserInfo(github.login) 93 | if (!user) return 94 | setting = await this.updateItemById(setting._id, { 95 | $set: { 96 | 'personal.github': user 97 | } 98 | }) 99 | // 个人github信息更新成功 100 | this.logger.info('个人GitHub信息更新成功') 101 | this.mountToApp(setting) 102 | return setting 103 | } 104 | 105 | /** 106 | * @desc 把配置挂载到app上 107 | * @param {Setting} setting 配置 108 | */ 109 | async mountToApp (setting) { 110 | let msg = '配置挂载成功' 111 | if (!setting) { 112 | msg = '配置更新成功' 113 | setting = await this.getItem() 114 | } 115 | this.app.setting = setting || null 116 | this.logger.info(msg) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /app/controller/tag.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 标签 Controller 3 | */ 4 | 5 | const { Controller } = require('egg') 6 | 7 | module.exports = class TagController extends Controller { 8 | get rules () { 9 | return { 10 | list: { 11 | // 查询关键词 12 | keyword: { type: 'string', required: false } 13 | }, 14 | create: { 15 | name: { type: 'string', required: true }, 16 | description: { type: 'string', required: false }, 17 | extends: { 18 | type: 'array', 19 | required: false, 20 | itemType: 'object', 21 | rule: { 22 | key: 'string', 23 | value: 'string' 24 | } 25 | } 26 | }, 27 | update: { 28 | name: { type: 'string', required: false }, 29 | description: { type: 'string', required: false }, 30 | extends: { 31 | type: 'array', 32 | required: false, 33 | itemType: 'object', 34 | rule: { 35 | key: 'string', 36 | value: 'string' 37 | } 38 | } 39 | } 40 | } 41 | } 42 | 43 | async list () { 44 | const { ctx } = this 45 | ctx.validate(this.rules.list, ctx.query) 46 | const query = {} 47 | const { keyword } = ctx.query 48 | if (keyword) { 49 | const keywordReg = new RegExp(keyword) 50 | query.$or = [ 51 | { name: keywordReg } 52 | ] 53 | } 54 | const data = await this.service.tag.getList(query) 55 | data 56 | ? ctx.success(data, '标签列表获取成功') 57 | : ctx.fail('标签列表获取失败') 58 | } 59 | 60 | async item () { 61 | const { ctx } = this 62 | const params = ctx.validateParamsObjectId() 63 | const data = await this.service.tag.getItemById(params.id) 64 | if (data) { 65 | data.articles = await this.service.article.getList({ tag: data._id }) 66 | } 67 | data 68 | ? ctx.success(data, '标签详情获取成功') 69 | : ctx.fail('标签详情获取失败') 70 | } 71 | 72 | async create () { 73 | const { ctx } = this 74 | const body = ctx.validateBody(this.rules.create) 75 | const { name } = body 76 | const exist = await this.service.tag.getItem({ name }) 77 | if (exist) { 78 | return ctx.fail('标签名称重复') 79 | } 80 | const data = await this.service.tag.create(body) 81 | data 82 | ? ctx.success(data, '标签创建成功') 83 | : ctx.fail('标签创建失败') 84 | } 85 | 86 | async update () { 87 | const { ctx } = this 88 | const params = ctx.validateParamsObjectId() 89 | const body = ctx.validateBody(this.rules.update) 90 | const exist = await this.service.tag.getItem({ 91 | name: body.name, 92 | _id: { 93 | $nin: [ params.id ] 94 | } 95 | }) 96 | if (exist) { 97 | return ctx.fail('标签名称重复') 98 | } 99 | const data = await this.service.tag.updateItemById(params.id, body) 100 | data 101 | ? ctx.success(data, '标签更新成功') 102 | : ctx.fail('标签更新失败') 103 | } 104 | 105 | async delete () { 106 | const { ctx } = this 107 | const params = ctx.validateParamsObjectId() 108 | const articles = await this.service.article.getList({ tag: params.id }, 'title') 109 | if (articles.length) { 110 | return ctx.fail('该标签下还有文章,不能删除', articles) 111 | } 112 | const data = await this.service.tag.deleteItemById(params.id) 113 | data 114 | ? ctx.success('标签删除成功') 115 | : ctx.fail('标签删除失败') 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /app/controller/auth.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc Auth Controller 3 | */ 4 | 5 | const { 6 | Controller 7 | } = require('egg') 8 | 9 | module.exports = class AuthController extends Controller { 10 | get rules () { 11 | return { 12 | login: { 13 | username: { type: 'string', required: true }, 14 | password: { type: 'string', required: true } 15 | }, 16 | update: { 17 | name: { type: 'string', required: false }, 18 | email: { type: 'email', required: false }, 19 | site: { type: 'url', required: false }, 20 | avatar: { type: 'string', required: false } 21 | }, 22 | password: { 23 | password: { type: 'string', required: true, min: 6 }, 24 | oldPassword: { type: 'string', required: true, min: 6 } 25 | } 26 | } 27 | } 28 | 29 | async login () { 30 | const { ctx } = this 31 | if (ctx.session._isAuthed) { 32 | return ctx.fail('你已登录,请勿重复登录') 33 | } 34 | const body = this.ctx.validateBody(this.rules.login) 35 | const user = await this.service.user.getItem({ name: body.username }) 36 | if (!user) { 37 | return ctx.fail('用户不存在') 38 | } 39 | const vertifyPassword = this.app.utils.encode.bcompare(body.password, user.password) 40 | if (!vertifyPassword) { 41 | return ctx.fail('密码错误') 42 | } 43 | const token = this.service.auth.setCookie(user, true) 44 | // 调用 rotateCsrfSecret 刷新用户的 CSRF token 45 | ctx.rotateCsrfSecret() 46 | this.logger.info(`用户登录成功, ID:${user._id},用户名:${user.name}`) 47 | ctx.success({ id: user._id, token }, '登录成功') 48 | } 49 | 50 | async logout () { 51 | const { ctx } = this 52 | this.service.auth.setCookie(ctx.session._user, false) 53 | this.logger.info(`用户退出成功, 用户ID:${ctx.session._user._id},用户名:${ctx.session._user.name}`) 54 | ctx.success('退出成功') 55 | } 56 | 57 | async info () { 58 | this.ctx.success({ 59 | info: this.ctx.session._user, 60 | token: this.ctx.session._token 61 | }, '管理员信息获取成功') 62 | } 63 | 64 | /** 65 | * @desc 管理员信息更新,不包含密码更新 66 | * @return {*} null 67 | */ 68 | async update () { 69 | const { ctx } = this 70 | const body = this.ctx.validateBody(this.rules.update) 71 | const exist = await this.service.user.getItemById( 72 | ctx.session._user._id, 73 | Object.keys(this.rules.update).join(' ') 74 | ) 75 | if (exist && exist.name !== body.name) { 76 | // 检测变更的name是否和其他用户冲突 77 | const conflict = await this.service.user.getItem({ name: body.name }) 78 | if (conflict) { 79 | // 有冲突 80 | return ctx.fail('用户名重复') 81 | } 82 | } 83 | const update = this.app.merge({}, exist, body) 84 | const data = await this.service.user.updateItemById(ctx.session._user._id, update) 85 | // 更新session 86 | await this.service.auth.updateSessionUser() 87 | data 88 | ? ctx.success(data, '管理员信息更新成功') 89 | : ctx.fail('管理员信息更新失败') 90 | } 91 | 92 | /** 93 | * @desc 管理员密码更新 94 | */ 95 | async password () { 96 | const { ctx } = this 97 | const body = this.ctx.validateBody(this.rules.password) 98 | const exist = await this.service.user.getItemById(ctx.session._user._id) 99 | const vertifyPassword = this.app.utils.encode.bcompare(body.oldPassword, exist.password) 100 | if (!vertifyPassword) { 101 | ctx.throw(200, '原密码错误') 102 | } 103 | const data = await this.service.user.updateItemById(ctx.session._user._id, { 104 | password: this.app.utils.encode.bhash(body.password) 105 | }) 106 | data 107 | ? ctx.success('密码更新成功') 108 | : ctx.fail('密码更新失败') 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /app/controller/category.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 分类 Controller 3 | */ 4 | 5 | const { Controller } = require('egg') 6 | 7 | module.exports = class CategoryController extends Controller { 8 | get rules () { 9 | return { 10 | list: { 11 | // 查询关键词 12 | keyword: { type: 'string', required: false } 13 | }, 14 | create: { 15 | name: { type: 'string', required: true }, 16 | description: { type: 'string', required: false }, 17 | extends: { 18 | type: 'array', 19 | required: false, 20 | itemType: 'object', 21 | rule: { 22 | key: 'string', 23 | value: 'string' 24 | } 25 | } 26 | }, 27 | update: { 28 | name: { type: 'string', required: false }, 29 | description: { type: 'string', required: false }, 30 | extends: { 31 | type: 'array', 32 | required: false, 33 | itemType: 'object', 34 | rule: { 35 | key: 'string', 36 | value: 'string' 37 | } 38 | } 39 | } 40 | } 41 | } 42 | 43 | async list () { 44 | const { ctx } = this 45 | ctx.validate(this.rules.list, ctx.query) 46 | const query = {} 47 | const { keyword } = ctx.query 48 | if (keyword) { 49 | const keywordReg = new RegExp(keyword) 50 | query.$or = [ 51 | { name: keywordReg } 52 | ] 53 | } 54 | const data = await this.service.category.getList(query) 55 | data 56 | ? ctx.success(data, '分类列表获取成功') 57 | : ctx.fail('分类列表获取失败') 58 | } 59 | 60 | async item () { 61 | const { ctx } = this 62 | const params = ctx.validateParamsObjectId() 63 | const data = await this.service.category.getItemById(params.id) 64 | if (data) { 65 | data.articles = await this.service.article.getList({ category: data._id }) 66 | } 67 | data 68 | ? ctx.success(data, '分类详情获取成功') 69 | : ctx.fail('分类详情获取失败') 70 | } 71 | 72 | async create () { 73 | const { ctx } = this 74 | const body = ctx.validateBody(this.rules.create) 75 | const { name } = body 76 | const exist = await this.service.category.getItem({ name }) 77 | if (exist) { 78 | return ctx.fail('分类名称重复') 79 | } 80 | const data = await this.service.category.create(body) 81 | data 82 | ? ctx.success(data, '分类创建成功') 83 | : ctx.fail('分类创建失败') 84 | } 85 | 86 | async update () { 87 | const { ctx } = this 88 | const params = ctx.validateParamsObjectId() 89 | const body = ctx.validateBody(this.rules.update) 90 | const exist = await this.service.category.getItem({ 91 | name: body.name, 92 | _id: { 93 | $nin: [ params.id ] 94 | } 95 | }) 96 | if (exist) { 97 | return ctx.fail('分类名称重复') 98 | } 99 | const data = await this.service.category.updateItemById(params.id, body) 100 | data 101 | ? ctx.success(data, '分类更新成功') 102 | : ctx.fail('分类更新失败') 103 | } 104 | 105 | async delete () { 106 | const { ctx } = this 107 | const params = ctx.validateParamsObjectId() 108 | const articles = await this.service.article.getList({ category: params.id }, 'title state createdAt') 109 | if (articles.length) { 110 | return ctx.fail('该分类下还有文章,不能删除', articles) 111 | } 112 | const data = await this.service.category.deleteItemById(params.id) 113 | data 114 | ? ctx.success('分类删除成功') 115 | : ctx.fail('分类删除失败') 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /app/controller/notification.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 通告 Controller 3 | */ 4 | 5 | const { Controller } = require('egg') 6 | 7 | module.exports = class NotificationController extends Controller { 8 | get rules () { 9 | return { 10 | list: { 11 | // 查询关键词 12 | page: { type: 'int', required: true, min: 1 }, 13 | limit: { type: 'int', required: false, min: 1 }, 14 | type: { type: 'enum', values: Object.values(this.config.modelEnum.notification.type.optional), required: false }, 15 | classify: { type: 'enum', values: Object.values(this.config.modelEnum.notification.classify.optional), required: false }, 16 | viewed: { type: 'boolean', required: false } 17 | } 18 | } 19 | } 20 | 21 | async list () { 22 | const { ctx } = this 23 | ctx.query.page = Number(ctx.query.page) 24 | const tranArray = ['limit', 'type'] 25 | tranArray.forEach(key => { 26 | if (ctx.query[key]) { 27 | ctx.query[key] = Number(ctx.query[key]) 28 | } 29 | }) 30 | if (ctx.query.viewed) { 31 | ctx.query.viewed = ctx.query.viewed === 'true' 32 | } 33 | ctx.validate(this.rules.list, ctx.query) 34 | const { page, limit, type, classify, viewed } = ctx.query 35 | const query = { type, classify, viewed } 36 | const options = { 37 | sort: { 38 | createdAt: -1 39 | }, 40 | page, 41 | limit: limit || 10, 42 | populate: [ 43 | { 44 | path: 'target.article', 45 | populate: [ 46 | { 47 | path: 'category' 48 | }, { 49 | path: 'tag' 50 | } 51 | ] 52 | }, { 53 | path: 'target.user', 54 | select: '-password' 55 | }, { 56 | path: 'target.comment', 57 | populate: [ 58 | { 59 | path: 'article' 60 | }, { 61 | path: 'author' 62 | } 63 | ] 64 | }, { 65 | path: 'actors.from', 66 | select: '-password' 67 | }, { 68 | path: 'actors.to', 69 | select: '-password' 70 | } 71 | ] 72 | } 73 | const data = await this.service.notification.getLimitListByQuery(ctx.processPayload(query), options) 74 | data 75 | ? ctx.success(data, '通告列表获取成功') 76 | : ctx.fail('通告列表获取失败') 77 | } 78 | 79 | async unviewedCount () { 80 | const { ctx } = this 81 | const list = await this.service.notification.getList({ viewed: false }) 82 | const notificationTypes = this.config.modelEnum.notification.type.optional 83 | const data = list.reduce((map, item) => { 84 | if (item.type === notificationTypes.GENERAL) { 85 | map.general++ 86 | } else if (item.type === notificationTypes.COMMENT) { 87 | map.comment++ 88 | } else if (item.type === notificationTypes.LIKE) { 89 | map.like++ 90 | } else if (item.type === notificationTypes.USER) { 91 | map.user++ 92 | } 93 | return map 94 | }, { 95 | general: 0, 96 | comment: 0, 97 | like: 0, 98 | user: 0 99 | }) 100 | ctx.success({ 101 | total: list.length, 102 | counts: data 103 | }, '未读通告数量获取成功') 104 | } 105 | 106 | async view () { 107 | const { ctx } = this 108 | const params = ctx.validateParamsObjectId() 109 | const update = { viewed: true } 110 | const data = await this.service.notification.updateItemById(params.id, update) 111 | data 112 | ? ctx.success('通告标记已读成功') 113 | : ctx.fail('通告标记已读失败') 114 | } 115 | 116 | async viewAll () { 117 | const { ctx } = this 118 | const update = { viewed: true } 119 | const data = await this.service.notification.updateMany({}, update) 120 | data 121 | ? ctx.success('通告全部标记已读成功') 122 | : ctx.fail('通告全部标记已读失败') 123 | } 124 | 125 | async delete () { 126 | const { ctx } = this 127 | const params = ctx.validateParamsObjectId() 128 | const data = await this.service.notification.deleteItemById(params.id) 129 | data 130 | ? ctx.success('通告删除成功') 131 | : ctx.fail('通告删除失败') 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /app/service/user.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc User Services 3 | */ 4 | 5 | const ProxyService = require('./proxy') 6 | 7 | module.exports = class UserService extends ProxyService { 8 | get model () { 9 | return this.app.model.User 10 | } 11 | 12 | async getListWithComments (query, select) { 13 | let list = await this.getList(query, select, { 14 | sort: '-createdAt' 15 | }) 16 | if (list && list.length) { 17 | list = await Promise.all( 18 | list.map(async item => { 19 | item = item.toObject() 20 | item.comments = await this.service.comment.count({ 21 | author: item._id 22 | }) 23 | return item 24 | }) 25 | ) 26 | } 27 | return list 28 | } 29 | 30 | // 创建用户 31 | async create (user, checkExist = true) { 32 | const { name } = user 33 | if (checkExist) { 34 | const exist = await this.getItem({ name }) 35 | if (exist) { 36 | this.logger.info('用户已存在,无需创建:' + name) 37 | return exist 38 | } 39 | } 40 | const data = await new this.model(user).save() 41 | const type = ['管理员', '用户'][data.role] 42 | if (data) { 43 | this.logger.info(`${type}创建成功:${name}`) 44 | } else { 45 | this.logger.error(`${type}创建失败:${name}`) 46 | } 47 | return data 48 | } 49 | 50 | /** 51 | * @desc 评论用户创建或更新 52 | * @param {*} author 评论的author 53 | * @return {User} user 54 | */ 55 | async checkCommentAuthor (author) { 56 | let user = null 57 | let error = '' 58 | const { isObjectId, isObject } = this.app.utils.validate 59 | if (isObjectId(author)) { 60 | user = await this.getItemById(author) 61 | } else if (isObject(author)) { 62 | const update = {} 63 | author.name && (update.name = author.name) 64 | author.site && (update.site = author.site) 65 | author.email && (update.email = author.email) 66 | update.avatar = this.app.utils.gravatar(author.email) 67 | const id = author.id || author._id 68 | 69 | const updateUser = async (exist, update) => { 70 | const hasDiff = exist && Object.keys(update).some(key => update[key] !== exist[key]) 71 | if (hasDiff) { 72 | // 有变动才更新 73 | user = await this.updateItemById(exist._id, update) 74 | if (user) { 75 | this.logger.info('用户更新成功:' + exist.name) 76 | this.service.notification.recordUser(exist, 'update') 77 | } 78 | } else { 79 | user = exist 80 | } 81 | } 82 | 83 | if (id) { 84 | // 更新 85 | if (isObjectId(id)) { 86 | user = await this.getItemById(id) 87 | await updateUser(user, update) 88 | } 89 | } else { 90 | // 根据 email 和 name 确定用户唯一性 91 | const exist = await this.getItem({ 92 | email: update.email, 93 | name: update.name 94 | }) 95 | if (exist) { 96 | // 更新 97 | await updateUser(exist, update) 98 | } else { 99 | // 创建 100 | user = await this.create(Object.assign(update, { 101 | role: this.config.modelEnum.user.role.optional.NORMAL 102 | }), false) 103 | if (user) { 104 | this.service.notification.recordUser(user, 'create') 105 | this.service.stat.record('USER_CREATE', { user: user._id }, 'count') 106 | } 107 | } 108 | } 109 | } 110 | 111 | if (!user && !error) { 112 | error = '用户不存在' 113 | } 114 | return { user, error } 115 | } 116 | 117 | /** 118 | * @desc 检测用户以往spam评论 119 | * @param {User} user 评论作者 120 | * @return {Boolean} 是否能发布评论 121 | */ 122 | async checkUserSpam (user) { 123 | if (!user) return 124 | const comments = await this.service.comment.getList({ author: user._id }) 125 | const spams = comments.filter(c => c.spam) 126 | if (spams.length >= this.app.setting.limit.commentSpamMaxCount) { 127 | // 如果已存在垃圾评论数达到最大限制 128 | if (!user.mute) { 129 | user = await this.updateItemById(user._id, { mute: true }) 130 | this.logger.info(`用户禁言成功:${user.name}`) 131 | } 132 | return false 133 | } 134 | return true 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /app/service/comment.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc Comment Services 3 | */ 4 | 5 | const path = require('path') 6 | const fs = require('fs') 7 | const moment = require('moment') 8 | const Email = require('email-templates') 9 | const ProxyService = require('./proxy') 10 | 11 | const email = new Email() 12 | 13 | module.exports = class CommentService extends ProxyService { 14 | get model () { 15 | return this.app.model.Comment 16 | } 17 | 18 | async getItemById (id) { 19 | let data = null 20 | const populate = [ 21 | { 22 | path: 'author', 23 | select: 'github name avatar email site' 24 | }, { 25 | path: 'parent', 26 | select: 'author meta sticky ups' 27 | }, { 28 | path: 'forward', 29 | select: 'author meta sticky ups renderedContent' 30 | }, { 31 | path: 'article', 32 | select: 'title, description thumb createdAt' 33 | } 34 | ] 35 | if (!this.ctx.session._isAuthed) { 36 | data = await this.getItem( 37 | { _id: id, state: 1, spam: false }, 38 | '-content -state -updatedAt -spam', 39 | null, 40 | populate 41 | ) 42 | } else { 43 | data = await this.getItem({ _id: id }, null, null, populate) 44 | } 45 | return data 46 | } 47 | 48 | async sendCommentEmailToAdminAndUser (comment, canReply = true) { 49 | if (comment.toObject) { 50 | comment = comment.toObject() 51 | } 52 | const { type, article } = comment 53 | const commentType = this.config.modelEnum.comment.type.optional 54 | const permalink = this.getPermalink(comment) 55 | let adminTitle = '未知的评论' 56 | let typeTitle = '' 57 | let at = null 58 | if (type === commentType.COMMENT) { 59 | // 文章评论 60 | typeTitle = '评论' 61 | at = await this.service.article.getItemById(article._id || article) 62 | if (at && at._id) { 63 | adminTitle = `博客文章《${at.title}》有了新的评论` 64 | } 65 | } else if (type === commentType.MESSAGE) { 66 | // 站内留言 67 | typeTitle = '留言' 68 | adminTitle = '博客有新的留言' 69 | } 70 | 71 | const authorId = comment.author._id.toString() 72 | const adminId = this.app._admin._id.toString() 73 | const forwardAuthorId = comment.forward && comment.forward.author.toString() 74 | // 非管理员评论,发送给管理员邮箱 75 | if (authorId !== adminId) { 76 | const html = await renderCommentEmailHtml(adminTitle, permalink, comment, at, canReply) 77 | this.service.mail.sendToAdmin(typeTitle, { 78 | subject: adminTitle, 79 | html 80 | }) 81 | } 82 | // 非回复管理员,非回复自身,才发送给被评论者 83 | if (forwardAuthorId && forwardAuthorId !== authorId && forwardAuthorId !== adminId) { 84 | const forwardAuthor = await this.service.user.getItemById(forwardAuthorId) 85 | if (forwardAuthor && forwardAuthor.email) { 86 | const subject = `你在 Jooger.me 的博客的${typeTitle}有了新的回复` 87 | const html = await renderCommentEmailHtml(subject, permalink, comment, at, canReply) 88 | this.service.mail.send(typeTitle, { 89 | to: forwardAuthor.email, 90 | subject, 91 | html 92 | }) 93 | } 94 | } 95 | } 96 | 97 | /** 98 | * @desc 获取评论所属页面链接 99 | * @param {Comment} comment 评论 100 | * @return {String} 页面链接 101 | */ 102 | getPermalink (comment = {}) { 103 | const { type, article } = comment 104 | const { COMMENT, MESSAGE } = this.config.modelEnum.comment.type.optional 105 | const url = this.config.author.url 106 | switch (type) { 107 | case COMMENT: 108 | return `${url}/article/${article._id || article}` 109 | case MESSAGE: 110 | return `${url}/guestbook` 111 | default: 112 | return '' 113 | } 114 | } 115 | 116 | /** 117 | * @desc 获取评论类型文案 118 | * @param {Number | String} type 评论类型 119 | * @return {String} 文案 120 | */ 121 | getCommentType (type) { 122 | return ['文章评论', '站点留言'][type] || '评论' 123 | } 124 | } 125 | 126 | async function renderCommentEmailHtml (title, link, comment, showReplyBtn = true) { 127 | const data = Object.assign({}, comment, { 128 | title, 129 | link, 130 | createdAt: moment(comment.createdAt).format('YYYY-MM-DD hh:mm'), 131 | showReplyBtn 132 | }) 133 | const html = await email.render('comment', data) 134 | const style = `` 135 | return html + style 136 | } 137 | 138 | function getCommentStyle () { 139 | const markdownStyle = path.resolve('emails/markdown.css') 140 | const markdownCss = fs.readFileSync(markdownStyle, { 141 | encoding: 'utf8' 142 | }) 143 | return markdownCss 144 | } 145 | -------------------------------------------------------------------------------- /app/utils/markdown.js: -------------------------------------------------------------------------------- 1 | const marked = require('marked') 2 | const highlight = require('highlight.js') 3 | const { randomString } = require('./encode') 4 | 5 | const languages = ['xml', 'bash', 'css', 'markdown', 'http', 'java', 'javascript', 'json', 'makefile', 'nginx', 'python', 'scss', 'sql', 'stylus'] 6 | highlight.registerLanguage('xml', require('highlight.js/lib/languages/xml')) 7 | highlight.registerLanguage('bash', require('highlight.js/lib/languages/bash')) 8 | highlight.registerLanguage('css', require('highlight.js/lib/languages/css')) 9 | highlight.registerLanguage('markdown', require('highlight.js/lib/languages/markdown')) 10 | highlight.registerLanguage('http', require('highlight.js/lib/languages/http')) 11 | highlight.registerLanguage('java', require('highlight.js/lib/languages/java')) 12 | highlight.registerLanguage('javascript', require('highlight.js/lib/languages/javascript')) 13 | highlight.registerLanguage('typescript', require('highlight.js/lib/languages/typescript')) 14 | highlight.registerLanguage('json', require('highlight.js/lib/languages/json')) 15 | highlight.registerLanguage('makefile', require('highlight.js/lib/languages/makefile')) 16 | highlight.registerLanguage('nginx', require('highlight.js/lib/languages/nginx')) 17 | highlight.registerLanguage('python', require('highlight.js/lib/languages/python')) 18 | highlight.registerLanguage('scss', require('highlight.js/lib/languages/scss')) 19 | highlight.registerLanguage('sql', require('highlight.js/lib/languages/sql')) 20 | highlight.registerLanguage('stylus', require('highlight.js/lib/languages/stylus')) 21 | highlight.configure({ 22 | classPrefix: '' // don't append class prefix 23 | }) 24 | 25 | const renderer = new marked.Renderer() 26 | 27 | renderer.heading = function (text, level) { 28 | return `
48 |
53 | ${_title ? `
《${_title}》
` : ''} 54 | 55 | `.replace(/\s+/g, ' ').replace('\n', '') 56 | } 57 | 58 | renderer.code = function (code, lang, escaped) { 59 | if (this.options.highlight) { 60 | const out = this.options.highlight(code, lang); 61 | if (out != null && out !== code) { 62 | escaped = true; 63 | code = out; 64 | } 65 | } 66 | 67 | if (!lang) { 68 | return ''
69 | + (escaped ? code : escape(code, true))
70 | + '';
71 | }
72 |
73 | return ''
77 | + (escaped ? code : escape(code, true))
78 | + '\n';
79 | }
80 | // renderer.code = function (code, lang) {
81 | // if (this.options.highlight) {
82 | // const out = this.options.highlight(code, lang)
83 | // if (out != null && out !== code) {
84 | // code = out
85 | // }
86 | // }
87 |
88 | // const lineCode = code.split('\n')
89 | // const codeWrapper = lineCode.map((line, index) => `${line}${index !== lineCode.length - 1 ? '' +
93 | // codeWrapper +
94 | // '\n'
95 | // }
96 |
97 | // return '' +
101 | // codeWrapper +
102 | // '\n\n'
103 | // }
104 |
105 | marked.setOptions({
106 | renderer,
107 | gfm: true,
108 | pedantic: false,
109 | sanitize: false,
110 | tables: true,
111 | breaks: true,
112 | headerIds: true,
113 | smartLists: true,
114 | smartypants: true,
115 | highlight (code, lang) {
116 | if (languages.indexOf(lang) < 0) {
117 | return highlight.highlightAuto(code).value
118 | }
119 | return highlight.highlight(lang, code).value
120 | }
121 | })
122 |
123 | function escape (html, encode) {
124 | return html
125 | .replace(!encode ? /&(?!#?\w+;)/g : /&/g, '&')
126 | .replace(//g, '>')
128 | .replace(/"/g, '"')
129 | .replace(/'/g, ''')
130 | }
131 |
132 | exports.render = (text, sanitize = false) => {
133 | marked.setOptions({ sanitize })
134 | return marked(text)
135 | }
136 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [C-CLIENT]: https://jooger.me
2 | [S-CLIENT]: https://api.jooger.me
3 | [egg]: https://eggjs.org
4 | [egg-image]: https://img.shields.io/badge/Powered%20By-Egg.js-ff69b4.svg?style=flat-square
5 | [david-image]: https://img.shields.io/david/jo0ger/node-server.svg?style=flat-square
6 | [david-url]: https://david-dm.org/jo0ger/node-server
7 |
8 | # node-server
9 |
10 | [![powered by Egg.js][egg-image]][egg]
11 | [![David deps][david-image]][david-url]
12 | [](https://github.com/jo0ger/node-server/network)
13 | [](https://github.com/jo0ger/node-server/stargazers)
14 | [](https://github.com/jo0ger/node-server/issues)
15 | [](https://github.com/jo0ger/node-server/commits/master)
16 |
17 | RESTful API server application for my blog
18 |
19 | * Web client for user: [jooger.me]([C-CLIENT]) powered by [Nuxt.js@2](https://github.com/nuxt/nuxt.js) and [TypeScript](https://github.com/Microsoft/TypeScript)
20 | * Web client for admin: vue-admin powered by Vue and iview
21 | * Server client: [api.jooger.me]([S-CLIENT]) powered by [Egg](https://github.com/eggjs/egg) and mongodb
22 |
23 | ## Quick Start
24 |
25 | ### Environment Dependencies
26 |
27 | - [redis](https://redis.io/)
28 | - [mongodb](https://www.mongodb.com/)
29 |
30 | ### Development
31 |
32 | Please make sure they are configured the same as `config/config.default.js`
33 |
34 | ``` bash
35 | $ yarn
36 |
37 | $ yarn dev
38 |
39 | $ open http://localhost:7001/
40 | ```
41 |
42 | ### Deploy
43 |
44 | ```bash
45 | $ npm start
46 | $ npm stop
47 | ```
48 |
49 | ### npm scripts
50 |
51 | - Use `npm run lint` to check code style.
52 | - Use `npm test` to run unit test.
53 | - Use `npm run autod` to auto detect dependencies upgrade, see [autod](https://www.npmjs.com/package/autod) for more detail.
54 |
55 | ### Develop / Deploy with Docker
56 |
57 | #### Requirements
58 |
59 | * docker
60 | * docker-compose
61 |
62 | #### Config
63 |
64 | ##### docker-compose config
65 |
66 | * development: docker-compose.dev.yml
67 | * production: docker-compose.yml
68 |
69 | ##### Change port
70 |
71 | ``` yml
72 | version: "3"
73 | services:
74 | node-server:
75 | ports:
76 | - ${HOST PORT}:7001
77 | ```
78 |
79 | #### Develop
80 |
81 | ``` bash
82 | # start
83 | $ docker-compose -f docker-compose.dev.yml up
84 |
85 | # stop
86 | $ docker-compose -f docker-compose.dev.yml down
87 |
88 | # stop and remove valume/cache
89 | $ docker-compose -f docker-compose.dev.yml down -v
90 | ```
91 |
92 | #### Deploy
93 |
94 | ``` bash
95 | # start
96 | $ docker-compose up -d
97 |
98 | # stop
99 | $ docker-compose down
100 |
101 | # stop and remove volume/cache
102 | $ docker-compose down -v
103 | ```
104 |
105 | ## CHANGELOG
106 |
107 | ### v2.2.3
108 |
109 | * fix: “一言” 接口修复
110 |
111 | ### v2.2.2
112 |
113 | * fix: 后台管理在获取评论列表时把子评论过滤掉了
114 |
115 | ### v2.2.1
116 |
117 | * fix: 备份数据上传失败会邮件通知管理员
118 | * fix: 垃圾评论检测时机有问题
119 | * fix: 文章评论数统计未区分评论状态
120 |
121 | ### v2.2.0
122 |
123 | * feat: 新增管理员检测的接口
124 | * feat: 新增C端公告的接口
125 | * feat: 定时任务新增数据库备份任务,配合jenkins进行数据备份
126 | * feat: 歌单歌曲新增歌词
127 | * fix: 配置里更新歌单ID时,未更新redis缓存
128 | * fix: 评论IP获取错误
129 | * fix: 评论的新用户重复创建
130 | * fix: 歌单定时任务里报undefined错误(因为未考虑抓取失败场景)
131 |
132 | ### v2.1.0
133 |
134 | 2018-11-03
135 |
136 | * feat: 评论&留言的邮件通知支持自定义模板
137 | * feat: 添加音乐接口,支持网易云音乐
138 | * feat: voice支持redis缓存
139 | * refactor: 移除reponse的中间件,添加到context的extend中
140 |
141 | ### v2.0.3
142 |
143 | 2018-10-13
144 |
145 | * fix: marked开启sanitize
146 | * fix: marked渲染图片时title错误
147 | * fix: 统计数据-总数统计错误,添加情况分类
148 | * fix: voice获取失败情况处理
149 |
150 |
151 | ### v2.0.2
152 |
153 | 2018-10-12
154 |
155 | * fix: github获取用户信息时clientID和clientSecret错误
156 | * fix: add marked sanitize control
157 | * fix: archive接口的月维度数据排序错误
158 | * fix: 关联文章排序错误
159 |
160 | ### v2.0.1
161 |
162 | 2018-10-09
163 |
164 | * fix: 获取context的ip错误
165 | * chore: docker添加logs的volume
166 |
167 | ### v2.0.0
168 |
169 | 2018-10-07
170 |
171 | * 框架:用Egg重构
172 | * Model层
173 | - article增加原创、转载字段
174 | - 新增notification站内通知和stat站内统计模型
175 | - user简化,去掉不必要字段
176 | - setting重构,分类型
177 | * 接口
178 | - 新增voice接口获取一些心灵鸡汤文字
179 | - 新增ip接口查询ip
180 | * 服务
181 | - ip查询优先阿里云IP查询,geoip-lite为降级
182 | - 定时任务换成egg的schedule
183 | - model proxy重构
184 | - 业务逻辑拆分,每个model都有其对应的service层
185 | - admin user和setting初始化流程变更
186 | - 完善的日志系统
187 | * addon
188 | - 接入sentry
189 | - docker支持
190 | - 增加release tag
191 |
192 |
193 | ### v1.1.0
194 |
195 | * 文章归档api(2018.01.04)
196 | * Model代理 (2018.01.28)
197 | * ESlint (2018.02.01
198 |
199 | ### v1.0.0
200 |
201 | * 音乐api (2017.9.26)
202 | * Github oauth 代理 (2017.9.28)
203 | * 文章分类api (2017.10.26)
204 | * Redis缓存部分数据 (2017.10.27 v1.1)
205 | * 评论api (2017.10.28)
206 | * 评论定位 [geoip](https://github.com/bluesmoon/node-geoip) (2017.10.29)
207 | * 垃圾评论过滤 [akismet](https://github.com/chrisfosterelli/akismet-api) (2017.10.29)
208 | * 用户禁言 (2017.10.29)
209 | * 评论发送邮件 [nodemailer](https://github.com/nodemailer/nodemailer) (2017.10.29)
210 | * GC优化 (2017.10.30,linux下需要预先安装g++, **已废弃**)
211 | * 个人动态api (2017.10.30)
212 |
213 |
214 |
--------------------------------------------------------------------------------
/app/service/notification.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @desc 通告 Services
3 | */
4 |
5 | const ProxyService = require('./proxy')
6 |
7 | module.exports = class NotificationService extends ProxyService {
8 | get model () {
9 | return this.app.model.Notification
10 | }
11 |
12 | get notificationConfig () {
13 | return this.config.modelEnum.notification
14 | }
15 |
16 | // 记录通告
17 | async record (typeKey, model, action, verb, target, actors) {
18 | if (!typeKey || !model || !action) return
19 | const modelName = this.app.utils.validate.isString(model)
20 | ? model
21 | : model.modelName.toLocaleUpperCase()
22 | const type = this.notificationConfig.type.optional[typeKey]
23 | const classifyKey = [typeKey, modelName, action].join('_')
24 | const classify = this.notificationConfig.classify.optional[classifyKey]
25 | if (!verb) {
26 | verb = this.genVerb(classifyKey)
27 | }
28 | const payload = { type, classify, verb, target, actors }
29 | const data = await this.create(payload)
30 | if (data) {
31 | this.logger.info(`通告生成成功,[id: ${data._id}] [type:${typeKey}],[classify: ${classifyKey}]`)
32 | }
33 | }
34 |
35 | async recordGeneral (model, action, err) {
36 | this.record('GENERAL', model, action, err.message || err)
37 | }
38 |
39 | // 记录评论相关动作
40 | async recordComment (comment, handle = 'create') {
41 | if (!comment || !comment._id) return
42 | const target = {}
43 | const actors = {}
44 | let action = ''
45 | comment = await this.service.comment.getItemById(comment._id)
46 | if (!comment) return
47 | const { COMMENT, MESSAGE } = this.config.modelEnum.comment.type.optional
48 | const { type, forward, article, author } = comment
49 | actors.from = author._id || author
50 | target.comment = comment._id
51 | if (type === COMMENT) {
52 | // 文章评论
53 | action += 'COMMENT'
54 | if (handle === 'create') {
55 | target.article = article._id || article
56 | }
57 | } else if (type === MESSAGE) {
58 | // 站内留言
59 | action += 'MESSAGE'
60 | }
61 | if (handle === 'create') {
62 | if (forward) {
63 | action += '_REPLY'
64 | const forwardId = forward._id || forward
65 | target.comment = forwardId
66 | const forwardItem = await this.service.comment.getItemById(forwardId)
67 | actors.to = forwardItem.author._id
68 | }
69 | } else if (handle === 'update') {
70 | // 更新
71 | action += '_UPDATE'
72 | }
73 | this.record('COMMENT', 'COMMENT', action, null, target, actors)
74 | }
75 |
76 | recordLike (type, model, user, like = false) {
77 | const { COMMENT, MESSAGE } = this.config.modelEnum.comment.type.optional
78 | let modelName = ''
79 | let action = ''
80 | const actionSuffix = like ? 'LIKE' : 'UNLIKE'
81 | const target = {}
82 | const actors = {}
83 | if (user) {
84 | actors.from = user._id || user
85 | }
86 | if (type === 'article') {
87 | // 文章
88 | modelName = 'ARTICLE'
89 | target.article = model._id
90 | } else if (type === 'comment') {
91 | // 评论
92 | modelName = 'COMMENT'
93 | target.comment = model._id
94 | if (model.type === COMMENT) {
95 | action += 'COMMENT_'
96 | } else if (model.type === MESSAGE) {
97 | action += 'MESSAGE_'
98 | }
99 | }
100 | action += actionSuffix
101 | this.record('LIKE', modelName, action, null, target, actors)
102 | }
103 |
104 | recordUser (user, handle) {
105 | let action = ''
106 | const target = {
107 | user: user._id || user
108 | }
109 | const actors = {
110 | from: target.user
111 | }
112 | if (handle === 'create') {
113 | action += 'CREATE'
114 | } else if (handle === 'update') {
115 | action += 'UPDATE'
116 | } else if (handle === 'mute') {
117 | action += 'MUTE_AUTO'
118 | }
119 | this.record('USER', 'USER', action, null, target, actors)
120 | }
121 |
122 | // 获取操作简语
123 | genVerb (classify) {
124 | const verbMap = {
125 | // type === 1,评论通知
126 | COMMENT_COMMENT_COMMENT: '评论了文章',
127 | COMMENT_COMMENT_COMMENT_REPLY: '回复了评论',
128 | COMMENT_COMMENT_COMMENT_UPDATE: '更新了评论',
129 | COMMENT_COMMENT_MESSAGE: '在站内留言',
130 | COMMENT_COMMENT_MESSAGE_REPLY: '回复了留言',
131 | COMMENT_COMMENT_MESSAGE_UPDATE: '更新了留言',
132 | // type === 2,点赞通知
133 | LIKE_ARTICLE_LIKE: '给文章点了赞',
134 | LIKE_ARTICLE_UNLIKE: '取消了文章点赞',
135 | LIKE_COMMENT_COMMENT_LIKE: '给评论点了赞',
136 | LIKE_COMMENT_MESSAGE_LIKE: '给留言点了赞',
137 | LIKE_COMMENT_COMMENT_UNLIKE: '取消了评论点赞',
138 | LIKE_COMMENT_MESSAGE_UNLIKE: '取消了留言点赞',
139 | // type === 3, 用户操作通知
140 | USER_USER_MUTE_AUTO: '用户被自动禁言',
141 | USER_USER_CREATE: '新增用户',
142 | USER_USER_UPDATE: '更新用户信息'
143 | }
144 | return verbMap[classify]
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/app/service/article.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @desc Article Services
3 | */
4 |
5 | const ProxyService = require('./proxy')
6 |
7 | module.exports = class ArticleService extends ProxyService {
8 | get model () {
9 | return this.app.model.Article
10 | }
11 |
12 | async getItemById (id, select, opt = {}, single = false) {
13 | let data = null
14 | const populate = [
15 | {
16 | path: 'category',
17 | select: 'name description extends'
18 | },
19 | {
20 | path: 'tag',
21 | select: 'name description extends'
22 | }
23 | ]
24 | if (!this.ctx.session._isAuthed) {
25 | // 前台博客访问文章的时候pv+1
26 | data = await this.updateItem({
27 | _id: id,
28 | state: this.config.modelEnum.article.state.optional.PUBLISH
29 | }, {
30 | $inc: { 'meta.pvs': 1 }
31 | }, Object.assign({}, opt, {
32 | select: '-content'
33 | }), populate)
34 | } else {
35 | data = await this.getItem({ _id: id }, opt, populate)
36 | }
37 | if (data && !single) {
38 | // 获取相关文章和上下篇文章
39 | const [related, adjacent] = await Promise.all([
40 | this.getRelatedArticles(data),
41 | this.getAdjacentArticles(data)
42 | ])
43 | data.related = related
44 | data.adjacent = adjacent
45 | }
46 | return data
47 | }
48 |
49 | async archives () {
50 | const $match = {}
51 | const $project = {
52 | year: { $year: '$createdAt' },
53 | month: { $month: '$createdAt' },
54 | title: 1,
55 | createdAt: 1,
56 | source: 1
57 | }
58 | if (!this.ctx.session._isAuthed) {
59 | $match.state = 1
60 | } else {
61 | $project.state = 1
62 | }
63 | let data = await this.aggregate([
64 | { $match },
65 | { $sort: { createdAt: -1 } },
66 | { $project },
67 | {
68 | $group: {
69 | _id: {
70 | year: '$year',
71 | month: '$month'
72 | },
73 | articles: {
74 | $push: {
75 | title: '$title',
76 | _id: '$_id',
77 | createdAt: '$createdAt',
78 | state: '$state',
79 | source: '$source'
80 | }
81 | }
82 | }
83 | }
84 | ])
85 | let total = 0
86 | if (data && data.length) {
87 | // 先取出year,并且降序排列,再填充month
88 | data = [...new Set(data.map(item => item._id.year).sort((a, b) => b - a))].map(year => {
89 | const months = []
90 | data.forEach(item => {
91 | const { _id, articles } = item
92 | if (year === _id.year) {
93 | total += articles.length
94 | months.push({
95 | month: _id.month,
96 | monthStr: this.app.utils.share.getMonthFromNum(_id.month),
97 | articles
98 | })
99 | }
100 | })
101 | return {
102 | year,
103 | months: months.sort((a, b) => b.month - a.month)
104 | }
105 | })
106 | }
107 | return {
108 | total,
109 | list: data || []
110 | }
111 | }
112 |
113 | // 根据标签获取相关文章
114 | async getRelatedArticles (data) {
115 | if (!data || !data._id) return null
116 | const { _id, tag = [] } = data
117 | const articles = await this.getList(
118 | {
119 | _id: { $nin: [ _id ] },
120 | state: data.state,
121 | tag: { $in: tag.map(t => t._id) }
122 | },
123 | 'title thumb createdAt publishedAt meta category',
124 | {
125 | sort: '-createdAt'
126 | },
127 | {
128 | path: 'category',
129 | select: 'name description'
130 | }
131 | )
132 | return articles && articles.slice(0, this.app.setting.limit.relatedArticleCount) || null
133 | }
134 |
135 | // 获取相邻的文章
136 | async getAdjacentArticles (data) {
137 | if (!data || !data._id) return null
138 | const query = {
139 | createdAt: {
140 | $lt: data.createdAt
141 | }
142 | }
143 | // 如果未通过权限校验,将文章状态重置为1
144 | if (!this.ctx.session._isAuthed) {
145 | query.state = this.config.modelEnum.article.state.optional.PUBLISH
146 | }
147 | const nextQuery = Object.assign({}, query, {
148 | createdAt: {
149 | $gt: data.createdAt
150 | }
151 | })
152 | const select = '-renderedContent'
153 | const opt = { sort: 'createdAt' }
154 | const populate = {
155 | path: 'category',
156 | select: 'name description'
157 | }
158 | const [prev, next] = await Promise.all([
159 | this.getItem(query, select, opt, populate),
160 | this.getItem(nextQuery, select, opt, populate)
161 | ])
162 | return {
163 | prev: prev || null,
164 | next: next || null
165 | }
166 | }
167 |
168 | async updateCommentCount (articleIds = []) {
169 | if (!Array.isArray(articleIds)) {
170 | articleIds = [articleIds]
171 | }
172 | if (!articleIds.length) return
173 | const { validate, share } = this.app.utils
174 | // TIP: 这里必须$in的是一个ObjectId对象数组,而不能只是id字符串数组
175 | articleIds = [...new Set(articleIds)].filter(id => validate.isObjectId(id)).map(id => share.createObjectId(id))
176 | const counts = await this.service.comment.aggregate([
177 | { $match: { article: { $in: articleIds }, state: this.config.modelEnum.comment.state.optional.PASS } },
178 | { $group: { _id: '$article', total_count: { $sum: 1 } } }
179 | ])
180 | await Promise.all(
181 | counts.map(count => this.updateItemById(count._id, { $set: { 'meta.comments': count.total_count } }))
182 | )
183 | this.logger.info('文章评论数量更新成功')
184 | }
185 | }
186 |
--------------------------------------------------------------------------------
/config/config.default.js:
--------------------------------------------------------------------------------
1 | module.exports = appInfo => {
2 | const config = exports = {}
3 |
4 | // use for cookie sign key, should change to your own and keep security
5 | config.keys = appInfo.name + '_1534765762288_2697'
6 |
7 | config.version = appInfo.pkg.version
8 |
9 | config.author = appInfo.pkg.author
10 |
11 | config.isLocal = appInfo.env === 'local'
12 |
13 | config.isProd = appInfo.env === 'prod'
14 |
15 | // add your config here
16 | config.middleware = [
17 | 'gzip',
18 | 'error',
19 | 'headers'
20 | ]
21 |
22 | config.security = {
23 | domainWhiteList: [
24 | '*.jooger.me',
25 | 'jooger.me',
26 | 'localhost:8081'
27 | ],
28 | csrf: {
29 | enable: false
30 | }
31 | }
32 |
33 |
34 | config.cors = {
35 | enable: true,
36 | credentials: true,
37 | allowMethods: 'GET,PUT,POST,DELETE,PATCH,OPTIONS'
38 | }
39 |
40 | config.session = {
41 | key: appInfo.name + '_token',
42 | maxAge: 1000 * 60 * 60 * 24 * 7,
43 | signed: true
44 | }
45 |
46 | config.userCookieKey = appInfo.name + '_userid'
47 |
48 | config.secrets = appInfo.name + '_secrets'
49 |
50 | config.bodyParser = {
51 | jsonLimit: '10mb'
52 | }
53 |
54 | config.gzip = {
55 | threshold: 1024
56 | }
57 |
58 | config.console = {
59 | debug: true,
60 | error: true
61 | }
62 |
63 | config.akismet = {
64 | client: {
65 | blog: config.author.url,
66 | key: '7fa12f4a1d08'
67 | }
68 | }
69 |
70 | config.mailer = {
71 | client: {
72 | service: '163',
73 | secure: true
74 | }
75 | }
76 |
77 | // mongoose配置
78 | config.mongoose = {
79 | url: process.env.EGG_MONGODB_URL || 'mongodb://node-server:node-server@127.0.0.1:27016/node-server',
80 | options: {
81 | useNewUrlParser: true,
82 | poolSize: 20,
83 | keepAlive: true,
84 | autoReconnect: true,
85 | reconnectInterval: 1000,
86 | reconnectTries: Number.MAX_VALUE
87 | }
88 | }
89 |
90 | config.redis = {
91 | client: {
92 | host: process.env.EGG_REDIS_HOST || '127.0.0.1',
93 | port: process.env.EGG_REDIS_PORT || 6378,
94 | db: 1,
95 | password: process.env.EGG_REDIS_PASSWORD || appInfo.name
96 | },
97 | agent: true
98 | }
99 |
100 | // allowed origins
101 | config.allowedOrigins = ['jooger.me', 'www.jooger.me', 'admin.jooger.me']
102 |
103 | // 请求响应code
104 | config.codeMap = {
105 | '-1': '请求失败',
106 | 200: '请求成功',
107 | 401: '权限校验失败',
108 | 403: 'Forbidden',
109 | 404: 'URL资源未找到',
110 | 422: '参数校验失败',
111 | 500: '服务器错误'
112 | }
113 |
114 | config.modelEnum = {
115 | article: {
116 | // 文章状态 ( 0 草稿(默认) | 1 已发布 )
117 | state: {
118 | default: 0,
119 | optional: {
120 | DRAFT: 0,
121 | PUBLISH: 1
122 | }
123 | },
124 | // 来源 0 原创 | 1 转载 | 2 混撰 | 3 翻译
125 | source: {
126 | default: 0,
127 | optional: {
128 | ORIGINAL: 0,
129 | REPRINT: 1,
130 | HYBRID: 2,
131 | TRANSLATE: 3
132 | }
133 | }
134 | },
135 | user: {
136 | // 角色 0 管理员 | 1 普通用户
137 | role: {
138 | default: 1,
139 | optional: {
140 | ADMIN: 0,
141 | NORMAL: 1
142 | }
143 | }
144 | },
145 | comment: {
146 | // 状态 -2 垃圾评论 | -1 已删除 | 0 待审核 | 1 通过
147 | state: {
148 | default: 1,
149 | optional: {
150 | SPAM: -2,
151 | DELETED: -1,
152 | AUDITING: 0,
153 | PASS: 1
154 | }
155 | },
156 | // 类型 0 文章评论 | 1 站内留言
157 | type: {
158 | default: 0,
159 | optional: {
160 | COMMENT: 0,
161 | MESSAGE: 1
162 | }
163 | }
164 | },
165 | notification: {
166 | type: {
167 | optional: {
168 | GENERAL: 0,
169 | COMMENT: 1,
170 | LIKE: 2,
171 | USER: 3
172 | }
173 | },
174 | classify: {
175 | optional: {
176 | // 遵循 type_model_action 模式
177 | // type === 0,系统通知
178 | GENERAL_MAIL_VERIFY_FAIL: 'mail_verify_fail', // 邮件客户端校验失败
179 | GENERAL_MAIL_SEND_FAIL: 'mail_send_fail', // 邮件发送失败
180 | GENERAL_AKISMET_CHECK_FAIL: 'akismet_check_fail', // akismet检测失败
181 | // type === 1,评论通知
182 | COMMENT_COMMENT_COMMENT: 'comment_comment', // 评论(非回复)
183 | COMMENT_COMMENT_COMMENT_REPLY: 'comment_comment_reply', // 评论回复
184 | COMMENT_COMMENT_COMMENT_UPDATE: 'comment_comment_update', // 评论更新(保留)
185 | COMMENT_COMMENT_MESSAGE: 'comment_message', // 站内留言
186 | COMMENT_COMMENT_MESSAGE_REPLY: 'comment_message_reply', // 站内留言回复
187 | COMMENT_COMMENT_MESSAGE_UPDATE: 'comment_message_reply', // 站内留言更新
188 | // type === 2,点赞通知
189 | LIKE_ARTICLE_LIKE: 'article_like', // 文章点赞
190 | LIKE_ARTICLE_UNLIKE: 'article_unlike', // 文章取消点赞
191 | LIKE_COMMENT_COMMENT_LIKE: 'coment_like', // 评论点赞
192 | LIKE_COMMENT_MESSAGE_LIKE: 'message_like', // 留言点赞
193 | LIKE_COMMENT_MESSAGE_UNLIKE: 'message_unlike', // 留言取消点赞
194 | LIKE_COMMENT_COMMENT_UNLIKE: 'comment_unlike', // 评论取消点赞
195 | // type === 3, 用户操作通知
196 | USER_USER_MUTE_AUTO: 'user_mute_auto', // 用户被自动禁言
197 | USER_USER_CREATE: 'user_create', // 用户创建
198 | USER_USER_UPDATE: 'user_update' // 用户更新
199 | }
200 | }
201 | },
202 | stat: {
203 | type: {
204 | optional: {
205 | // 遵循 target_action 模式
206 | KEYWORD_SEARCH: 0, // 文章关键词搜索
207 | CATEGORY_SEARCH: 1, // 文章分类搜索
208 | TAG_SEARCH: 2, // 文章标签搜索
209 | ARTICLE_VIEW: 3, // 文章访问
210 | ARTICLE_LIKE: 4, // 文章点赞
211 | USER_CREATE: 5 // 用户创建
212 | }
213 | }
214 | }
215 | }
216 |
217 | // 初始化管理员,默认的名称和密码,名称需要是github名称
218 | config.defaultAdmin = {
219 | name: 'Jooger',
220 | password: 'admin123456',
221 | email: 'iamjooger@gmail.com',
222 | site: 'https://jooger.me'
223 | }
224 |
225 | config.defaultAvatar = 'https://static.jooger.me/img/common/avatar.png'
226 |
227 | return config
228 | }
229 |
--------------------------------------------------------------------------------
/app/service/stat.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @desc 各类统计 Service
3 | */
4 |
5 | const moment = require('moment')
6 | const ProxyService = require('./proxy')
7 |
8 | module.exports = class StatService extends ProxyService {
9 | get model () {
10 | return this.app.model.Stat
11 | }
12 |
13 | get statConfig () {
14 | return this.app.config.modelEnum.stat.type.optional
15 | }
16 |
17 | get dimensions () {
18 | return {
19 | day: {
20 | type: 'day',
21 | format: '%Y-%m-%d',
22 | mFormat: 'YYYY-MM-DD'
23 | },
24 | month: {
25 | type: 'month',
26 | format: '%Y-%m',
27 | mFormat: 'YYYY-MM'
28 | },
29 | year: {
30 | type: 'year',
31 | format: '%Y',
32 | mFormat: 'YYYY'
33 | }
34 | }
35 | }
36 |
37 | get dimensionsValidate () {
38 | return Object.values(this.dimensions).map(item => item.type)
39 | }
40 |
41 | async record (typeKey, target = {}, statKey) {
42 | const type = this.statConfig[typeKey]
43 | const stat = { [statKey]: 1 }
44 | const payload = { type, target, stat }
45 | const data = await this.create(payload)
46 | if (data) {
47 | this.logger.info(`统计项生成成功,[id: ${data._id}] [type:${typeKey}] [stat:${statKey}]`)
48 | }
49 | }
50 |
51 | async getCount (type) {
52 | const [today, total] = await Promise.all([
53 | this.countToday(type),
54 | this.countTotal(type)
55 | ])
56 | return { today, total }
57 | }
58 |
59 | countToday (type) {
60 | return this.countFromToday(0, type)
61 | }
62 |
63 | async countTotal (type) {
64 | if (['pv', 'up'].includes(type)) {
65 | const res = await this.service.article.aggregate([
66 | {
67 | $group: {
68 | _id: '$_id',
69 | total: {
70 | $sum: '$meta.' + type + 's'
71 | }
72 | }
73 | }
74 | ])
75 | return res.reduce((sum, item) => {
76 | sum += item.total
77 | return sum
78 | }, 0)
79 | } else if (['comment', 'message'].includes(type)) {
80 | return await this.service.comment.count({ type: ['comment', 'message'].findIndex(item => item === type) })
81 | } else if (['user'].includes(type)) {
82 | return await this.service.user.count({ role: this.config.modelEnum.user.role.optional.NORMAL })
83 | }
84 | // 上面都不支持时候,才走stat model数据
85 | return await this.countFromToday(null, type)
86 | }
87 |
88 | countFromToday (subtract, type) {
89 | const today = new Date()
90 | const before = (subtract !== null) ? moment().subtract(subtract, 'days') : subtract
91 | return this.countRange(before, today, type)
92 | }
93 |
94 | async countRange (start, end, type) {
95 | let sm = start && moment(start)
96 | let em = end && moment(end)
97 | let service = null
98 | const filter = {
99 | createdAt: {}
100 | }
101 | if (sm) {
102 | const format = sm.format('YYYY-MM-DD 00:00:00')
103 | sm = moment(format)
104 | filter.createdAt.$gte = new Date(format)
105 | }
106 | if (em) {
107 | const format = em.format('YYYY-MM-DD 23:59:59')
108 | em = moment(format)
109 | filter.createdAt.$lte = new Date(format)
110 | }
111 | if (type === 'pv') {
112 | service = this
113 | filter.type = this.statConfig.ARTICLE_VIEW
114 | } else if (type === 'up') {
115 | service = this
116 | filter.type = this.statConfig.ARTICLE_LIKE
117 | } else if (type === 'comment') {
118 | // 文章评论量
119 | service = this.service.comment
120 | filter.type = this.config.modelEnum.comment.type.optional.COMMENT
121 | } else if (type === 'message') {
122 | // 站内留言量
123 | service = this.service.comment
124 | filter.type = this.config.modelEnum.comment.type.optional.MESSAGE
125 | } else if (type === 'user') {
126 | // 用户创建
127 | service = this
128 | filter.type = this.statConfig.USER_CREATE
129 | }
130 | return service && service.count(filter) || null
131 | }
132 |
133 | async trendRange (start, end, dimension, type) {
134 | let sm = moment(start)
135 | let em = moment(end)
136 | let service = null
137 | const $sort = {
138 | createdAt: -1
139 | }
140 | const $match = {
141 | createdAt: {}
142 | }
143 | if (sm) {
144 | const format = sm.format('YYYY-MM-DD 00:00:00')
145 | sm = moment(format)
146 | $match.createdAt.$gte = new Date(format)
147 | }
148 | if (em) {
149 | const format = em.format('YYYY-MM-DD 23:59:59')
150 | em = moment(format)
151 | $match.createdAt.$lte = new Date(format)
152 | }
153 | const $project = {
154 | _id: 0,
155 | createdAt: 1,
156 | date: {
157 | $dateToString: {
158 | format: this.dimensions[dimension].format,
159 | date: '$createdAt',
160 | // TIP: mongod是ISODate,是GMT-8h
161 | timezone: '+08'
162 | }
163 | }
164 | }
165 | const $group = {
166 | _id: '$date',
167 | count: {
168 | $sum: 1
169 | }
170 | }
171 | if (type === 'pv') {
172 | service = this
173 | $match.type = this.statConfig.ARTICLE_VIEW
174 | } else if (type === 'up') {
175 | service = this
176 | $match.type = this.statConfig.ARTICLE_LIKE
177 | } else if (type === 'comment') {
178 | // 文章评论量
179 | service = this.service.comment
180 | $match.type = this.config.modelEnum.comment.type.optional.COMMENT
181 | } else if (type === 'message') {
182 | // 站内留言量
183 | service = this.service.comment
184 | $match.type = this.config.modelEnum.comment.type.optional.MESSAGE
185 | } else if (type === 'user') {
186 | // 用户创建
187 | service = this
188 | $match.type = this.statConfig.USER_CREATE
189 | }
190 | if (!service) return []
191 | const data = await service.aggregate([
192 | { $sort },
193 | { $match },
194 | { $project },
195 | { $group }
196 | ])
197 | // day维度
198 | let radix = 1000 * 60 * 60 * 24
199 | if (dimension === this.dimensions.month.type) {
200 | // month 维度
201 | radix *= 30
202 | } else if (dimension === this.dimensions.year.type) {
203 | // year 维度
204 | radix *= 365
205 | }
206 | const diff = Math.ceil(em.diff(sm) / radix)
207 | return new Array(diff || 1).fill().map((item, index) => {
208 | const date = moment(sm).add(index, dimension + 's').format(this.dimensions[dimension].mFormat)
209 | let count = 0
210 | const hit = data.find(d => d._id === date)
211 | if (hit) {
212 | count = hit.count
213 | }
214 | return {
215 | date,
216 | count
217 | }
218 | })
219 | }
220 | }
221 |
--------------------------------------------------------------------------------
/emails/markdown.css:
--------------------------------------------------------------------------------
1 | .markdown-body {
2 | font-size: 16px;
3 | font-weight: 300;
4 | color: var(--markdown-color);
5 | font-family: Helvetica Neue For Number, -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol
6 | }
7 |
8 | .markdown-body h1,
9 | .markdown-body h2,
10 | .markdown-body h3,
11 | .markdown-body h4,
12 | .markdown-body h5,
13 | .markdown-body h6 {
14 | font-weight: 700;
15 | margin: 1em 0;
16 | color: var(--dark)
17 | }
18 |
19 | .markdown-body hr {
20 | height: .05em;
21 | border: 0;
22 | color: var(--border-color);
23 | background-color: var(--border-color)
24 | }
25 |
26 | .markdown-body blockquote {
27 | margin: 1em 0;
28 | border-left: 4px solid var(--border-color);
29 | padding: 0 1em;
30 | color: var(--text-color-secondary)
31 | }
32 |
33 | .markdown-body pre {
34 | position: relative;
35 | font-size: inherit;
36 | padding: 16px;
37 | overflow: auto;
38 | line-height: 1.45;
39 | background-color: var(--code-color-light);
40 | border-radius: 2px
41 | }
42 |
43 | .markdown-body code {
44 | padding: .2em .4em;
45 | margin: 0;
46 | font-size: 85%;
47 | background-color: var(--code-color);
48 | border-radius: 3px;
49 | font-family: monospace;
50 | color: var(--keyword-color)
51 | }
52 |
53 | .markdown-body code.language-bash:after,
54 | .markdown-body code.language-c:after,
55 | .markdown-body code.language-cpp:after,
56 | .markdown-body code.language-cs:after,
57 | .markdown-body code.language-css:after,
58 | .markdown-body code.language-go:after,
59 | .markdown-body code.language-html:after,
60 | .markdown-body code.language-java:after,
61 | .markdown-body code.language-javascript:after,
62 | .markdown-body code.language-js:after,
63 | .markdown-body code.language-jsx:after,
64 | .markdown-body code.language-py:after,
65 | .markdown-body code.language-rb:after,
66 | .markdown-body code.language-swift:after,
67 | .markdown-body code.language-ts:after,
68 | .markdown-body code.language-typescript:after {
69 | position: absolute;
70 | top: 0;
71 | right: 0;
72 | display: block;
73 | text-align: right;
74 | font-size: 80%;
75 | font-family: monospace;
76 | padding: 0 10px;
77 | height: 24px;
78 | line-height: 24px;
79 | color: var(--text-color-secondary);
80 | background-color: var(--code-color-dark);
81 | border-bottom-left-radius: 2px
82 | }
83 |
84 | .markdown-body code.language-cpp:after {
85 | content: "C++"
86 | }
87 |
88 | .markdown-body code.language-java:after {
89 | content: "Java"
90 | }
91 |
92 | .markdown-body code.language-c:after {
93 | content: "C"
94 | }
95 |
96 | .markdown-body code.language-cs:after {
97 | content: "C#"
98 | }
99 |
100 | .markdown-body code.language-html:after {
101 | content: "Html"
102 | }
103 |
104 | .markdown-body code.language-css:after {
105 | content: "Css"
106 | }
107 |
108 | .markdown-body code.language-javascript:after,
109 | .markdown-body code.language-js:after {
110 | content: "JavaScript"
111 | }
112 |
113 | .markdown-body code.language-ts:after,
114 | .markdown-body code.language-typescript:after {
115 | content: "TavaScript"
116 | }
117 |
118 | .markdown-body code.language-jsx:after {
119 | content: "Jsx"
120 | }
121 |
122 | .markdown-body code.language-bash:after {
123 | content: "Bash"
124 | }
125 |
126 | .markdown-body code.language-py:after {
127 | content: "Python"
128 | }
129 |
130 | .markdown-body code.language-rb:after {
131 | content: "Ruby"
132 | }
133 |
134 | .markdown-body code.language-swift:after {
135 | content: "Swift"
136 | }
137 |
138 | .markdown-body code.language-go:after {
139 | content: "Go"
140 | }
141 |
142 | .markdown-body pre>code {
143 | border: 0;
144 | margin: 0;
145 | padding: 0;
146 | background-color: var(--code-color-light);
147 | font-size: 85%;
148 | color: var(--text-color)
149 | }
150 |
151 | .markdown-body a,
152 | .markdown-body a:visited {
153 | padding-bottom: 4px;
154 | color: var(--text-color);
155 | background-color: inherit;
156 | text-decoration: none;
157 | font-weight: 700
158 | }
159 |
160 | .markdown-body a:hover,
161 | .markdown-body a:visited:hover {
162 | text-decoration: underline
163 | }
164 |
165 | .markdown-body img {
166 | max-width: 100%;
167 | cursor: zoom-in;
168 | border: 6px solid var(--border-color)
169 | }
170 |
171 | .markdown-body .image-wrapper {
172 | text-align: center
173 | }
174 |
175 | .markdown-body .image-alt {
176 | text-align: center;
177 | color: var(--text-color-secondary);
178 | font-size: 80%
179 | }
180 |
181 | .markdown-body div,
182 | .markdown-body p {
183 | line-height: 1.7em
184 | }
185 |
186 | .markdown-body ol,
187 | .markdown-body ul {
188 | padding-left: 2em;
189 | list-style: disc
190 | }
191 |
192 | .markdown-body ol li,
193 | .markdown-body ul li {
194 | line-height: 1.8
195 | }
196 |
197 | .markdown-body table {
198 | display: block;
199 | width: 100%;
200 | overflow: hidden;
201 | border-spacing: 0;
202 | border-collapse: collapse
203 | }
204 |
205 | .markdown-body table tr {
206 | background-color: var(--dark);
207 | border-top: 1px solid var(--border-color)
208 | }
209 |
210 | .markdown-body table tr:nth-child(2n) {
211 | background-color: #f6f8fa
212 | }
213 |
214 | .markdown-body table td,
215 | .markdown-body table th {
216 | padding: 6px 13px;
217 | border: 1px solid #dfe2e5
218 | }
219 |
220 | .markdown-body table th {
221 | font-weight: 600
222 | }
223 |
224 | .markdown-body blockquote,
225 | .markdown-body dl,
226 | .markdown-body ol,
227 | .markdown-body p,
228 | .markdown-body pre,
229 | .markdown-body table,
230 | .markdown-body ul {
231 | margin-top: 0;
232 | margin-bottom: .72em
233 | }
234 |
235 | .markdown-body .hljs {
236 | display: block;
237 | overflow-x: auto;
238 | padding: .5em;
239 | color: var(--text-color)
240 | }
241 |
242 | .markdown-body .hljs .comment,
243 | .markdown-body .hljs .quote {
244 | color: var(--text-color-secondary);
245 | font-style: italic
246 | }
247 |
248 | .markdown-body .hljs .doctag,
249 | .markdown-body .hljs .formula,
250 | .markdown-body .hljs .keyword {
251 | color: #a626a4
252 | }
253 |
254 | .markdown-body .hljs .deletion,
255 | .markdown-body .hljs .name,
256 | .markdown-body .hljs .section,
257 | .markdown-body .hljs .selector-tag,
258 | .markdown-body .hljs .subst {
259 | color: #e45649
260 | }
261 |
262 | .markdown-body .hljs .literal {
263 | color: #0184bb
264 | }
265 |
266 | .markdown-body .hljs .addition,
267 | .markdown-body .hljs .attribute,
268 | .markdown-body .hljs .meta-string,
269 | .markdown-body .hljs .regexp,
270 | .markdown-body .hljs .string {
271 | color: #50a14f
272 | }
273 |
274 | .markdown-body .hljs .built_in,
275 | .markdown-body .hljs .class .title {
276 | color: #c18401
277 | }
278 |
279 | .markdown-body .hljs .attr,
280 | .markdown-body .hljs .number,
281 | .markdown-body .hljs .selector-attr,
282 | .markdown-body .hljs .selector-class,
283 | .markdown-body .hljs .selector-pseudo,
284 | .markdown-body .hljs .template-variable,
285 | .markdown-body .hljs .type,
286 | .markdown-body .hljs .variable {
287 | color: #986801
288 | }
289 |
290 | .markdown-body .hljs .bullet,
291 | .markdown-body .hljs .link,
292 | .markdown-body .hljs .meta,
293 | .markdown-body .hljs .selector-id,
294 | .markdown-body .hljs .symbol,
295 | .markdown-body .hljs .title {
296 | color: #4078f2
297 | }
298 |
299 | .markdown-body .hljs .emphasis {
300 | font-style: italic
301 | }
302 |
303 | .markdown-body .hljs .strong {
304 | font-weight: 700
305 | }
306 |
307 | .markdown-body .hljs .link {
308 | text-decoration: underline
309 | }
310 |
--------------------------------------------------------------------------------
/app/service/agent.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @desc api 代理
3 | */
4 |
5 | const axios = require('axios')
6 | const geoip = require('geoip-lite')
7 | const NeteaseMusic = require('simple-netease-cloud-music')
8 | const { Service } = require('egg')
9 |
10 | const netease = new NeteaseMusic()
11 |
12 | module.exports = class AgentService extends Service {
13 | get voiceStoreConfig () {
14 | return {
15 | key: 'voice',
16 | // 最大限制500条缓存
17 | maxLen: 500
18 | }
19 | }
20 |
21 | get musicStoreConfig () {
22 | return {
23 | key: 'music'
24 | }
25 | }
26 |
27 | async lookupIp (ip) {
28 | ip = ip || this.ctx.getCtxIp()
29 | const res = await axios.get('https://dm-81.data.aliyun.com/rest/160601/ip/getIpInfo.json', {
30 | headers: {
31 | Authorization: `APPCODE ${this.app.setting.keys.aliApiGateway.ip.appCode}`
32 | },
33 | params: {
34 | ip
35 | }
36 | }).catch(() => null)
37 | let location = {}
38 | if (res && res.status === 200 && !res.data.code) {
39 | location = res.data.data
40 | } else {
41 | location = geoip.lookup(ip) || {}
42 | }
43 | return {
44 | ip,
45 | location
46 | }
47 | }
48 |
49 | async getVoice () {
50 | const { key } = this.voiceStoreConfig
51 | const voiceStore = await this.app.store.get(key)
52 | let data = null
53 | if (voiceStore && voiceStore.length) {
54 | // 随机
55 | data = voiceStore[Math.floor(Math.random() * voiceStore.length)]
56 | } else {
57 | data = await this.fetchRemoteVoice()
58 | }
59 | return data
60 | }
61 |
62 | async fetchRemoteVoice () {
63 | const res = await axios.get('https://v1.hitokoto.cn', {
64 | params: {
65 | encode: 'json',
66 | charset: 'utf-8'
67 | }
68 | }).catch(err => {
69 | this.logger.error('获取Voice失败:' + err)
70 | return null
71 | })
72 | if (res && res.status === 200) {
73 | const { hitokoto, from, creator } = res.data
74 | const data = {
75 | text: hitokoto,
76 | source: from,
77 | author: creator
78 | }
79 | await this.setVoiceToStore(data)
80 | return data
81 | }
82 | return null
83 | }
84 |
85 | async setVoiceToStore (voice) {
86 | if (!voice) return
87 | const { key, maxLen } = this.voiceStoreConfig
88 | let voiceStore = await this.app.store.get(key)
89 | if (!voiceStore) {
90 | // 初始化
91 | voiceStore = []
92 | }
93 | if (voiceStore.length >= maxLen) {
94 | voiceStore.shift()
95 | }
96 | voiceStore.push(voice)
97 | await this.app.store.set(key, voiceStore)
98 | }
99 |
100 | async getMusicList () {
101 | const { key } = this.musicStoreConfig
102 | let list = await this.app.store.get(key)
103 | if (!list) {
104 | list = await this.fetchRemoteMusicList()
105 | }
106 | return list
107 | }
108 |
109 | async getMusicSong (songId) {
110 | const { key } = this.musicStoreConfig
111 | const list = await this.app.store.get(key)
112 | let song = null
113 | if (list) {
114 | const hit = list.find(item => item.id === songId)
115 | if (hit && hit.url) {
116 | song = hit
117 | }
118 | }
119 | return song || await this.fetchRemoteMusicSong(songId)
120 | }
121 |
122 | async fetchRemoteMusicList (cacheIt = true) {
123 | const playListId = this.app.setting.site.musicId
124 | if (!playListId) return
125 | const data = await netease.playlist(playListId)
126 | .catch(err => {
127 | this.logger.error('获取歌单列表失败:' + err)
128 | return null
129 | })
130 |
131 | if (!data || !data.playlist) return
132 | const tracks = (data.playlist.tracks || []).map(({ name, id, ar, al, dt, tns }) => {
133 | return {
134 | id,
135 | name,
136 | duration: dt || 0,
137 | album: al ? {
138 | name: al.name,
139 | cover: this.config.isProd ? (this.app.proxyUrl(al.picUrl) || '') : al.picUrl,
140 | tns: al.tns
141 | } : {},
142 | artists: ar ? ar.map(({ id, name }) => ({ id, name })) : [],
143 | tns: tns || []
144 | }
145 | })
146 | cacheIt && await this.setMusicListToStore(tracks)
147 | return tracks
148 | }
149 |
150 | async fetchRemoteMusicSong (songId, cacheIt = true) {
151 | if (!songId) return
152 | songId = +songId
153 |
154 | const app = this.app
155 |
156 | // 获取歌曲链接
157 | async function fetchUrl () {
158 | let song = await netease.url(songId)
159 | .catch(err => {
160 | this.logger.error('获取歌曲链接失败:' + err)
161 | return null
162 | })
163 | if (!song || !song.data || !song.data[0]) return null
164 | song = song.data[0]
165 | song.url = app.proxyUrl(song.url)
166 | return song
167 | }
168 |
169 | // 获取歌词
170 | async function fetchLyric () {
171 | const res = {}
172 | const { lrc, tlyric } = await netease.lyric(songId)
173 | .catch(err => {
174 | this.logger.error('获取歌曲歌词失败:' + err)
175 | return {
176 | lrc: null,
177 | tlyric: null
178 | }
179 | })
180 | res.lyric = lrc && lrc.lyric || null
181 | res.tlyric = tlyric && tlyric.lyric || null
182 | return res
183 | }
184 | const song = await fetchUrl()
185 | const { lyric, tlyric } = await fetchLyric()
186 | if (song) {
187 | Object.assign(song, {
188 | lyric: parseLyric(lyric, tlyric)
189 | })
190 | if (cacheIt) {
191 | return await this.setMusicSongToStore(songId, song)
192 | }
193 | }
194 | return song
195 | }
196 |
197 | async setMusicListToStore (playlist) {
198 | if (!playlist || !playlist.length) return
199 | const { key } = this.musicStoreConfig
200 | // 1小时的缓存时间
201 | await this.app.store.set(key, playlist, 60 * 60 * 1000)
202 | }
203 |
204 | async setMusicSongToStore (songId, song) {
205 | if (!songId || !song) return
206 | const { key } = this.musicStoreConfig
207 | const list = await this.app.store.get(key)
208 | if (!list) return
209 | const hit = list.find(item => item.id === songId)
210 | if (!hit) return
211 | Object.assign(hit, song)
212 | await this.setMusicListToStore(list)
213 | return Object.assign(hit)
214 | }
215 | }
216 |
217 | // 歌词时间正则 => 01:59.999
218 | const lrcTimeReg = /\[(([0-5][0-9]):([0-5][0-9])\.(\d+))\]/g
219 | /**
220 | * 解析歌词
221 | * @param {String} lrc 原版歌词
222 | * @param {String} tlrc 翻译歌词
223 | * @return {Array