├── . 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 | [](https://github.com/jo0ger/node-server/network) 13 | [](https://github.com/jo0ger/node-server/stargazers) 14 | [](https://github.com/jo0ger/node-server/issues) 15 | [](https://github.com/jo0ger/node-server/commits/master) 16 | 17 | RESTful API server application for my blog 18 | 19 | * Web client for user: [jooger.me]([C-CLIENT]) powered by [Nuxt.js@2](https://github.com/nuxt/nuxt.js) and [TypeScript](https://github.com/Microsoft/TypeScript) 20 | * Web client for admin: vue-admin powered by Vue and iview 21 | * Server client: [api.jooger.me]([S-CLIENT]) powered by [Egg](https://github.com/eggjs/egg) and mongodb 22 | 23 | ## Quick Start 24 | 25 | ### Environment Dependencies 26 | 27 | - [redis](https://redis.io/) 28 | - [mongodb](https://www.mongodb.com/) 29 | 30 | ### Development 31 | 32 | Please make sure they are configured the same as `config/config.default.js` 33 | 34 | ``` bash 35 | $ yarn 36 | 37 | $ yarn dev 38 | 39 | $ open http://localhost:7001/ 40 | ``` 41 | 42 | ### Deploy 43 | 44 | ```bash 45 | $ npm start 46 | $ npm stop 47 | ``` 48 | 49 | ### npm scripts 50 | 51 | - Use `npm run lint` to check code style. 52 | - Use `npm test` to run unit test. 53 | - Use `npm run autod` to auto detect dependencies upgrade, see [autod](https://www.npmjs.com/package/autod) for more detail. 54 | 55 | ### Develop / Deploy with Docker 56 | 57 | #### Requirements 58 | 59 | * docker 60 | * docker-compose 61 | 62 | #### Config 63 | 64 | ##### docker-compose config 65 | 66 | * development: docker-compose.dev.yml 67 | * production: docker-compose.yml 68 | 69 | ##### Change port 70 | 71 | ``` yml 72 | version: "3" 73 | services: 74 | node-server: 75 | ports: 76 | - ${HOST PORT}:7001 77 | ``` 78 | 79 | #### Develop 80 | 81 | ``` bash 82 | # start 83 | $ docker-compose -f docker-compose.dev.yml up 84 | 85 | # stop 86 | $ docker-compose -f docker-compose.dev.yml down 87 | 88 | # stop and remove valume/cache 89 | $ docker-compose -f docker-compose.dev.yml down -v 90 | ``` 91 | 92 | #### Deploy 93 | 94 | ``` bash 95 | # start 96 | $ docker-compose up -d 97 | 98 | # stop 99 | $ docker-compose down 100 | 101 | # stop and remove volume/cache 102 | $ docker-compose down -v 103 | ``` 104 | 105 | ## CHANGELOG 106 | 107 | ### v2.2.3 108 | 109 | * fix: “一言” 接口修复 110 | 111 | ### v2.2.2 112 | 113 | * fix: 后台管理在获取评论列表时把子评论过滤掉了 114 | 115 | ### v2.2.1 116 | 117 | * fix: 备份数据上传失败会邮件通知管理员 118 | * fix: 垃圾评论检测时机有问题 119 | * fix: 文章评论数统计未区分评论状态 120 | 121 | ### v2.2.0 122 | 123 | * feat: 新增管理员检测的接口 124 | * feat: 新增C端公告的接口 125 | * feat: 定时任务新增数据库备份任务,配合jenkins进行数据备份 126 | * feat: 歌单歌曲新增歌词 127 | * fix: 配置里更新歌单ID时,未更新redis缓存 128 | * fix: 评论IP获取错误 129 | * fix: 评论的新用户重复创建 130 | * fix: 歌单定时任务里报undefined错误(因为未考虑抓取失败场景) 131 | 132 | ### v2.1.0 133 | 134 | 2018-11-03 135 | 136 | * feat: 评论&留言的邮件通知支持自定义模板 137 | * feat: 添加音乐接口,支持网易云音乐 138 | * feat: voice支持redis缓存 139 | * refactor: 移除reponse的中间件,添加到context的extend中 140 | 141 | ### v2.0.3 142 | 143 | 2018-10-13 144 | 145 | * fix: marked开启sanitize 146 | * fix: marked渲染图片时title错误 147 | * fix: 统计数据-总数统计错误,添加情况分类 148 | * fix: voice获取失败情况处理 149 | 150 | 151 | ### v2.0.2 152 | 153 | 2018-10-12 154 | 155 | * fix: github获取用户信息时clientID和clientSecret错误 156 | * fix: add marked sanitize control 157 | * fix: archive接口的月维度数据排序错误 158 | * fix: 关联文章排序错误 159 | 160 | ### v2.0.1 161 | 162 | 2018-10-09 163 | 164 | * fix: 获取context的ip错误 165 | * chore: docker添加logs的volume 166 | 167 | ### v2.0.0 168 | 169 | 2018-10-07 170 | 171 | * 框架:用Egg重构 172 | * Model层 173 | - article增加原创、转载字段 174 | - 新增notification站内通知和stat站内统计模型 175 | - user简化,去掉不必要字段 176 | - setting重构,分类型 177 | * 接口 178 | - 新增voice接口获取一些心灵鸡汤文字 179 | - 新增ip接口查询ip 180 | * 服务 181 | - ip查询优先阿里云IP查询,geoip-lite为降级 182 | - 定时任务换成egg的schedule 183 | - model proxy重构 184 | - 业务逻辑拆分,每个model都有其对应的service层 185 | - admin user和setting初始化流程变更 186 | - 完善的日志系统 187 | * addon 188 | - 接入sentry 189 | - docker支持 190 | - 增加release tag 191 | 192 | 193 | ### v1.1.0 194 | 195 | * 文章归档api(2018.01.04) 196 | * Model代理 (2018.01.28) 197 | * ESlint (2018.02.01 198 | 199 | ### v1.0.0 200 | 201 | * 音乐api (2017.9.26) 202 | * Github oauth 代理 (2017.9.28) 203 | * 文章分类api (2017.10.26) 204 | * Redis缓存部分数据 (2017.10.27 v1.1) 205 | * 评论api (2017.10.28) 206 | * 评论定位 [geoip](https://github.com/bluesmoon/node-geoip) (2017.10.29) 207 | * 垃圾评论过滤 [akismet](https://github.com/chrisfosterelli/akismet-api) (2017.10.29) 208 | * 用户禁言 (2017.10.29) 209 | * 评论发送邮件 [nodemailer](https://github.com/nodemailer/nodemailer) (2017.10.29) 210 | * GC优化 (2017.10.30,linux下需要预先安装g++, **已废弃**) 211 | * 个人动态api (2017.10.30) 212 | 213 | 214 | -------------------------------------------------------------------------------- /app.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
48 |
53 | ${_title ? `
《${_title}》
` : ''} 54 | 55 | `.replace(/\s+/g, ' ').replace('\n', '') 56 | } 57 | 58 | renderer.code = function (code, lang, escaped) { 59 | if (this.options.highlight) { 60 | const out = this.options.highlight(code, lang); 61 | if (out != null && out !== code) { 62 | escaped = true; 63 | code = out; 64 | } 65 | } 66 | 67 | if (!lang) { 68 | return ''
69 | + (escaped ? code : escape(code, true))
70 | + '
';
71 | }
72 |
73 | return ''
77 | + (escaped ? code : escape(code, true))
78 | + '
\n';
79 | }
80 | // renderer.code = function (code, lang) {
81 | // if (this.options.highlight) {
82 | // const out = this.options.highlight(code, lang)
83 | // if (out != null && out !== code) {
84 | // code = out
85 | // }
86 | // }
87 |
88 | // const lineCode = code.split('\n')
89 | // const codeWrapper = lineCode.map((line, index) => `${line}${index !== lineCode.length - 1 ? '' +
93 | // codeWrapper +
94 | // '\n
'
95 | // }
96 |
97 | // return '' +
101 | // codeWrapper +
102 | // '\n
\n'
103 | // }
104 |
105 | marked.setOptions({
106 | renderer,
107 | gfm: true,
108 | pedantic: false,
109 | sanitize: false,
110 | tables: true,
111 | breaks: true,
112 | headerIds: true,
113 | smartLists: true,
114 | smartypants: true,
115 | highlight (code, lang) {
116 | if (languages.indexOf(lang) < 0) {
117 | return highlight.highlightAuto(code).value
118 | }
119 | return highlight.highlight(lang, code).value
120 | }
121 | })
122 |
123 | function escape (html, encode) {
124 | return html
125 | .replace(!encode ? /&(?!#?\w+;)/g : /&/g, '&')
126 | .replace(//g, '>')
128 | .replace(/"/g, '"')
129 | .replace(/'/g, ''')
130 | }
131 |
132 | exports.render = (text, sanitize = false) => {
133 | marked.setOptions({ sanitize })
134 | return marked(text)
135 | }
136 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------