├── .gitignore ├── deploy.sh ├── image.js ├── images ├── development-applications.png ├── development-header.png ├── development-post.png ├── development-rule.png ├── development-tips.png ├── qanda-header.png ├── recommendation-list.png ├── software-download.png ├── software-header.png ├── software-mojang-green.png ├── software-mojang-red.png ├── software-mojang-yellow.png ├── software-mojang.png ├── software-post.png └── software-rule.png ├── index.js ├── package.json └── state.js /.gitignore: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | !.gitignore 4 | 5 | !*.js 6 | !images/ 7 | !deploy.sh 8 | 9 | !package.json 10 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd $(dirname $0) 4 | 5 | git fetch https://github.com/ustc-zzzz/MCBBSHeaderImage.git 6 | git checkout -B master FETCH_HEAD 2>&1 >/dev/null 7 | npm install 8 | 9 | echo $(realpath index.js) 10 | forever stop -a -l mcbbs-header-image.log $(realpath index.js) 11 | forever start -a -l mcbbs-header-image.log $(realpath index.js) 12 | -------------------------------------------------------------------------------- /image.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var fs = require('fs'); 4 | var path = require('path'); 5 | var state = require('./state'); 6 | var canvas = require('canvas'); 7 | 8 | // returns a promise which will contain a png 9 | function drawDirect (fileName) { 10 | return new Promise(function (resolve, reject) { 11 | resolve(fs.createReadStream(path.join(__dirname, 'images', fileName))); 12 | }); 13 | } 14 | 15 | // returns a promise which will contain a png 16 | function drawSoftwareMojangState () { 17 | return state.getMojangAuthStatus().then(function (status) { 18 | if (status.authserver) { 19 | return fs.createReadStream(__dirname + '/images/software-mojang-' + status.authserver + '.png'); 20 | } else { 21 | return fs.createReadStream(__dirname + '/images/software-mojang.png'); 22 | } 23 | }); 24 | } 25 | 26 | // returns a promise which will contain a png 27 | function drawSoftwareHeader () { 28 | return new Promise(function (resolve, reject) { 29 | fs.readFile(__dirname + '/images/software-header.png', function (err, data) { 30 | if (err) reject(err); else resolve(data); 31 | }); 32 | }).then(function (data) { 33 | var img = new canvas.Image(); 34 | img.src = data; 35 | var ctx = new canvas.Canvas(img.width, img.height).getContext('2d'); 36 | ctx.drawImage(img, 0, 0, img.width, img.height); 37 | return ctx; 38 | }).then(function (ctx) { 39 | return state.getMinecraftVersions().then(function (data) { 40 | return state.getSoftwareViews().then(function (text) { 41 | text = '' + text; 42 | ctx.font = '18px DejaVu Sans'; 43 | ctx.fillStyle = '#fbf2db'; 44 | ctx.fillText(text, 177 - 5 * text.length, 27); // magic numbers 45 | ctx.font = '16px DejaVu Sans'; 46 | ctx.fillText(data.stable ? 'Minecraft ' + data.stable : 'Unknown', 522, 27); // magic numbers 47 | ctx.fillText(data.snapshot ? 'Minecraft ' + data.snapshot : 'Unknown', 714, 27); // magic numbers 48 | return ctx; 49 | }); 50 | }); 51 | }).then(function (ctx) { 52 | return ctx.canvas.pngStream(); 53 | }); 54 | } 55 | 56 | // returns a promise which will contain a png 57 | function drawDevelopmentHeader () { 58 | return new Promise(function (resolve, reject) { 59 | fs.readFile(__dirname + '/images/development-header.png', function (err, data) { 60 | if (err) reject(err); else resolve(data); 61 | }); 62 | }).then(function (data) { 63 | var img = new canvas.Image(); 64 | img.src = data; 65 | var ctx = new canvas.Canvas(img.width, img.height).getContext('2d'); 66 | ctx.drawImage(img, 0, 0, img.width, img.height); 67 | return ctx; 68 | }).then(function (ctx) { 69 | return state.getSaleStats().then(function (data) { 70 | return state.getDevelopmentViews().then(function (text) { 71 | text = '' + text; 72 | ctx.font = '18px DejaVu Sans'; 73 | ctx.fillStyle = '#fbf2db'; 74 | ctx.fillText(text, 177 - 5 * text.length, 27); // magic numbers 75 | ctx.font = '16px DejaVu Sans'; 76 | ctx.fillText(data.total ? data.total.toLocaleString('en') : '-', 542, 27); // magic numbers 77 | ctx.fillText(data.last24h ? data.last24h.toLocaleString('en') : '-', 820, 27); // magic numbers 78 | return ctx; 79 | }); 80 | }); 81 | }).then(function (ctx) { 82 | return ctx.canvas.pngStream(); 83 | }); 84 | } 85 | 86 | function drawQandaHeader () { 87 | return new Promise(function (resolve, reject) { 88 | fs.readFile(__dirname + '/images/qanda-header.png', function (err, data) { 89 | if (err) reject(err); else resolve(data); 90 | }); 91 | }).then(function (data) { 92 | var img = new canvas.Image(); 93 | img.src = data; 94 | var ctx = new canvas.Canvas(img.width, img.height).getContext('2d'); 95 | ctx.drawImage(img, 0, 0, img.width, img.height); 96 | return ctx; 97 | }).then(function (ctx) { 98 | return state.getQandaViews().then(function (text) { 99 | text = '' + text; 100 | ctx.font = '30px DejaVu Sans'; 101 | ctx.fillStyle = '#000000'; 102 | ctx.fillText(text, 95 - 10 * text.length, 70); // magic numbers 103 | return ctx; 104 | }); 105 | }).then(function (ctx) { 106 | return ctx.canvas.pngStream(); 107 | }); 108 | } 109 | 110 | 111 | module.exports = { 112 | drawDirect: drawDirect, 113 | drawSoftwareHeader: drawSoftwareHeader, 114 | drawQandaHeader: drawQandaHeader, 115 | drawDevelopmentHeader: drawDevelopmentHeader, 116 | drawSoftwareMojangState: drawSoftwareMojangState, 117 | increaseSoftwareViews: state.increaseSoftwareViews, 118 | increaseDevelopmentViews: state.increaseDevelopmentViews, 119 | increaseQandaViews: state.increaseQandaViews 120 | } 121 | -------------------------------------------------------------------------------- /images/development-applications.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ustc-zzzz/MCBBSHeaderImage/7b66a1821d06307f4a1e9fea7d78eba8816bf76d/images/development-applications.png -------------------------------------------------------------------------------- /images/development-header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ustc-zzzz/MCBBSHeaderImage/7b66a1821d06307f4a1e9fea7d78eba8816bf76d/images/development-header.png -------------------------------------------------------------------------------- /images/development-post.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ustc-zzzz/MCBBSHeaderImage/7b66a1821d06307f4a1e9fea7d78eba8816bf76d/images/development-post.png -------------------------------------------------------------------------------- /images/development-rule.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ustc-zzzz/MCBBSHeaderImage/7b66a1821d06307f4a1e9fea7d78eba8816bf76d/images/development-rule.png -------------------------------------------------------------------------------- /images/development-tips.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ustc-zzzz/MCBBSHeaderImage/7b66a1821d06307f4a1e9fea7d78eba8816bf76d/images/development-tips.png -------------------------------------------------------------------------------- /images/qanda-header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ustc-zzzz/MCBBSHeaderImage/7b66a1821d06307f4a1e9fea7d78eba8816bf76d/images/qanda-header.png -------------------------------------------------------------------------------- /images/recommendation-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ustc-zzzz/MCBBSHeaderImage/7b66a1821d06307f4a1e9fea7d78eba8816bf76d/images/recommendation-list.png -------------------------------------------------------------------------------- /images/software-download.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ustc-zzzz/MCBBSHeaderImage/7b66a1821d06307f4a1e9fea7d78eba8816bf76d/images/software-download.png -------------------------------------------------------------------------------- /images/software-header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ustc-zzzz/MCBBSHeaderImage/7b66a1821d06307f4a1e9fea7d78eba8816bf76d/images/software-header.png -------------------------------------------------------------------------------- /images/software-mojang-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ustc-zzzz/MCBBSHeaderImage/7b66a1821d06307f4a1e9fea7d78eba8816bf76d/images/software-mojang-green.png -------------------------------------------------------------------------------- /images/software-mojang-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ustc-zzzz/MCBBSHeaderImage/7b66a1821d06307f4a1e9fea7d78eba8816bf76d/images/software-mojang-red.png -------------------------------------------------------------------------------- /images/software-mojang-yellow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ustc-zzzz/MCBBSHeaderImage/7b66a1821d06307f4a1e9fea7d78eba8816bf76d/images/software-mojang-yellow.png -------------------------------------------------------------------------------- /images/software-mojang.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ustc-zzzz/MCBBSHeaderImage/7b66a1821d06307f4a1e9fea7d78eba8816bf76d/images/software-mojang.png -------------------------------------------------------------------------------- /images/software-post.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ustc-zzzz/MCBBSHeaderImage/7b66a1821d06307f4a1e9fea7d78eba8816bf76d/images/software-post.png -------------------------------------------------------------------------------- /images/software-rule.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ustc-zzzz/MCBBSHeaderImage/7b66a1821d06307f4a1e9fea7d78eba8816bf76d/images/software-rule.png -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var fs = require('fs'); 4 | var path = require('path'); 5 | var logger = require('morgan'); 6 | var crypto = require('crypto'); 7 | var image = require('./image'); 8 | var express = require('express'); 9 | 10 | var app = express(), router = express.Router(); 11 | 12 | var uaFilter = /^Mozilla\/\S*\ / 13 | var softwareRefererFilter = /^https?:\/\/(www\.mcbbs\.net|mcbbs\.net|mcbbs\.tvt\.im)\/forum\-software/; 14 | var developmentRefererFilter = /^https?:\/\/(www\.mcbbs\.net|mcbbs\.net|mcbbs\.tvt\.im)\/forum\-development/; 15 | var qandaRefererFilter = /^https?:\/\/(www\.mcbbs\.net|mcbbs\.net|mcbbs\.tvt\.im)\/forum\-(qanda|multiqanda|modqanda|1566)/; 16 | 17 | var developmentFileMap = { 18 | 'post.png': image.drawDirect.bind(image, 'development-post.png'), 19 | 'applications.png': image.drawDirect.bind(image, 'development-applications.png'), 20 | 'rule.png': image.drawDirect.bind(image, 'development-rule.png'), 21 | 'recommendation.png': image.drawDirect.bind(image, 'recommendation-list.png'), 22 | 'tips.png': image.drawDirect.bind(image, 'development-tips.png'), 23 | 'header.png': function (isRealVisit) { 24 | if (isRealVisit()) { 25 | return image.increaseDevelopmentViews().then(image.drawDevelopmentHeader); 26 | } else { 27 | return image.drawDevelopmentHeader(); 28 | } 29 | } 30 | }; 31 | 32 | var softwareFileMap = { 33 | 'post.png': image.drawDirect.bind(image, 'software-post.png'), 34 | 'download.png': image.drawDirect.bind(image, 'software-download.png'), 35 | 'rule.png': image.drawDirect.bind(image, 'software-rule.png'), 36 | 'recommendation.png': image.drawDirect.bind(image, 'recommendation-list.png'), 37 | 'mojang.png': image.drawSoftwareMojangState.bind(image), 38 | 'header.png': function (isRealVisit) { 39 | if (isRealVisit()) { 40 | return image.increaseSoftwareViews().then(image.drawSoftwareHeader); 41 | } else { 42 | return image.drawSoftwareHeader(); 43 | } 44 | } 45 | }; 46 | 47 | var qandaFileMap = { 48 | 'header.png': function (isRealVisit) { 49 | if (isRealVisit()) { 50 | return image.increaseQandaViews().then(image.drawQandaHeader); 51 | } else { 52 | return image.drawQandaHeader(); 53 | } 54 | } 55 | }; 56 | 57 | var ipRecords = {}, maxVisitsInPeriod = 6, period = 3600000; // milliseconds 58 | 59 | function isTheseIPVisitsTooFrequently (list) { 60 | return isThisIPVisitsTooFrequently(list.length == 0 ? "" : list[0]) 61 | } 62 | 63 | function isThisIPVisitsTooFrequently (ip) { 64 | var visitRecords = ipRecords[ip]; 65 | var now = Date.now(); 66 | if (!visitRecords) { 67 | ipRecords[ip] = [now]; 68 | return false; 69 | } else if (visitRecords.length < maxVisitsInPeriod) { 70 | visitRecords.push(now); 71 | return false; 72 | } else if (visitRecords[0] + period <= now) { 73 | visitRecords.shift(); 74 | visitRecords.push(now); 75 | return false; 76 | } else { 77 | return true; 78 | } 79 | } 80 | 81 | function getUserIP(req) { 82 | var forwardedFor = req.get('X-Forwarded-For'); 83 | return (((forwardedFor === undefined) ? req.ip : forwardedFor) + '').replace(' ','').split(','); 84 | } 85 | 86 | router.get('/development/:name', function (req, res, next) { 87 | var name = req.params['name']; 88 | if (name && developmentFileMap[name]) { 89 | res.setHeader('Content-Type', 'image/png'); 90 | developmentFileMap[name](function () { 91 | var isValidUA = uaFilter.test(req.get('User-Agent')); 92 | var isValidReferer = developmentRefererFilter.test(req.get('Referer')); 93 | var isThisIPNotRestricted = !isTheseIPVisitsTooFrequently(getUserIP(req)); 94 | return isValidUA && isValidReferer && isThisIPNotRestricted; 95 | }).then(function (stream) { 96 | stream.pipe(res); 97 | }).catch(next); 98 | } else { 99 | next(); 100 | } 101 | }); 102 | 103 | router.get('/software/:name', function (req, res, next) { 104 | var name = req.params['name']; 105 | if (name && softwareFileMap[name]) { 106 | res.setHeader('Content-Type', 'image/png'); 107 | softwareFileMap[name](function () { 108 | var isValidUA = uaFilter.test(req.get('User-Agent')); 109 | var isValidReferer = softwareRefererFilter.test(req.get('Referer')); 110 | var isThisIPNotRestricted = !isTheseIPVisitsTooFrequently(getUserIP(req)); 111 | return isValidUA && isValidReferer && isThisIPNotRestricted; 112 | }).then(function (stream) { 113 | stream.pipe(res); 114 | }).catch(next); 115 | } else { 116 | next(); 117 | } 118 | }); 119 | 120 | router.get('/qanda/:name', function (req, res, next) { 121 | var name = req.params['name']; 122 | if (name && qandaFileMap[name]) { 123 | res.setHeader('Content-Type', 'image/png'); 124 | qandaFileMap[name](function () { 125 | var isValidUA = uaFilter.test(req.get('User-Agent')); 126 | var isValidReferer = qandaRefererFilter.test(req.get('Referer')); 127 | var isThisIPNotRestricted = !isTheseIPVisitsTooFrequently(getUserIP(req)); 128 | return isValidUA && isValidReferer && isThisIPNotRestricted; 129 | }).then(function (stream) { 130 | stream.pipe(res); 131 | }).catch(next); 132 | } else { 133 | next(); 134 | } 135 | }); 136 | 137 | router.get('/log/:token', function (req, res, next) { 138 | var hash = crypto.createHash('sha256').update('log/').update('' + req.params['token']).digest('hex'); 139 | if (hash === '9473e7baa5c8d0aba1be684531e2b87a41dc0c01597fb3e359f54e2ac07fd437') { 140 | var file = fs.createReadStream(path.join(process.env.HOME, '.forever/mcbbs-header-image.log')); 141 | file.pipe(res); 142 | } else { 143 | next(); 144 | } 145 | }); 146 | 147 | app.use(logger('common')); 148 | app.use('/image', router); 149 | app.use(function (req, res, next) { 150 | res.status(404).end(); 151 | }); 152 | 153 | app.listen(3003); 154 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mcbbs-head-image", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "node ./index.js" 7 | }, 8 | "dependencies": { 9 | "canvas": "^2.9.3", 10 | "express": "^4.16.4", 11 | "morgan": "^1.9.1", 12 | "request": "^2.88.0", 13 | "request-promise": "^4.2.4", 14 | "sqlite3": "^4.0.6" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /state.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var sqlite3 = require('sqlite3'); 4 | var rp = require('request-promise'); 5 | 6 | var saleStats = {}, versions = {}, mojangStatus = {}, lastUpdateTime = 0; 7 | var timeInterval = 60000; 8 | 9 | var viewsDatabase, waitUntilOpen = new Promise(function (resolve, reject) { 10 | var mode = sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE; 11 | viewsDatabase = new sqlite3.Database(__dirname + '/views.db', mode, function (err) { 12 | if (err) reject(err); else resolve(); 13 | }); 14 | }).then(function () { 15 | return new Promise(function (resolve, reject) { 16 | var sql = 'CREATE TABLE IF NOT EXISTS software (date INTEGER(32) PRIMARY KEY, count INTEGER(32) DEFAULT 0);'; 17 | viewsDatabase.run(sql, [], resolve); 18 | }); 19 | }).then(function () { 20 | return new Promise(function (resolve, reject) { 21 | var sql = 'CREATE TABLE IF NOT EXISTS development (date INTEGER(32) PRIMARY KEY, count INTEGER(32) DEFAULT 0);'; 22 | viewsDatabase.run(sql, [], resolve); 23 | }); 24 | }).then(function () { 25 | return new Promise(function (resolve, reject) { 26 | var sql = 'CREATE TABLE IF NOT EXISTS qanda (date INTEGER(32) PRIMARY KEY, count INTEGER(32) DEFAULT 0);'; 27 | viewsDatabase.run(sql, [], resolve); 28 | }); 29 | }); 30 | 31 | function getApproximateTimeStamp () { 32 | var timeDiff = 28800000; // UTC+8 33 | return Math.floor((Date.now() + timeDiff) / 86400000) * 86400 - timeDiff / 1000; 34 | } 35 | 36 | function updateStats () { 37 | function updateUntilItIsOK (times) { 38 | return Promise.all([rp({ 39 | method: 'POST', 40 | uri: 'https://api.mojang.com/orders/statistics', 41 | body: {metricKeys: ['item_sold_minecraft', 'prepaid_card_redeemed_minecraft']}, 42 | json: true 43 | }).catch(function () { 44 | return {}; 45 | }), rp({ 46 | uri: 'https://launchermeta.mojang.com/mc/game/version_manifest.json', 47 | json: true 48 | }), rp({ 49 | uri: 'https://status.mojang.com/check', 50 | json: true 51 | }).catch(function () { 52 | return []; 53 | })]).then(function (data) { 54 | saleStats = data[0]; 55 | var minecraftVersions = data[1].versions; 56 | versions.stable = versions.snapshot = undefined; 57 | for (var i in minecraftVersions) { 58 | var type = minecraftVersions[i].type; 59 | if (type === 'release' && !versions.stable) versions.stable = minecraftVersions[i].id; 60 | if (type === 'snapshot' && !versions.snapshot) versions.snapshot = minecraftVersions[i].id; 61 | if (versions.stable && versions.snapshot) break; 62 | } 63 | var statusCollection = data[2]; 64 | for (var i in statusCollection) { 65 | var json = statusCollection[i]; 66 | if (json['authserver.mojang.com']) { 67 | mojangStatus['authserver'] = json['authserver.mojang.com']; 68 | break; 69 | } 70 | } 71 | lastUpdateTime = Date.now(); 72 | return null; 73 | }).catch(function (err) { 74 | if (times > 0) { 75 | return updateUntilItIsOK(times - 1); 76 | } 77 | lastUpdateTime = Date.now(); 78 | return Promise.resolve(null); 79 | }); 80 | } 81 | if (Math.floor(lastUpdateTime / timeInterval) < Math.floor(Date.now() / timeInterval)) { 82 | var promise = updateUntilItIsOK(3); 83 | if (lastUpdateTime === 0) { 84 | return promise; 85 | } else { 86 | lastUpdateTime = Date.now(); 87 | } 88 | } 89 | return Promise.resolve(null); 90 | } 91 | 92 | function getMinecraftVersions () { 93 | return updateStats().then(function () { 94 | return versions; 95 | }); 96 | } 97 | 98 | function getMojangAuthStatus () { 99 | return updateStats().then(function () { 100 | return mojangStatus; 101 | }); 102 | } 103 | 104 | function getSaleStats () { 105 | return updateStats().then(function () { 106 | return saleStats; 107 | }); 108 | } 109 | 110 | function getSoftwareViews () { 111 | return getViews('software'); 112 | } 113 | 114 | function getDevelopmentViews () { 115 | return getViews('development'); 116 | } 117 | 118 | function getQandaViews () { 119 | return getViews('qanda'); 120 | } 121 | 122 | function increaseSoftwareViews () { 123 | return increaseViews('software'); 124 | } 125 | 126 | function increaseDevelopmentViews () { 127 | return increaseViews('development'); 128 | } 129 | 130 | function increaseQandaViews () { 131 | return increaseViews('qanda'); 132 | } 133 | 134 | function increaseViews (tableName) { 135 | return new Promise(function (resolve, reject) { 136 | var date = getApproximateTimeStamp(); 137 | var sql = 'INSERT OR IGNORE INTO ' + tableName + '(date) VALUES(?)'; 138 | viewsDatabase.run(sql, [date], function (err) { 139 | if (err) reject(err); else { 140 | var sql = 'UPDATE ' + tableName + ' SET count=count+1 WHERE date=?'; 141 | viewsDatabase.run(sql, [date], function (err) { 142 | if (err) reject(err); else resolve(); 143 | }); 144 | } 145 | }) 146 | }); 147 | } 148 | 149 | function getViews (tableName) { 150 | return new Promise(function (resolve, reject) { 151 | var sql = 'SELECT count FROM ' + tableName + ' WHERE date=?'; 152 | viewsDatabase.get(sql, [getApproximateTimeStamp()], function (err, row) { 153 | if (err) reject(err); else resolve(row ? row.count : 1); 154 | }); 155 | }); 156 | } 157 | 158 | module.exports = { 159 | getSaleStats: getSaleStats, 160 | getSoftwareViews: getSoftwareViews, 161 | getDevelopmentViews: getDevelopmentViews, 162 | getQandaViews: getQandaViews, 163 | getMojangAuthStatus: getMojangAuthStatus, 164 | getMinecraftVersions: getMinecraftVersions, 165 | increaseSoftwareViews: increaseSoftwareViews, 166 | increaseDevelopmentViews: increaseDevelopmentViews, 167 | increaseQandaViews: increaseQandaViews 168 | }; 169 | --------------------------------------------------------------------------------