Links: <%-page.link%>
+ <%- config.copyright %> + <%- config.ad %> +diff --git a/app.js b/app.js index 5c3abe7..353b198 100644 --- a/app.js +++ b/app.js @@ -1,17 +1,25 @@ const express = require('express'); const path = require('path'); +const session = require('express-session'); +const flash = require('connect-flash'); +const rateLimit = require('express-rate-limit'); +const compression = require('compression'); const cookieParser = require('cookie-parser'); const logger = require('morgan'); const http = require('http'); +const serveStatic = require('serve-static'); const config = require('./config'); - const indexRouter = require('./routers/index'); const messageRouter = require('./routers/message'); +const userRouter = require('./routers/user'); const { refreshToken } = require('./common/wechat'); const { initializeTokenStore } = require('./common/token'); const app = express(); +app.locals.isLogged = false; +app.locals.isAdmin = false; + setTimeout(async () => { // TODO: Here we need an improvement! I have tried EventEmitter but it's not working. :( await initializeTokenStore(); @@ -23,14 +31,49 @@ app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'ejs'); app.set('trust proxy', true); +app.use( + rateLimit({ + windowMs: 30 * 1000, + max: 30, + }) +); +app.use( + '/login', + rateLimit({ + windowMs: 60 * 1000, + max: 5, + }) +); +app.use(compression()); app.use(logger('dev')); app.use(express.json()); app.use(express.urlencoded({ extended: false })); -app.use(cookieParser()); +app.use(cookieParser(config.cookie_secret)); +app.use( + session({ + resave: true, + saveUninitialized: true, + secret: config.session_secret, + }) +); +app.use(flash()); app.use(express.static(path.join(__dirname, 'public'))); - -app.use('/', indexRouter); +app.use( + '/public', + serveStatic(path.join(__dirname, `public`), { + maxAge: '600000', + }) +); +app.use('*', (req, res, next) => { + if (req.session.user !== undefined) { + res.locals.isLogged = true; + res.locals.isAdmin = req.session.user.isAdmin; + } + next(); +}); app.use('/message', messageRouter); +app.use('/', indexRouter); +app.use('/', userRouter); const server = http.createServer(app); diff --git a/common/message.js b/common/message.js index 0314cec..9bcd434 100644 --- a/common/message.js +++ b/common/message.js @@ -1,24 +1,30 @@ -const { pushWechatMessage } = require('./wechat'); +const { pushWeChatMessage } = require('./wechat'); +const Message = require('../models/message').Message; + +async function processMessage(userPrefix, message) { + if (message.content) { + message = await Message.create(message); + } + let result = { + success: false, + message: `unsupported message type ${message.type}`, + }; + switch (message.type) { + case '0': // WeChat message + result = await pushWeChatMessage(userPrefix, message); + break; + case '1': // Email message + // TODO: Email message + break; + case '2': // HTTP GET request + // TODO: HTTP GET request + break; + default: + break; + } + return result; +} module.exports = { - async processMessage(userPrefix, message) { - let result = { - success: false, - message: `unsupported message type ${message.type}`, - }; - switch (message.type) { - case '0': // WeChat message - result = await pushWechatMessage(userPrefix, message); - break; - case '1': // Email message - // TODO: Email message - break; - case '2': // HTTP GET request - // TODO: HTTP GET request - break; - default: - break; - } - return result; - }, + processMessage, }; diff --git a/common/token.js b/common/token.js index dbb819f..8d60a4e 100644 --- a/common/token.js +++ b/common/token.js @@ -11,6 +11,7 @@ async function initializeTokenStore() { appSecret: user.wechatAppSecret, templateId: user.wechatTemplateId, openId: user.wechatOpenId, + serverVerifyToken: user.wechatVerifyToken, token: '', }); } diff --git a/common/utils.js b/common/utils.js deleted file mode 100644 index a3f206f..0000000 --- a/common/utils.js +++ /dev/null @@ -1,33 +0,0 @@ -function formatTime(format) { - if (format === undefined) format = 'yyyy-MM-dd hh:mm:ss'; - const date = new Date(); - const o = { - 'M+': date.getMonth() + 1, - 'd+': date.getDate(), - 'h+': date.getHours(), - 'm+': date.getMinutes(), - 's+': date.getSeconds(), - S: date.getMilliseconds(), - }; - - if (/(y+)/.test(format)) { - format = format.replace( - RegExp.$1, - (date.getFullYear() + '').substr(4 - RegExp.$1.length) - ); - } - - for (let k in o) { - if (new RegExp('(' + k + ')').test(format)) { - format = format.replace( - RegExp.$1, - RegExp.$1.length === 1 ? o[k] : ('00' + o[k]).substr(('' + o[k]).length) - ); - } - } - return format; -} - -module.exports = { - formatTime, -}; diff --git a/common/wechat.js b/common/wechat.js index fc8238c..471339b 100644 --- a/common/wechat.js +++ b/common/wechat.js @@ -28,16 +28,19 @@ async function requestToken(appId, appSecret) { async function pushWeChatMessage(userPrefix, message) { // Reference: https://mp.weixin.qq.com/debug/cgi-bin/readtmpl?t=tmplmsg/faq_tmpl let user = tokenStore.get(userPrefix); + if (!user) { + return { + success: false, + message: `tokenStore 中不存在该前缀(${userPrefix})`, + }; + } let access_token = user.token; let request_data = { touser: user.wechatOpenId, template_id: user.wechatTemplateId, }; if (message.content) { - // TODO - // Generate html, save message to database and then return the id - let id = 'TODO'; - request_data.url = `${config.href}${userPrefix}/${id}`; + request_data.url = `${config.href}${userPrefix}/${message.id}`; } request_data.data = { text: { value: message.description } }; let requestUrl = `https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=${access_token}`; diff --git a/config.js b/config.js index f771749..40f3b8d 100644 --- a/config.js +++ b/config.js @@ -2,6 +2,8 @@ const config = { port: process.env.PORT || 3000, database: 'data.db', href: 'https://github.com/', + session_secret: 'change this', + cookie_secret: 'change this', }; module.exports = config; diff --git a/models/user.js b/models/user.js index 6a56854..49c33c0 100644 --- a/models/user.js +++ b/models/user.js @@ -41,6 +41,7 @@ User.init( wechatAppSecret: DataTypes.STRING, wechatTemplateId: DataTypes.STRING, wechatOpenId: DataTypes.STRING, + wechatVerifyToken: DataTypes.STRING, }, { sequelize } ); diff --git a/package.json b/package.json index 54b7415..9795404 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "wechat-message-push", - "version": "0.0.0", + "name": "message-pusher", + "version": "0.2.0", "private": true, "scripts": { "start": "node ./app.js", @@ -8,13 +8,18 @@ }, "dependencies": { "axios": "^0.21.1", + "compression": "^1.7.4", + "connect-flash": "^0.1.1", "cookie-parser": "~1.4.4", "debug": "~2.6.9", "ejs": "^3.1.5", "express": "~4.16.1", + "express-rate-limit": "^5.2.3", + "express-session": "^1.17.1", "marked": "^1.2.7", "morgan": "~1.9.1", "sequelize": "^6.3.5", + "serve-static": "^1.14.1", "sqlite3": "^5.0.1" }, "devDependencies": { diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/public/main.css b/public/main.css new file mode 100644 index 0000000..d6c5645 --- /dev/null +++ b/public/main.css @@ -0,0 +1,246 @@ +body { + font-family: Verdana, Candara, Arial, Helvetica, Microsoft YaHei, sans-serif; + line-height: 1.6; + margin: 0; +} + +nav { + margin-bottom: 16px; +} + +a { + text-decoration: none; +} + +.wrapper { + max-width: 960px; + margin: 0 auto; +} + +#page-container { + position: relative; + min-height: 97vh; +} + +#content-wrap { + padding-bottom: 4rem; +} + +#footer { + height: 4rem; +} + +#footer a { + color: black; +} + +code { + font-family: Consolas, 'Courier New', monospace; +} + +.page-card-list { + margin: 8px 8px; +} + +.page-card-title { + font-size: x-large; + font-weight: 500; + color: #000000; + text-decoration: none; +} + +.page-card-text { + margin-top: 8px; +} + +.pagination { + margin: 16px 4px; +} + +.pagination a { + border: none; + overflow: hidden; +} + +.shadow { + box-shadow: 0 0.5em 1em -0.125em rgba(10,10,10,.1), 0 0 0 1px rgba(10,10,10,.02); +} + +.nav-shadow { + box-shadow: 0 2px 3px rgba(26,26,26,.1); +} + +.box article { + overflow-wrap: break-word; + font-size: larger; + word-break: break-word; + line-height: 1.6; + padding: 16px; + margin-bottom: 16px; + background-color: #ffffff; +} + +img { + max-width: 100%; + max-height: 100%; +} + +.normal-container { + margin: auto; + max-width: 960px; + padding: 16px 16px; + overflow-wrap: break-word; + word-break: break-word; + line-height: 1.6; + font-size: larger; +} + +.narrow-container { + margin: auto; + max-width: 560px; + padding: 16px 16px; + overflow-wrap: break-word; + word-break: break-word; + line-height: 1.6; + font-size: larger; +} + +article a { + color: #368CCB; + text-decoration: none; +} + +article a:hover { + color: #368CCB; + text-decoration: none; +} + +article h2, +article h3, +article h4, +article h5, +article h6 { + font-weight: 700; + line-height: 1.5; + margin: 20px 0 15px; + margin-block-start: 1em; + margin-block-end: 0.2em; +} + +article h1 { + font-size: 1.7em +} + +article h2 { + font-size: 1.6em +} + +article h3 { + font-size: 1.45em +} + +article h4 { + font-size: 1.25em; +} + +article h5 { + font-size: 1.1em; +} +article h6 { + font-size: 1em; + font-weight: bold +} + +@media screen and (max-width: 960px) { + article h1 { + font-size: 1.5em + } + + article h2 { + font-size: 1.35em + } + + article h3 { + font-size: 1.3em + } + + article h4 { + font-size: 1.2em; + } +} + +article p { + margin-top: 0; + margin-bottom: 1.25rem; +} + +article table { + margin: auto; + border-collapse: collapse; + border-spacing: 0; + vertical-align: middle; + text-align: left; + min-width: 66%; +} + +article table td, +article table th { + padding: 5px 8px; + border: 1px solid #bbb; +} + +article blockquote { + margin-left: 0; + padding: 0 1em; + font-size: smaller; + border-left: 5px solid #ddd; +} + +article pre { + overflow-x: auto; + padding: 0; + font-size: 16px; + margin-top: 12px; + margin-bottom: 12px; +} + +article ol { + text-decoration: none; + padding-inline-start: 40px; + margin-bottom: 1.25rem; +} + +article code { + color: #bc9458; + padding: .065em .4em; +} + +article .copyright{ + display: none; +} + +.info { + font-size: 14px; + line-height: 28px; + text-align: left; + color: #738292; + margin-bottom: 3em +} + +.info a { + text-decoration: none; + color: inherit; +} + +.links { + margin: 16px; +} + +span.line { + display: inline-block; +} + +.toc { + position: sticky; + top: 24px; +} \ No newline at end of file diff --git a/public/main.js b/public/main.js new file mode 100644 index 0000000..e69de29 diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..77470cb --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / \ No newline at end of file diff --git a/routers/index.js b/routers/index.js index 4fb5969..5a09110 100644 --- a/routers/index.js +++ b/routers/index.js @@ -1,84 +1,85 @@ const express = require('express'); const router = express.Router(); -const crypto = require('crypto'); -const fs = require('fs'); +const { User } = require('../models'); +const { tokenStore } = require('../common/token'); -router.all('/', (req, res, next) => { - fs.promises - .access('./.env') - .then(() => { - res.render('info', { - message: '服务已在运行。', - }); - // pushMessage( - // req, - // res, - // `请注意,ip 地址为 ${req.ip} 的用户访问了你的消息通知服务,如果非你本人,则你的私有消息通知服务可能已被泄露,当前版本无法阻止其他用户通过本系统向你发送消息。` - // ); - }) - .catch(() => { - res.render('configure'); - }); +router.get('/', (req, res, next) => { + res.render('index', { + message: '', + }); }); -router.post('/configure', (req, res, next) => { - fs.promises - .access('./.env') - .then(() => { - res.render('message', { - message: '.env 文件已经存在,请手动删除该文件后重试!', - }); - }) - .catch(() => { - let content = - `APP_ID=${req.body.APP_ID}\n` + - `APP_SECRET=${req.body.APP_SECRET}\n` + - `TOKEN=${req.body.TOKEN}\n` + - `TEMPLATE_ID=${req.body.TEMPLATE_ID}\n` + - `OPEN_ID=${req.body.OPEN_ID}`; - fs.promises - .writeFile('./.env', content, 'utf8') - .then(() => { - res.render('message', { - message: - '.env 文件写入成功,程序即将自动关闭以应用写入的新的环境变量,需要进程守护程序自动重启应用或者手动重启。', - }); - process.exit(); - }) - .catch((e) => { - res.render('info', { - message: '在尝试写入 .env 文件时发生错误:' + e, - }); - }); - }); +router.get('/login', (req, res, next) => { + res.render('login', { + message: '', + }); }); -router.all('/verify', (req, res, next) => { - // 验证消息来自微信服务器:https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Access_Overview.html - const { signature, timestamp, nonce, echostr } = req.query; - const token = process.env.TOKEN; - let tmp_array = [token, timestamp, nonce].sort(); - let tmp_string = tmp_array.join(''); - tmp_string = crypto.createHash('sha1').update(tmp_string).digest('hex'); - if (tmp_string === signature) { - res.send(echostr); - } else { - res.send('verification failed'); +router.post('/login', async (req, res, next) => { + let user = { + username: req.body.username, + password: req.body.password, + }; + let message = ''; + try { + user = await User.findOne({ where: user }); + if (user) { + req.session.user = user; + return res.redirect('/'); + } else { + message = '用户名或密码错误'; + } + } catch (e) { + console.error(e); + message = e.message; + } + res.render('login', { + message, + }); +}); + +router.get('/register', (req, res, next) => { + res.render('register'); +}); + +router.post('/register', async (req, res, next) => { + let user = { + username: req.body.username, + password: req.body.password, + }; + try { + user = await User.create(user); + } catch (e) { + console.error(e); } }); -router.all('/push', (req, res, next) => { - let content = req.query.content || req.body.content; - pushMessage(req, res, content); -}); - -router.get('/favicon.ico', (req, res, next) => { - res.sendStatus(404); -}); - -router.all('/:content', (req, res, next) => { - let content = req.params.content; - pushMessage(req, res, content); +router.post('/configure', async (req, res, next) => { + let user = { + username: req.body.username, + password: req.body.password, + accessToken: req.body.accessToken, + email: req.body.email, + prefix: req.body.prefix, + wechatAppId: req.body.wechatAppId, + wechatAppSecret: req.body.wechatAppSecret, + wechatTemplateId: req.body.wechatTemplateId, + wechatOpenId: req.body.wechatOpenId, + wechatVerifyToken: req.body.wechatVerifyToken, + }; + try { + user = await User.create(user); + tokenStore.set(user.prefix, { + appId: user.wechatAppId, + appSecret: user.wechatAppSecret, + templateId: user.wechatTemplateId, + openId: user.wechatOpenId, + wechatVerifyToken: user.wechatVerifyToken, + token: '', + }); + } catch (e) { + console.error(e); + } }); module.exports = router; diff --git a/routers/message.js b/routers/message.js index 256b06b..b2bf5b9 100644 --- a/routers/message.js +++ b/routers/message.js @@ -1,9 +1,6 @@ const express = require('express'); const lexer = require('marked').lexer; const parser = require('marked').parser; -const Message = require('../models/message').Message; -const pushWeChatMessage = require('../common/wechat').pushWeChatMessage; -const formatTime = require('../common/utils').formatTime; const router = express.Router(); @@ -11,49 +8,19 @@ function md2html(markdown) { return parser(lexer(markdown)); } -router.get('/:description', (req, res, next) => { - req.query.description = req.params.description; - next(); +router.get('/delete/:id', (req, res, next) => { + // TODO: delete message + res.json({ + success: true, + message: 'Ok', + }); }); -router.all('/', (req, res) => { - let message = { - title: req.query.title || req.body.title || '无标题', - status: 1, - created_by: '系统', // TODO - created_time: formatTime(), - description: req.query.description || req.body.description, - content: md2html(req.query.content || req.body.content), - }; - - Message.create(message) - .then((value) => { - console.log(value); - pushWeChatMessage(message.description, message.link) - .then((response) => { - res.json(response); - }) - .catch((reason) => { - res.json(reason); - }); - }) - .catch((reason) => { - res.json(reason); - }); -}); - -router.get('/detail/:id', (req, res) => { +router.get('/:id', (req, res, next) => { const id = req.params.id; - Message.getById(id) - .then((value) => { - console.log(value); - res.render('message', value[0]); - }) - .catch((reason) => { - res.render('info', { - message: '获取该消息时发生了错误:' + reason, - }); - }); + // TODO: show article + req.query.description = req.params.description; + res.render('article'); }); module.exports = router; diff --git a/routers/user.js b/routers/user.js new file mode 100644 index 0000000..d8088b5 --- /dev/null +++ b/routers/user.js @@ -0,0 +1,40 @@ +const express = require('express'); +const crypto = require('crypto'); +const Message = require('../models/message').Message; +const { processMessage } = require('../common/message'); +const { tokenStore } = require('../common/token'); + +const router = express.Router(); + +router.get('/:userPrefix/configure', (req, res, next) => { + res.render('configure'); +}); + +router.all('/:userPrefix/verify', (req, res, next) => { + // 验证消息来自微信服务器:https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Access_Overview.html + const userPrefix = req.params.userPrefix; + const { signature, timestamp, nonce, echostr } = req.query; + const token = tokenStore.get(userPrefix).wechatVerifyToken; + let tmp_array = [token, timestamp, nonce].sort(); + let tmp_string = tmp_array.join(''); + tmp_string = crypto.createHash('sha1').update(tmp_string).digest('hex'); + if (tmp_string === signature) { + res.send(echostr); + } else { + res.send('verification failed'); + } +}); + +router.all('/:userPrefix', async (req, res, next) => { + const userPrefix = req.params.userPrefix; + let message = { + type: req.query.type || req.body.type || '0', + title: req.query.title || req.body.title || '无标题', + description: req.query.description || req.body.description, + content: req.query.content || req.body.content, + }; + let result = await processMessage(userPrefix, message); + res.json(result); +}); + +module.exports = router; diff --git a/views/article.ejs b/views/article.ejs new file mode 100644 index 0000000..620a127 --- /dev/null +++ b/views/article.ejs @@ -0,0 +1,34 @@ +<%- include('./partials/header') %> + +
Links: <%-page.link%>
+ <%- config.copyright %> + <%- config.ad %> +