├── . dockerignore ├── .autod.conf.js ├── .dockerignore ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmrc ├── .release-it.json ├── .travis.yml ├── Dockerfile ├── README.md ├── app.js ├── app ├── controller │ ├── agent.js │ ├── article.js │ ├── auth.js │ ├── category.js │ ├── comment.js │ ├── moment.js │ ├── notification.js │ ├── setting.js │ ├── stat.js │ ├── tag.js │ └── user.js ├── extend │ ├── application.js │ └── context.js ├── lib │ └── plugin │ │ ├── egg-akismet │ │ ├── agent.js │ │ ├── app.js │ │ ├── config │ │ │ └── config.default.js │ │ ├── lib │ │ │ └── akismet.js │ │ └── package.json │ │ └── egg-mailer │ │ ├── agent.js │ │ ├── app.js │ │ ├── config │ │ └── config.default.js │ │ ├── lib │ │ └── mailer.js │ │ └── package.json ├── middleware │ ├── auth.js │ ├── error.js │ ├── gzip.js │ └── headers.js ├── model │ ├── article.js │ ├── category.js │ ├── comment.js │ ├── moment.js │ ├── notification.js │ ├── setting.js │ ├── stat.js │ ├── tag.js │ └── user.js ├── router.js ├── router │ ├── backend.js │ └── frontend.js ├── schedule │ ├── backup.js │ ├── links.js │ ├── music.js │ ├── personal.js │ └── voice.js ├── service │ ├── agent.js │ ├── akismet.js │ ├── article.js │ ├── auth.js │ ├── category.js │ ├── comment.js │ ├── github.js │ ├── mail.js │ ├── moment.js │ ├── notification.js │ ├── proxy.js │ ├── sentry.js │ ├── seo.js │ ├── setting.js │ ├── stat.js │ ├── tag.js │ └── user.js └── utils │ ├── encode.js │ ├── gravatar.js │ ├── markdown.js │ ├── share.js │ └── validate.js ├── appveyor.yml ├── config ├── config.default.js ├── config.local.js ├── config.prod.js ├── plugin.js └── plugin.prod.js ├── docker-compose.dev.yml ├── docker-compose.yml ├── emails ├── comment.pug └── markdown.css ├── init.d └── mongo │ └── init.js ├── package.json ├── test └── app │ └── service │ ├── category.test.js │ └── tag.test.js └── yarn.lock /. dockerignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | npm-debug.log* 3 | .nuxt/ 4 | 5 | .vscode 6 | .idea 7 | node_modules -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .git/ 3 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry = https://registry.npm.taobao.org -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | [![GitHub forks](https://img.shields.io/github/forks/jo0ger/node-server.svg?style=flat-square)](https://github.com/jo0ger/node-server/network) 13 | [![GitHub stars](https://img.shields.io/github/stars/jo0ger/node-server.svg?style=flat-square)](https://github.com/jo0ger/node-server/stargazers) 14 | [![GitHub issues](https://img.shields.io/github/issues/jo0ger/node-server.svg?style=flat-square)](https://github.com/jo0ger/node-server/issues) 15 | [![GitHub last commit](https://img.shields.io/github/last-commit/jo0ger/node-server.svg?style=flat-square)](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.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/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/controller/article.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 文章 Controller 3 | */ 4 | 5 | const { Controller } = require('egg') 6 | 7 | module.exports = class ArticleController extends Controller { 8 | get rules () { 9 | const { state, source } = this.config.modelEnum.article 10 | return { 11 | list: { 12 | page: { type: 'int', required: true, min: 1 }, 13 | limit: { type: 'int', required: false, min: 1 }, 14 | state: { type: 'enum', values: Object.values(state.optional), required: false }, 15 | source: { type: 'enum', values: Object.values(source.optional), required: false }, 16 | category: { type: 'string', required: false }, 17 | tag: { type: 'string', required: false }, 18 | keyword: { type: 'string', required: false }, 19 | startDate: { type: 'string', required: false }, 20 | endDate: { type: 'string', required: false }, 21 | // -1 desc | 1 asc 22 | order: { type: 'enum', values: [-1, 1], required: false }, 23 | sortBy: { type: 'enum', values: ['createdAt', 'updatedAt', 'publishedAt', 'meta.ups', 'meta.pvs', 'meta.comments'], required: false } 24 | }, 25 | create: { 26 | title: { type: 'string', required: true }, 27 | content: { type: 'string', required: true }, 28 | description: { type: 'string', required: false }, 29 | keywords: { type: 'array', required: false }, 30 | category: { type: 'objectId', required: true }, 31 | tag: { type: 'array', required: false, itemType: 'objectId' }, 32 | state: { type: 'enum', values: Object.values(state.optional), required: true }, 33 | source: { type: 'enum', values: Object.values(source.optional), required: true }, 34 | from: { type: 'url', required: false }, 35 | thumb: { type: 'url', required: false }, 36 | createdAt: { type: 'string', required: false } 37 | }, 38 | update: { 39 | title: { type: 'string', required: false }, 40 | content: { type: 'string', required: false }, 41 | description: { type: 'string', required: false }, 42 | keywords: { type: 'array', required: false }, 43 | category: { type: 'objectId', required: false }, 44 | tag: { type: 'array', required: false, itemType: 'objectId' }, 45 | state: { type: 'enum', values: Object.values(state.optional), required: false }, 46 | source: { type: 'enum', values: Object.values(source.optional), required: false }, 47 | from: { type: 'url', required: false }, 48 | thumb: { type: 'url', required: false }, 49 | createdAt: { type: 'string', required: false } 50 | } 51 | } 52 | } 53 | 54 | async list () { 55 | const { ctx, app } = this 56 | ctx.query.page = Number(ctx.query.page) 57 | if (ctx.query.limit) { 58 | ctx.query.limit = Number(ctx.query.limit) 59 | } 60 | const tranArray = ['limit', 'state', 'source', 'order'] 61 | tranArray.forEach(key => { 62 | if (ctx.query[key]) { 63 | ctx.query[key] = Number(ctx.query[key]) 64 | } 65 | }) 66 | ctx.validate(this.rules.list, ctx.query) 67 | const { page, limit, state, keyword, category, tag, source, order, sortBy, startDate, endDate } = ctx.query 68 | const options = { 69 | sort: { 70 | createdAt: -1 71 | }, 72 | page, 73 | limit: limit || this.app.setting.limit.articleCount, 74 | select: '-content -renderedContent', 75 | populate: [ 76 | { 77 | path: 'category', 78 | select: 'name description extends' 79 | }, { 80 | path: 'tag', 81 | select: 'name description' 82 | } 83 | ] 84 | } 85 | const query = { state, source } 86 | 87 | // 搜索关键词 88 | if (keyword) { 89 | const keywordReg = new RegExp(keyword) 90 | query.$or = [ 91 | { title: keywordReg } 92 | ] 93 | } 94 | 95 | // 分类 96 | if (category) { 97 | // 如果是id 98 | if (app.utils.validate.isObjectId(category)) { 99 | query.category = category 100 | } else { 101 | // 普通字符串,需要先查到id 102 | const c = await this.service.category.getItem({ name: category }) 103 | query.category = c ? c._id : app.utils.share.createObjectId() 104 | } 105 | } 106 | 107 | // 标签 108 | if (tag) { 109 | // 如果是id 110 | if (app.utils.validate.isObjectId(tag)) { 111 | query.tag = tag 112 | } else { 113 | // 普通字符串,需要先查到id 114 | const t = await this.service.tag.getItem({ name: tag }) 115 | query.tag = t ? t._id : app.utils.share.createObjectId() 116 | } 117 | } 118 | 119 | // 未通过权限校验(前台获取文章列表) 120 | if (!ctx.session._isAuthed) { 121 | // 将文章状态重置为1 122 | query.state = 1 123 | // 文章列表不需要content和state 124 | options.select = '-content -renderedContent -state' 125 | } else { 126 | // 排序 127 | if (sortBy && order) { 128 | options.sort = {} 129 | options.sort[sortBy] = order 130 | } 131 | 132 | // 起始日期 133 | if (startDate) { 134 | const $gte = new Date(startDate) 135 | if ($gte.toString() !== 'Invalid Date') { 136 | query.createdAt = { $gte } 137 | } 138 | } 139 | 140 | // 结束日期 141 | if (endDate) { 142 | const $lte = new Date(endDate) 143 | if ($lte.toString() !== 'Invalid Date') { 144 | query.createdAt = Object.assign({}, query.createdAt, { $lte }) 145 | } 146 | } 147 | } 148 | const data = await this.service.article.getLimitListByQuery(ctx.processPayload(query), options) 149 | const statService = this.service.stat 150 | // 生成搜索统计 151 | if (query.category) { 152 | statService.record('CATEGORY_SEARCH', { category: query.category }, 'count') 153 | } 154 | if (query.tag) { 155 | statService.record('TAG_SEARCH', { tag: query.tag }, 'count') 156 | } 157 | if (keyword) { 158 | statService.record('KEYWORD_SEARCH', { keyword }, 'count') 159 | } 160 | data 161 | ? ctx.success(data, '文章列表获取成功') 162 | : ctx.fail('文章列表获取失败') 163 | } 164 | 165 | async item () { 166 | const { ctx } = this 167 | const params = ctx.validateParamsObjectId() 168 | const data = await this.service.article.getItemById(params.id) 169 | if (!this.ctx.session._isAuthed) { 170 | // 生成 pv 统计项 171 | this.service.stat.record('ARTICLE_VIEW', { article: params.id }, 'count') 172 | } 173 | data 174 | ? ctx.success(data, '文章详情获取成功') 175 | : ctx.fail('文章详情获取失败') 176 | } 177 | 178 | async create () { 179 | const { ctx } = this 180 | const body = ctx.validateBody(this.rules.create) 181 | const { REPRINT, TRANSLATE } = this.config.modelEnum.article.source.optional 182 | if ([REPRINT, TRANSLATE].find(item => item === body.source) && !body.from) { 183 | return ctx.fail(422, '缺少原文章链接') 184 | } 185 | if (body.createdAt) { 186 | body.createdAt = new Date(body.createdAt) 187 | } 188 | const exist = await this.service.article.getItem({ title: body.title }) 189 | if (exist) { 190 | return ctx.fail('文章名称重复') 191 | } 192 | const data = await this.service.article.create(body) 193 | if (data) { 194 | ctx.success(data, '文章创建成功') 195 | if (this.config.isProd) { 196 | this.service.seo.baiduSeo('push', `${this.config.author.url}/article/${data._id}`) 197 | } 198 | } else { 199 | ctx.fail('文章创建失败') 200 | } 201 | } 202 | 203 | async update () { 204 | const { ctx } = this 205 | const params = ctx.validateParamsObjectId() 206 | const body = ctx.validateBody(this.rules.update) 207 | const { REPRINT, TRANSLATE } = this.config.modelEnum.article.source.optional 208 | if ([REPRINT, TRANSLATE].find(item => item === body.source) && !body.from) { 209 | return ctx.fail(422, '缺少原文章链接') 210 | } 211 | if (body.createdAt) { 212 | body.createdAt = new Date(body.createdAt) 213 | } 214 | const exist = await this.service.article.getItem({ 215 | _id: { 216 | $ne: params.id 217 | }, 218 | title: body.title 219 | }) 220 | this.logger.info(exist); 221 | 222 | if (exist) { 223 | return ctx.fail('文章名称重复') 224 | } 225 | const data = await this.service.article.updateItemById( 226 | params.id, 227 | body, 228 | null, 229 | 'category tag' 230 | ) 231 | if (data) { 232 | ctx.success(data, '文章更新成功') 233 | if (this.config.isProd) { 234 | this.service.seo.baiduSeo('update', `${this.config.author.url}/article/${data._id}`) 235 | } 236 | } else { 237 | ctx.fail('文章更新失败') 238 | } 239 | } 240 | 241 | async delete () { 242 | const { ctx } = this 243 | const params = ctx.validateParamsObjectId() 244 | const data = await this.service.article.deleteItemById(params.id) 245 | if (data) { 246 | ctx.success(data, '文章删除成功') 247 | if (this.config.isProd) { 248 | this.service.seo.baiduSeo('delete', `${this.config.author.url}/article/${data._id}`) 249 | } 250 | } else { 251 | ctx.fail('文章删除失败') 252 | } 253 | } 254 | 255 | async like () { 256 | const { ctx } = this 257 | const params = ctx.validateParamsObjectId() 258 | const data = await this.service.article.updateItemById(params.id, { 259 | $inc: { 260 | 'meta.ups': 1 261 | } 262 | }) 263 | if (data) { 264 | if (!this.ctx.session._isAuthed) { 265 | // 生成like通告 266 | this.service.notification.recordLike('article', data, ctx.request.body.user, true) 267 | // 生成 like 统计项 268 | this.service.stat.record('ARTICLE_LIKE', { article: params.id }, 'count') 269 | } 270 | ctx.success('文章点赞成功') 271 | } else { 272 | ctx.fail('文章点赞失败') 273 | } 274 | } 275 | 276 | async unlike () { 277 | const { ctx } = this 278 | const params = ctx.validateParamsObjectId() 279 | const data = await this.service.article.updateItemById(params.id, { 280 | $inc: { 281 | 'meta.ups': -1 282 | } 283 | }) 284 | if (data) { 285 | // 生成unlike通告 286 | this.service.notification.recordLike('article', data, ctx.request.body.user, false) 287 | ctx.success('文章取消点赞成功') 288 | } else { 289 | ctx.fail('文章取消点赞失败') 290 | } 291 | } 292 | 293 | 294 | async archives () { 295 | this.ctx.success(await this.service.article.archives(), '归档获取成功') 296 | } 297 | 298 | async hot () { 299 | const { ctx } = this 300 | const limit = this.app.setting.limit.hotArticleCount 301 | const data = await this.service.article.getList( 302 | { 303 | state: this.config.modelEnum.article.state.optional.PUBLISH 304 | }, 305 | '-content -renderedContent -state', 306 | { 307 | sort: '-meta.comments -meta.ups -meta.pvs', 308 | limit 309 | } 310 | ) 311 | data 312 | ? ctx.success(data, '热门文章获取成功') 313 | : ctx.fail('热门文章获取失败') 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /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/comment.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 评论 Controller 3 | */ 4 | 5 | const { Controller } = require('egg') 6 | 7 | module.exports = class CommentController extends Controller { 8 | get rules () { 9 | return { 10 | list: { 11 | page: { type: 'int', required: true, min: 1 }, 12 | limit: { type: 'int', required: false, min: 1 }, 13 | state: { type: 'enum', values: Object.values(this.config.modelEnum.comment.state.optional), required: false }, 14 | type: { type: 'enum', values: Object.values(this.config.modelEnum.comment.type.optional), required: false }, 15 | author: { type: 'objectId', required: false }, 16 | article: { type: 'objectId', required: false }, 17 | parent: { type: 'objectId', required: false }, 18 | keyword: { type: 'string', required: false }, 19 | // 时间区间查询仅后台可用,且依赖于createdAt 20 | startDate: { type: 'string', required: false }, 21 | endDate: { type: 'string', required: false }, 22 | // 排序仅后台能用,且order和sortBy需同时传入才起作用 23 | // -1 desc | 1 asc 24 | order: { type: 'enum', values: [-1, 1], required: false }, 25 | sortBy: { type: 'enum', values: ['createdAt', 'updatedAt', 'ups'], required: false } 26 | }, 27 | create: { 28 | content: { type: 'string', required: true }, 29 | type: { type: 'enum', values: Object.values(this.config.modelEnum.comment.type.optional), required: true }, 30 | article: { type: 'objectId', required: false }, 31 | parent: { type: 'objectId', required: false }, 32 | forward: { type: 'objectId', required: false } 33 | }, 34 | update: { 35 | content: { type: 'string', required: false }, 36 | state: { type: 'enum', values: Object.values(this.config.modelEnum.comment.state.optional), required: false }, 37 | sticky: { type: 'boolean', required: false } 38 | } 39 | } 40 | } 41 | 42 | async list () { 43 | const { ctx } = this 44 | ctx.query.page = Number(ctx.query.page) 45 | if (ctx.query.limit) { 46 | ctx.query.limit = Number(ctx.query.limit) 47 | } 48 | const tranArray = ['limit', 'state', 'type', 'order'] 49 | tranArray.forEach(key => { 50 | if (ctx.query[key]) { 51 | ctx.query[key] = Number(ctx.query[key]) 52 | } 53 | }) 54 | ctx.validate(this.rules.list, ctx.query) 55 | const { page, limit, state, type, keyword, author, article, parent, order, sortBy, startDate, endDate } = ctx.query 56 | // 过滤条件 57 | const options = { 58 | sort: { 59 | createdAt: 1 60 | }, 61 | page, 62 | limit: limit || this.app.setting.limit.commentCount, 63 | select: '', 64 | populate: [ 65 | { 66 | path: 'author', 67 | select: !ctx.session._isAuthed ? 'github avatar name site' : '-password' 68 | }, 69 | { 70 | path: 'parent', 71 | select: 'author meta sticky ups', 72 | match: !ctx.session._isAuthed && { 73 | state: 1 74 | } || null 75 | }, 76 | { 77 | path: 'forward', 78 | select: 'author meta sticky ups', 79 | match: !ctx.session._isAuthed && { 80 | state: 1 81 | } || null, 82 | populate: { 83 | path: 'author', 84 | select: 'avatar github name' 85 | } 86 | } 87 | ] 88 | } 89 | 90 | // 查询条件 91 | const query = { state, type, author, article } 92 | 93 | // 搜索关键词 94 | if (keyword) { 95 | const keywordReg = new RegExp(keyword) 96 | query.$or = [ 97 | { title: keywordReg } 98 | ] 99 | } 100 | 101 | // 排序 102 | if (sortBy && order) { 103 | options.sort = {} 104 | options.sort[sortBy] = order 105 | } 106 | 107 | // 未通过权限校验(前台获取文章列表) 108 | if (!ctx.session._isAuthed) { 109 | // 将评论状态重置为1 110 | query.state = 1 111 | query.spam = false 112 | // 评论列表不需要content和state 113 | options.select = '-content -state -updatedAt -spam -type -meta.ip' 114 | 115 | if (parent) { 116 | // 获取子评论 117 | query.parent = parent 118 | } else { 119 | // 获取父评论 120 | query.parent = { $exists: false } 121 | } 122 | } else { 123 | options.sort.createdAt = -1 124 | options.populate.push({ 125 | path: 'article', 126 | select: '-renderedContent -content' 127 | }) 128 | // 起始日期 129 | if (startDate) { 130 | const $gte = new Date(startDate) 131 | if ($gte.toString() !== 'Invalid Date') { 132 | query.createdAt = { $gte } 133 | } 134 | } 135 | 136 | // 结束日期 137 | if (endDate) { 138 | const $lte = new Date(endDate) 139 | if ($lte.toString() !== 'Invalid Date') { 140 | query.createdAt = Object.assign({}, query.createdAt, { $lte }) 141 | } 142 | } 143 | } 144 | const data = await this.service.comment.getLimitListByQuery(ctx.processPayload(query), options) 145 | const commentType = this.config.modelEnum.comment.type.optional.COMMENT 146 | const typeText = type === commentType ? '评论' : '留言' 147 | if (!data) { 148 | return ctx.fail(typeText + '列表获取失败') 149 | } 150 | 151 | let { list, pageInfo } = data 152 | list = await Promise.all( 153 | list.map(async doc => { 154 | doc.subCount = 0 155 | const count = await this.service.comment.count({ parent: doc._id }) 156 | doc.subCount = count 157 | return doc 158 | }) 159 | ) 160 | ctx.success({ list, pageInfo }, typeText + '列表获取成功') 161 | } 162 | 163 | async item () { 164 | const { ctx } = this 165 | const params = ctx.validateParamsObjectId() 166 | const data = await this.service.comment.getItemById(params.id) 167 | data 168 | ? ctx.success(data, '评论详情获取成功') 169 | : ctx.fail('评论详情获取失败') 170 | } 171 | 172 | async create () { 173 | const { ctx } = this 174 | ctx.validateCommentAuthor() 175 | const body = ctx.validateBody(this.rules.create) 176 | const { COMMENT, MESSAGE } = this.config.modelEnum.comment.type.optional 177 | body.author = ctx.request.body.author 178 | const { article, parent, forward, type, content, author } = body 179 | if (type === COMMENT) { 180 | if (!article) { 181 | return ctx.fail(422, '缺少文章ID') 182 | } 183 | } else if (type === MESSAGE) { 184 | // 站内留言 185 | delete body.article 186 | } 187 | if ((parent && !forward) || (!parent && forward)) { 188 | return ctx.fail(422, '缺少父评论ID或被回复评论ID') 189 | } 190 | const { user, error } = await this.service.user.checkCommentAuthor(author) 191 | if (!user) { 192 | return ctx.fail(error) 193 | } else if (user.mute) { 194 | // 被禁言 195 | return ctx.fail('你已被禁言,请联系管理员解禁') 196 | } 197 | body.author = user._id 198 | const spamValid = await this.service.user.checkUserSpam(user) 199 | if (!spamValid) { 200 | return ctx.fail('该用户的垃圾评论数量已达到最大限制,已被禁言') 201 | } 202 | // 永链 203 | const permalink = this.service.comment.getPermalink(body) 204 | const { ip, location } = await ctx.getLocation() 205 | const meta = body.meta = { 206 | location, 207 | ip, 208 | ua: ctx.req.headers['user-agent'] || '', 209 | referer: ctx.req.headers.referer || '' 210 | } 211 | if (this.config.isProd) { 212 | const isSpam = await this.service.akismet.checkSpam({ 213 | user_ip: ip, 214 | user_agent: meta.ua, 215 | referrer: meta.referer, 216 | permalink, 217 | comment_type: this.service.comment.getCommentType(type), 218 | comment_author: user.name, 219 | comment_author_email: user.email, 220 | comment_author_url: user.site, 221 | comment_content: content, 222 | is_test: !this.config.isProd 223 | }) 224 | // 如果是Spam评论 225 | if (isSpam) { 226 | this.logger.warn('检测为垃圾评论,禁止发布') 227 | return ctx.fail('检测为垃圾评论,请修改后在提交') 228 | } 229 | this.logger.info('评论检测正常,可以发布') 230 | } 231 | body.renderedContent = this.app.utils.markdown.render(body.content, true) 232 | const comment = await this.service.comment.create(body) 233 | if (comment) { 234 | const data = await this.service.comment.getItemById(comment._id) 235 | if (data.type === COMMENT) { 236 | // 如果是文章评论,则更新文章评论数量 237 | this.service.article.updateCommentCount(data.article._id) 238 | } 239 | if (this.config.isProd) { 240 | // 发送邮件通知站主和被评论者 241 | this.service.comment.sendCommentEmailToAdminAndUser(data, !ctx.session._isAuthed) 242 | } 243 | // 生成通告 244 | this.service.notification.recordComment(comment, 'create') 245 | ctx.success(data, data.type === COMMENT ? '评论发布成功' : '留言发布成功') 246 | } else { 247 | ctx.fail('发布失败') 248 | } 249 | } 250 | 251 | async update () { 252 | const { ctx } = this 253 | const { params } = ctx 254 | ctx.validateParamsObjectId() 255 | if (!ctx.session._isAuthed) { 256 | ctx.validateCommentAuthor() 257 | } 258 | const body = ctx.validateBody(this.rules.update) 259 | const exist = await this.service.comment.getItemById(params.id) 260 | if (!exist) { 261 | return ctx.fail('评论不存在') 262 | } 263 | if (!ctx.session._isAuthed && ctx.session._user._id !== exist.author._id) { 264 | return ctx.fail('非本人评论不能修改') 265 | } 266 | const permalink = this.service.comment.getPermalink(exist) 267 | const opt = { 268 | user_ip: exist.meta.ip, 269 | user_agent: exist.meta.ua, 270 | referrer: exist.meta.referer, 271 | permalink, 272 | comment_type: this.service.comment.getCommentType(exist.type), 273 | comment_author: exist.author.name, 274 | comment_author_email: exist.author.email, 275 | comment_author_url: exist.author.site, 276 | comment_content: exist.content, 277 | is_test: !this.config.isProd 278 | } 279 | if (body.content) { 280 | const isSpam = await this.service.akismet.checkSpam(opt) 281 | // 如果是Spam评论 282 | if (isSpam) { 283 | this.logger.warn('检测为垃圾评论,禁止发布') 284 | return ctx.fail('检测为垃圾评论,不能更新') 285 | } 286 | this.logger.info('评论检测正常,可以更新') 287 | body.renderedContent = this.app.utils.markdown.render(body.content, true) 288 | } 289 | // 状态修改是涉及到spam修改 290 | if (body.state !== undefined) { 291 | const SPAM = this.config.modelEnum.comment.state.optional.SPAM 292 | if (exist.state === SPAM && body.state !== SPAM) { 293 | // 垃圾评论转为正常评论 294 | if (exist.spam) { 295 | body.spam = false 296 | // 异步报告给Akismet 297 | this.app.akismet.submitSpam(opt) 298 | } 299 | } else if (exist.state !== SPAM && body.state === SPAM) { 300 | // 正常评论转为垃圾评论 301 | if (!exist.spam) { 302 | body.spam = true 303 | // 异步报告给Akismet 304 | this.app.akismet.submitHam(opt) 305 | } 306 | } 307 | } 308 | let data = null 309 | if (!ctx.session._isAuthed) { 310 | data = await this.service.comment.updateItemById( 311 | params.id, 312 | body, 313 | null, 314 | [ 315 | { 316 | path: 'author', 317 | select: 'github' 318 | }, { 319 | path: 'parent', 320 | select: 'author meta sticky ups' 321 | }, { 322 | path: 'forward', 323 | select: 'author meta sticky ups' 324 | } 325 | ] 326 | ) 327 | } else { 328 | data = await this.service.comment.updateItemById(params.id, body) 329 | } 330 | if (data) { 331 | if (data.type === this.config.modelEnum.comment.type.optional.COMMENT) { 332 | // 异步 如果是文章评论,则更新文章评论数量 333 | this.service.article.updateCommentCount(data.article._id) 334 | } 335 | // 生成通告 336 | this.service.notification.recordComment(data, 'update') 337 | ctx.success(data, '评论更新成功') 338 | } else { 339 | ctx.fail('评论更新失败') 340 | } 341 | } 342 | 343 | async delete () { 344 | const { ctx } = this 345 | const params = ctx.validateParamsObjectId() 346 | const data = await this.service.comment.deleteItemById(params.id) 347 | if (data.type === this.config.modelEnum.comment.type.optional.COMMENT) { 348 | // 异步 如果是文章评论,则更新文章评论数量 349 | this.service.article.updateCommentCount(data.article._id) 350 | } 351 | data 352 | ? ctx.success('评论删除成功') 353 | : ctx.fail('评论删除失败') 354 | } 355 | 356 | async like () { 357 | const { ctx } = this 358 | const params = ctx.validateParamsObjectId() 359 | const data = await this.service.comment.updateItemById(params.id, { 360 | $inc: { 361 | ups: 1 362 | } 363 | }) 364 | if (data) { 365 | // 生成评论点赞通告 366 | this.service.notification.recordLike('comment', data, ctx.request.body.user, true) 367 | ctx.success('评论点赞成功') 368 | } else { 369 | ctx.fail('评论点赞失败') 370 | } 371 | } 372 | 373 | async unlike () { 374 | const { ctx } = this 375 | const params = ctx.validateParamsObjectId() 376 | const data = await this.service.comment.updateItemById(params.id, { 377 | $inc: { 378 | ups: -1 379 | } 380 | }) 381 | if (data) { 382 | // 生成评论unlike通告 383 | this.service.notification.recordLike('comment', data, ctx.request.body.user, false) 384 | ctx.success('评论取消点赞成功') 385 | } else { 386 | ctx.fail('评论取消点赞失败') 387 | } 388 | } 389 | } 390 | -------------------------------------------------------------------------------- /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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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/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/lib/plugin/egg-akismet/agent.js: -------------------------------------------------------------------------------- 1 | module.exports = agent => { 2 | if (agent.config.akismet.agent) require('./lib/akismet')(agent) 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-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/lib/plugin/egg-akismet/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "eggPlugin": { 3 | "name": "akismet" 4 | } 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/app.js: -------------------------------------------------------------------------------- 1 | module.exports = app => { 2 | if (app.config.mailer.app) require('./lib/mailer')(app) 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-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/lib/plugin/egg-mailer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "eggPlugin": { 3 | "name": "mailer" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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/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/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/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/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 | -------------------------------------------------------------------------------- /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/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/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/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} 解析后的歌词 224 | */ 225 | function parseLyric (lrc, tlrc) { 226 | if (!lrc) return [] 227 | function parse (text) { 228 | if (!text) text = '' 229 | return text.split('\n').reduce((prev, line) => { 230 | const match = lrcTimeReg.exec(line) 231 | if (match) { 232 | const time = parseTime(match[1]) 233 | prev[time] = line.replace(lrcTimeReg, '') || '~~~' 234 | } 235 | return prev 236 | }, {}) 237 | } 238 | 239 | const lrcParsed = parse(lrc) 240 | const tlrcParsed = parse(tlrc) 241 | 242 | return Object.keys(lrcParsed) 243 | .map(time => parseFloat(time)) 244 | .sort((a, b) => a - b) 245 | .reduce((prev, time) => { 246 | prev.push({ 247 | time, 248 | lrc: lrcParsed[time], 249 | tlrc: tlrcParsed[time] || '' 250 | }) 251 | return prev 252 | }, []) 253 | } 254 | 255 | function parseTime (time) { 256 | time = time.split(':') 257 | if (time.length !== 2) { 258 | return 0 259 | } 260 | const minutes = parseInt(time[0]) 261 | const seconds = parseFloat(time[1]) 262 | return minutes * 60 + seconds 263 | } 264 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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/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 `${text}` 29 | } 30 | 31 | renderer.link = function (href, title, text) { 32 | const isOrigin = href.indexOf('jooger.me') > -1 33 | const isImage = /(/gi.test(text) 34 | return ` 35 | ${text} 41 | `.replace(/\s+/g, ' ').replace('\n', '') 42 | } 43 | 44 | renderer.image = function (href, title, text) { 45 | const _title = title || text || '' 46 | return ` 47 |

48 | ${text || title || href} 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 ? '
' : ''}`.replace(/\s+/g, ' ')).join('') 90 | 91 | // if (!lang) { 92 | // return '
' +
 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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------