diff --git a/app/controller/topic.js b/app/controller/topic.js index b5a92afb..864791c3 100644 --- a/app/controller/topic.js +++ b/app/controller/topic.js @@ -2,7 +2,6 @@ const Controller = require('egg').Controller; const _ = require('lodash'); -// const validator = require('validator'); const path = require('path'); const fs = require('fs'); const awaitWriteStream = require('await-stream-ready').write; @@ -102,60 +101,48 @@ class TopicController extends Controller { async put() { const { ctx, service } = this; const { tabs } = this.config; - const title = ctx.request.body.title.trim(); - const tab = ctx.request.body.tab.trim(); - const content = ctx.request.body.t_content.trim(); + const { body } = ctx.request; // 得到所有的 tab, e.g. ['ask', 'share', ..] - const allTabs = tabs.map(function(tPair) { - return tPair[0]; - }); - - // 验证 - let editError; - if (title === '') { - editError = '标题不能是空的。'; - } else if (title.length < 5 || title.length > 100) { - editError = '标题字数太多或太少。'; - } else if (!tab || allTabs.indexOf(tab) === -1) { - editError = '必须选择一个版块。'; - } else if (content === '') { - editError = '内容不可为空。'; - } - // END 验证 - - if (editError) { - ctx.status = 422; - await ctx.render('topic/edit', { - edit_error: editError, - title, - content, - tabs, - }); - return; - } + const allTabs = tabs.map(tPair => tPair[0]); + + // 使用 egg_validate 验证 + // TODO: 此处可以优化,将所有使用egg_validate的rules集中管理,避免即时新建对象 + const RULE_CREATE = { + title: { + type: 'string', + max: 100, + min: 5, + }, + content: { + type: 'string', + }, + tab: { + type: 'enum', + values: allTabs, + }, + }; + ctx.validate(RULE_CREATE, ctx.request.body); // 储存新主题帖 const topic = await service.topic.newAndSave( - title, - content, - tab, + body.title, + body.content, + body.tab, ctx.user._id ); // 发帖用户增加积分,增加发表主题数量 await service.user.incrementScoreAndReplyCount(topic.author_id, 5, 1); - ctx.redirect('/topic/' + topic._id); - // 通知被@的用户 await service.at.sendMessageToMentionUsers( - content, + body.content, topic._id, ctx.user._id ); - await ctx.redirect('/topic/' + topic._id); + ctx.redirect('/topic/' + topic._id); } /** @@ -198,9 +185,7 @@ class TopicController extends Controller { const { ctx, service, config } = this; const topic_id = ctx.params.tid; - let title = ctx.request.body.title; - let tab = ctx.request.body.tab; - let content = ctx.request.body.t_content; + let { title, tab, content } = ctx.request.body; const { topic } = await service.topic.getTopicById(topic_id); if (!topic) { diff --git a/app/middleware/limit.js b/app/middleware/limit.js deleted file mode 100644 index 00192762..00000000 --- a/app/middleware/limit.js +++ /dev/null @@ -1,64 +0,0 @@ -// 'use strict'; -// -// const config = require('../config'); -// const cache = require('../common/cache'); -// const moment = require('moment'); -// -// const SEPARATOR = '^_^@T_T'; -// -// const makePerDayLimiter = function(identityName, identityFn) { -// return function(name, limitCount, options) { -// /* -// options.showJson = true 表示调用来自API并返回结构化数据;否则表示调用来自前段并渲染错误页面 -// */ -// return function(req, res, next) { -// const identity = identityFn(req); -// const YYYYMMDD = moment().format('YYYYMMDD'); -// const key = YYYYMMDD + -// SEPARATOR + -// identityName + -// SEPARATOR + -// name + -// SEPARATOR + -// identity; -// -// cache.get(key, function(err, count) { -// if (err) { -// return next(err); -// } -// count = count || 0; -// if (count < limitCount) { -// count += 1; -// cache.set(key, count, 60 * 60 * 24); -// res.set('X-RateLimit-Limit', limitCount); -// res.set('X-RateLimit-Remaining', limitCount - count); -// next(); -// } else { -// res.status(403); -// if (options.showJson) { -// res.send({ -// success: false, -// error_msg: '频率限制:当前操作每天可以进行 ' + limitCount + ' 次', -// }); -// } else { -// res.render('notify/notify', { -// error: '频率限制:当前操作每天可以进行 ' + limitCount + ' 次', -// }); -// } -// } -// }); -// }; -// }; -// }; -// -// exports.peruserperday = makePerDayLimiter('peruserperday', function(req) { -// return (req.user || req.session.user).loginname; -// }); -// -// exports.peripperday = makePerDayLimiter('peripperday', function(req) { -// const realIP = req.get('x-real-ip'); -// if (!realIP) { -// throw new Error('should provice `x-real-ip` header'); -// } -// return realIP; -// }); diff --git a/app/middleware/topic_per_day_limit.js b/app/middleware/topic_per_day_limit.js new file mode 100644 index 00000000..cd611244 --- /dev/null +++ b/app/middleware/topic_per_day_limit.js @@ -0,0 +1,29 @@ +'use strict'; +const moment = require('moment'); + +module.exports = ({ perDayPerUserLimitCount = 10 }) => { + + return async function(ctx, next) { + const { user, service } = ctx; + const YYYYMMDD = moment().format('YYYYMMDD'); + const key = `topics_count_${user._id}_${YYYYMMDD}`; + + let todayTopicsCount = (await service.cache.get(key)) || 0; + if (todayTopicsCount >= perDayPerUserLimitCount) { + ctx.status = 403; + await ctx.render('notify/notify', + { error: `今天的话题发布数量已达到限制(${perDayPerUserLimitCount})` }); + return; + } + + await next(); + + if (ctx.status === 302) { + // 新建话题成功 + todayTopicsCount += 1; + await service.cache.incr(key, 60 * 60 * 24); + ctx.set('X-RateLimit-Limit', perDayPerUserLimitCount); + ctx.set('X-RateLimit-Remaining', perDayPerUserLimitCount - todayTopicsCount); + } + }; +}; diff --git a/app/router.js b/app/router.js index 77140894..f500e070 100644 --- a/app/router.js +++ b/app/router.js @@ -10,6 +10,7 @@ module.exports = app => { const userRequired = middleware.userRequired(); const adminRequired = middleware.adminRequired(); + const topicPerDayLimit = middleware.topicPerDayLimit(config.topic); // home page router.get('/', site.index); @@ -78,9 +79,7 @@ module.exports = app => { router.post('/topic/:tid/delete', userRequired, topic.delete); // // 保存新建的文章 - // router.post('/topic/create', userRequired, limit.peruserperday('create_topic', config.create_post_per_day, { showJson: false }), topic.put); - - router.post('/topic/create', userRequired, topic.put); + router.post('/topic/create', userRequired, topicPerDayLimit, topic.put); router.post('/topic/:tid/edit', userRequired, topic.update); router.post('/topic/collect', userRequired, topic.collect); // 关注某话题 diff --git a/app/view/topic/edit.html b/app/view/topic/edit.html index 5ca34ae9..dbb0466c 100644 --- a/app/view/topic/edit.html +++ b/app/view/topic/edit.html @@ -53,7 +53,7 @@
- diff --git a/config/config.default.js b/config/config.default.js index c38fee40..d44cc40c 100644 --- a/config/config.default.js +++ b/config/config.default.js @@ -137,5 +137,9 @@ module.exports = appInfo => { secret: process.env.EGG_ALINODE_SECRET || '', }; + config.topic = { + perDayPerUserLimitCount: 10, + }; + return config; }; diff --git a/config/plugin.js b/config/plugin.js index 074d0aa7..f6e8c2fb 100644 --- a/config/plugin.js +++ b/config/plugin.js @@ -38,3 +38,8 @@ exports.alinode = { package: 'egg-alinode', env: [ 'prod' ], }; + +exports.validate = { + enable: true, + package: 'egg-validate', +}; diff --git a/package.json b/package.json index 7b6b7c75..07132923 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "egg-passport-local": "^1.2.1", "egg-redis": "^2.0.0", "egg-scripts": "^2.5.0", + "egg-validate": "^1.0.0", "egg-view-ejs": "^2.0.0", "loader": "^2.1.1", "loader-koa": "^2.0.1", diff --git a/test/app/controller/topic.test.js b/test/app/controller/topic.test.js index 2b1fa291..d0d0d769 100644 --- a/test/app/controller/topic.test.js +++ b/test/app/controller/topic.test.js @@ -92,51 +92,54 @@ describe('test/app/controller/topic.test.js', () => { await app.httpRequest().get(`/topic/${topicId}/edit`).expect(200); }); - it('should POST /topic/create ok', async () => { - const body = { - title: '', - tab: '', - t_content: '', - }; + it('should POST /topic/create forbidden', async () => { + app.mockCsrf(); + await app.httpRequest().post('/topic/create').expect(403); + }); + it('should POST /topic/create forbidden', async () => { mockUser(); + app.mockCsrf(); + await app.httpRequest().post('/topic/create') + .send({ + invalid_field: 'not make sense', + }) + .expect(422); + }); - const r1 = await app - .httpRequest() - .post('/topic/create') - .send(body); - assert(r1.text.includes('标题不能是空的。')); - - body.title = 'hi'; - const r2 = await app - .httpRequest() - .post('/topic/create') - .send(body); - assert(r2.text.includes('标题字数太多或太少。')); - - body.title = '这是一个大标题'; - const r4 = await app - .httpRequest() - .post('/topic/create') - .send(body); - assert(r4.text.includes('必须选择一个版块。')); - - body.tab = 'share'; - const r3 = await app - .httpRequest() - .post('/topic/create') - .send(body); - assert(r3.text.includes('内容不可为空。')); - - body.t_content = 'hi'; - - await app - .httpRequest() - .post('/topic/create') - .send(body) + it('should POST /topic/create ok', async () => { + mockUser(); + app.mockCsrf(); + await app.httpRequest().post('/topic/create') + .send({ + tab: 'share', + title: 'topic测试标题', + content: 'topic test topic content', + }) .expect(302); }); + it('should POST /topic/create per day limit works', async () => { + mockUser(); + app.mockCsrf(); + for (let i = 0; i < 9; i++) { + await app.httpRequest().post('/topic/create') + .send({ + tab: 'share', + title: `topic测试标题${i + 1}`, + content: 'topic test topic content', + }) + .expect(302); + } + await app.httpRequest().post('/topic/create') + .send({ + tab: 'share', + title: 'topic测试标题11', + content: 'topic test topic content', + }) + .expect(403); + }); + it('should POST /topic/:tid/top ok', async () => { mockUser(); const res = await app.httpRequest().post(`/topic/${topicId}/top`); @@ -168,7 +171,7 @@ describe('test/app/controller/topic.test.js', () => { const body = { title: '', tab: '', - t_content: '', + content: '', }; fakeUser(); @@ -212,7 +215,7 @@ describe('test/app/controller/topic.test.js', () => { .send(body); assert(r3.text.includes('内容不可为空。')); - body.t_content = 'hi'; + body.content = 'hi'; await app .httpRequest() .post(`/topic/${topicId}/edit`)