├── .gitignore ├── public ├── favicon.ico ├── favicon.png ├── badged_logo.png └── github-markdown.css ├── process.yml ├── .travis.yml ├── views ├── badge.html └── home.html ├── test └── test.js ├── controllers ├── home.js └── badge.js ├── script └── logger.js ├── gulpfile.js ├── LICENSE ├── package.json ├── app.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | logs/ 3 | .DS_Store 4 | *.log 5 | *.env* -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hr/badged/dev/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hr/badged/dev/public/favicon.png -------------------------------------------------------------------------------- /public/badged_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hr/badged/dev/public/badged_logo.png -------------------------------------------------------------------------------- /process.yml: -------------------------------------------------------------------------------- 1 | apps: 2 | - script : app.js 3 | name: badger 4 | instances: 4 5 | exec_mode: cluster 6 | error_file: 'logs/app-out.log' 7 | out_file: 'logs/app-err.log' -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | deploy: 2 | provider: heroku 3 | api_key: $HEROKU_API_KEY 4 | app: get-badge 5 | 6 | language: node_js 7 | node_js: 8 | - 8.11.4 9 | branches: 10 | only: 11 | - master 12 | cache: 13 | directories: 14 | - node_modules 15 | -------------------------------------------------------------------------------- /views/badge.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | downloads 5 | <%- count %> 6 | 7 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests 3 | * TODO: this 4 | * (C) Habib Rehman 5 | ******************************/ 6 | 7 | var request = require('supertest') 8 | var api = require('../..') 9 | 10 | describe('GET /stats', function () { 11 | it('should respond with stats', function (done) { 12 | var app = api() 13 | 14 | request(app.listen()) 15 | .get('/stats') 16 | .expect({ 17 | requests: 100000, 18 | average_duration: 52, 19 | uptime: 123123132 20 | }) 21 | .end(done) 22 | }) 23 | }) 24 | 25 | describe('GET /stats/:name', function () { 26 | it('should respond with a single stat', function (done) { 27 | var app = api() 28 | 29 | request(app.listen()) 30 | .get('/stats/requests') 31 | .expect('100000', done) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /controllers/home.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /** 3 | * Sweet Home 4 | * Renders the README.md in GH style 5 | * (C) Habib Rehman 6 | ******************************/ 7 | const showdown = require('showdown'), 8 | logger = require('winston'), 9 | fs = require('fs-extra'), 10 | README_PATH = `${__dirname}/../README.md`, 11 | converter = new showdown.Converter() 12 | 13 | let readmeHTML = '

Oops, this is unexpected...

' 14 | 15 | fs.readFile(README_PATH, 'utf8') 16 | .then((readmeFile) => { 17 | readmeHTML = converter.makeHtml(readmeFile) 18 | }) 19 | .catch((e) => { 20 | logger.error('Could not parse README, got error:', e) 21 | }) 22 | 23 | 24 | 25 | /** 26 | * Index 27 | */ 28 | 29 | exports.index = async function (ctx, next) { 30 | await ctx.render('home', { 31 | readmeHTML: readmeHTML 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /script/logger.js: -------------------------------------------------------------------------------- 1 | const winston = require('winston') 2 | const fs = require('fs-extra') 3 | const logDir = `./logs` 4 | const logFileOpts = { 5 | filename: `${logDir}`, 6 | datePattern: '/dd-MM-yyyy.log', 7 | prepend: false, 8 | colorize: false 9 | 10 | } 11 | const logConsoleOpts = { 12 | colorize: true 13 | } 14 | 15 | fs.ensureDirSync(logDir) 16 | 17 | winston.configure({ 18 | level: process.env.NODE_ENV === 'development' ? 'debug' : 'info', 19 | exitOnError: false, 20 | emitErrs: true, 21 | handleExceptions: true, 22 | transports: [ 23 | new (winston.transports.Console)(logConsoleOpts), 24 | new (require('winston-daily-rotate-file'))(logFileOpts) 25 | ] 26 | }) 27 | 28 | // Override stdout console 29 | console.log = winston.info 30 | console.error = winston.error 31 | console.info = winston.info 32 | 33 | module.exports = winston 34 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'), 2 | nodemon = require('gulp-nodemon'), 3 | env = require('gulp-env'), 4 | exec = require('child_process').exec 5 | 6 | gulp.task('nodemon', function () { 7 | env({ 8 | file: '.env', 9 | vars: {} 10 | }) 11 | 12 | nodemon({ 13 | script: 'app.js', 14 | verbose: true, 15 | debug: true, 16 | ignore: ['logs/', '*.log', '.DS_Store'], 17 | nodeArgs: ['--inspect'], 18 | ext: 'js json', 19 | events: { 20 | restart: "osascript -e 'display notification \"App restarted due to:\n'$FILENAME'\" with title \"nodemon\"'" 21 | } 22 | }) 23 | }) 24 | 25 | gulp.task('inspect', function (cb) { 26 | env({ 27 | file: '.env', 28 | vars: {} 29 | }) 30 | 31 | exec('./node_modules/.bin/nodemon --inspect-brk app.js', function (err, stdout, stderr) { 32 | console.log(stdout) 33 | console.log(stderr) 34 | cb(err) 35 | }) 36 | }) 37 | 38 | gulp.task('default', ['nodemon']) 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Habib Rehman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "badged", 3 | "version": "1.0.0", 4 | "description": "GitHub Release downloads badges", 5 | "main": "index.js", 6 | "engines": { 7 | "node": "8.11.4" 8 | }, 9 | "scripts": { 10 | "test": "make test", 11 | "dev": "./node_modules/.bin/nodemon app.js", 12 | "preinstall": "if [ \"$NODE_ENV\" != \"development\" ]; then npm install pm2 -g && pm2 install pm2-logrotate; fi", 13 | "start": "pm2 start --attach process.yml && pm2 logs all" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/HR/badged.git" 18 | }, 19 | "keywords": [ 20 | "GitHub", 21 | "Releases", 22 | "badges", 23 | "shields", 24 | "download", 25 | "count", 26 | "downloads" 27 | ], 28 | "author": "Habib Rehman", 29 | "license": "MIT", 30 | "bugs": { 31 | "url": "https://github.com/HR/badged/issues" 32 | }, 33 | "homepage": "https://github.com/HR/badged#readme", 34 | "dependencies": { 35 | "fs-extra": "^3.0.1", 36 | "koa": "^2.2.0", 37 | "koa-compress": "^2.0.0", 38 | "koa-ejs": "^4.0.0", 39 | "koa-logger": "^3.0.0", 40 | "koa-response-time": "^2.0.0", 41 | "koa-router": "^7.2.0", 42 | "koa-static": "^3.0.0", 43 | "lodash": "^4.17.11", 44 | "mongodb": "^2.2.27", 45 | "numeral": "^2.0.6", 46 | "parse-link-header": "^1.0.0", 47 | "request": "^2.81.0", 48 | "request-promise-native": "^1.0.4", 49 | "showdown": "^1.6.4", 50 | "winston": "^2.3.1", 51 | "winston-daily-rotate-file": "^1.4.6" 52 | }, 53 | "devDependencies": { 54 | "gulp": "^4.0.0", 55 | "gulp-env": "^0.4.0", 56 | "gulp-nodemon": "^2.2.1", 57 | "nodemon": "^1.18.4" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /views/home.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Badged 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | <%- readmeHTML %> 13 |
14 | 17 | 18 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /** 3 | * app.js 4 | * Entry point for the Badged App 5 | * (C) Habib Rehman 6 | ******************************/ 7 | 8 | // Init logger to override console 9 | const logger = require('./script/logger') 10 | 11 | const controllersPath = `${__dirname}/controllers`, 12 | badge = require(`${controllersPath}/badge`), 13 | home = require(`${controllersPath}/home`), 14 | {resolve} = require('path'), 15 | koa = require('koa'), 16 | responseTime = require('koa-response-time'), 17 | serve = require('koa-static'), 18 | compress = require('koa-compress'), 19 | klogger = require('koa-logger'), 20 | render = require('koa-ejs'), 21 | Router = require('koa-router'), 22 | _ = require('lodash'), 23 | MongoClient = require('mongodb').MongoClient 24 | 25 | // Constants 26 | const VIEW_PATH = `${__dirname}/views`, 27 | ENV = process.env.NODE_ENV || 'development', 28 | PORT = process.env.PORT || '4000', 29 | MONGODB_URI = process.env.MONGODB_URI, 30 | DEFAULT_DB_COLLECTION = 'downloads' 31 | 32 | // Init 33 | const app = new koa() 34 | const router = new Router() 35 | var _db 36 | 37 | /** 38 | * Config 39 | */ 40 | 41 | // Normalize 42 | function normalize (path) { 43 | return resolve(path.toString().toLowerCase()) 44 | } 45 | 46 | // Ejs setup 47 | render(app, { 48 | root: VIEW_PATH, 49 | viewExt: 'html', 50 | layout: false, 51 | cache: true, 52 | debug: true 53 | }) 54 | 55 | // Middleware to protect against HTTP Parameter Pollution attacks 56 | app.use(async (ctx, next) => { 57 | for (var param in ctx.query) { 58 | if (_.has(ctx.query, param)) { 59 | ctx.query[param] = _.isArray(ctx.query[param]) ? ctx.query[param][0] : ctx.query[param] 60 | } 61 | } 62 | await next() 63 | }) 64 | 65 | // Middleware for normalizing request path 66 | app.use(async (ctx, next) => { 67 | ctx.path = normalize(ctx.path) 68 | await next() 69 | }) 70 | 71 | // logging 72 | if ('test' != ENV) app.use(klogger()) 73 | 74 | // Mongodb logging 75 | require('mongodb').Logger.setLevel('info') 76 | 77 | // serve static files 78 | app.use(serve(`${__dirname}/public`)) 79 | 80 | // x-response-time 81 | app.use(responseTime()) 82 | 83 | // Compress 84 | app.use(compress()) 85 | 86 | // Add database to the context 87 | app.use(async (ctx, next) => { 88 | ctx.db = _db 89 | ctx.downloads = _db.collection(DEFAULT_DB_COLLECTION) 90 | await next() 91 | }) 92 | 93 | /** 94 | * Routes 95 | */ 96 | router.get('/', home.index) 97 | router.get('/status', badge.status) 98 | router.get('/:owner/:repo', badge.release) 99 | router.get('/:owner/:repo/total', badge.total) 100 | router.get('/:owner/:repo/:id', badge.releaseById) 101 | router.get('/:owner/:repo/tags/:tag', badge.releaseByTag) 102 | 103 | // catch all 104 | router.get('/*', home.index) 105 | 106 | app 107 | .use(router.routes()) 108 | .use(router.allowedMethods()) 109 | 110 | MongoClient.connect(MONGODB_URI) 111 | .then((db) => { 112 | _db = db 113 | logger.info(`Connected to db!`) 114 | // Start server after connected to db 115 | app.listen(PORT, () => { 116 | logger.info(`listening on port ${PORT}`) 117 | }) 118 | }) 119 | .catch(function (err) { 120 | logger.error(err) 121 | }) 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |
3 | Badged 4 |
5 | Badged 6 |
7 |
8 |

9 | 10 | 11 |

Customizable GitHub Release downloads badge service

12 | 13 |

14 | 15 | Downloads 17 | 18 | 19 | Downloads 21 | 22 | 23 | Downloads 25 | 26 |

27 |
28 | 29 | 30 | A service that provides you with a customizable download count badges for your 31 | GitHub Releases. Allows you to keep track of your release downloads and let 32 | others know how popular your releases are. Use virtually any badge service (e.g. 33 | shields.io) you like! Uses GitHub API, Mongodb 🌱, Koa ⚡ and ES17 ✨. 34 | Available for free. 35 | 36 | ## Features 37 | - Get a shiny downloads badge to add to your projects' README.md 38 | - Pretty-printed download count 39 | - Get downloads badge for a specific release by 40 | - Id 41 | - Tag 42 | - Get all-time (total) download count of all your releases 43 | - Use any badge service of your choice (with any customization offered) 44 | - Highly scalable service (see Scalability) 45 | 46 | ## Scalability 47 | All downloads are updated at an interval of an hour so as to stay within the 48 | GitHub API request limit and increase scalability. Updates are request-driven 49 | meaning that download counts are only updated after the interval if the badge is 50 | requested. Furthermore, advanced caching is used to ensure only modified data is 51 | updated via conditional requests which also reduces API quota usage and 52 | increases scalability. 53 | 54 | ## Usage 55 | Using the Badged API is pretty simple, just form the badged link for the desired 56 | repo badge and use it as the source of an image element. 57 | 58 | HTML 59 | ```html 60 | Downloads badge 61 | ``` 62 | Markdown 63 | ```markdown 64 | ![Downloads badge](https://get-badge.herokuapp.com/HR/Crypter) 65 | ``` 66 | 67 | ### Base url 68 | The base url for all downloads badges is 69 | ``` 70 | https://get-badge.herokuapp.com/:username/:repo 71 | ``` 72 | Where `username` and `repo` are the GitHub username and repository respectively. 73 | 74 | ### Get downloads badge for latest release 75 | ``` 76 | https://get-badge.herokuapp.com/:username/:repo 77 | ``` 78 | By default, the base url yields a badge for latest release 79 | 80 | ### Get downloads badge for a release by id 81 | ``` 82 | https://get-badge.herokuapp.com/:username/:repo/:id 83 | ``` 84 | Where `id` is the GitHub Release id. 85 | 86 | ### Get downloads badge for a release by tag name 87 | ``` 88 | https://get-badge.herokuapp.com/:username/:repo/tags/:tag 89 | ``` 90 | Where `tag` is the GitHub Release tag name. 91 | 92 | ### Get downloads badge for all releases 93 | ``` 94 | https://get-badge.herokuapp.com/:username/:repo/total 95 | ``` 96 | The latest total download count for all releases is calculated when requested. 97 | 98 | ### Specifying a custom badge 99 | By default, the shields.io downloads badge (i.e. 100 | `https://img.shields.io/badge/downloads-${DOWNLOAD_COUNT}-green.svg`) with the 101 | calculated download count is sent as the response. 102 | 103 | However, you can specify a custom badge URI _for any badge_ via the `badge` 104 | parameter. The badge URI must include the `%s` substitution character, which 105 | badged substitutes with the calculated download count (Pretty-printed), to yield 106 | the correct downloads badge. E.g. if the download count is 1293 and the badge 107 | URI is `https://img.shields.io/badge/downloads-%s-red.svg` yields the badge 108 | `https://img.shields.io/badge/downloads-1,293-red.svg` 109 | 110 | ### Examples 111 | - Downloads badge for the latest release `https://get-badge.herokuapp.com/HR/Crypter` ![Crypter](https://get-badge.herokuapp.com/HR/Crypter) 112 | - Custom downloads badge for the latest release `https://get-badge.herokuapp.com/HR/Crypter?badge=https://img.shields.io/badge/downloads-%s-red.svg` ![Crypter](https://get-badge.herokuapp.com/HR/Crypter?badge=https://img.shields.io/badge/downloads-%s-red.svg) 113 | - Downloads badge for release by id `https://get-badge.herokuapp.com/HR/Crypter/5163582` ![Crypter](https://get-badge.herokuapp.com/HR/Crypter/5163582) 114 | - Downloads badge for release by tag `https://get-badge.herokuapp.com/HR/Crypter/tags/v3.0.0` ![Crypter](https://get-badge.herokuapp.com/HR/Crypter/tags/v3.0.0) 115 | - Downloads badge for all releases `https://get-badge.herokuapp.com/HR/Crypter/total`![Crypter](https://get-badge.herokuapp.com/HR/Crypter/total) 116 | 117 | ## License 118 | The MIT License (MIT) 119 | 120 | Copyright (c) Habib Rehman (https://git.io/HR) 121 | 122 | Permission is hereby granted, free of charge, to any person obtaining a copy 123 | of this software and associated documentation files (the "Software"), to deal 124 | in the Software without restriction, including without limitation the rights 125 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 126 | copies of the Software, and to permit persons to whom the Software is 127 | furnished todo so, subject to the following conditions: 128 | 129 | The above copyright notice and this permission notice shall be included in 130 | all copies or substantial portions of the Software. 131 | 132 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 133 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 134 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 135 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 136 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 137 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 138 | THE SOFTWARE. 139 | -------------------------------------------------------------------------------- /public/github-markdown.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: octicons-link; 3 | src: url(data:font/woff;charset=utf-8;base64,d09GRgABAAAAAAZwABAAAAAACFQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABEU0lHAAAGaAAAAAgAAAAIAAAAAUdTVUIAAAZcAAAACgAAAAoAAQAAT1MvMgAAAyQAAABJAAAAYFYEU3RjbWFwAAADcAAAAEUAAACAAJThvmN2dCAAAATkAAAABAAAAAQAAAAAZnBnbQAAA7gAAACyAAABCUM+8IhnYXNwAAAGTAAAABAAAAAQABoAI2dseWYAAAFsAAABPAAAAZwcEq9taGVhZAAAAsgAAAA0AAAANgh4a91oaGVhAAADCAAAABoAAAAkCA8DRGhtdHgAAAL8AAAADAAAAAwGAACfbG9jYQAAAsAAAAAIAAAACABiATBtYXhwAAACqAAAABgAAAAgAA8ASm5hbWUAAAToAAABQgAAAlXu73sOcG9zdAAABiwAAAAeAAAAME3QpOBwcmVwAAAEbAAAAHYAAAB/aFGpk3jaTY6xa8JAGMW/O62BDi0tJLYQincXEypYIiGJjSgHniQ6umTsUEyLm5BV6NDBP8Tpts6F0v+k/0an2i+itHDw3v2+9+DBKTzsJNnWJNTgHEy4BgG3EMI9DCEDOGEXzDADU5hBKMIgNPZqoD3SilVaXZCER3/I7AtxEJLtzzuZfI+VVkprxTlXShWKb3TBecG11rwoNlmmn1P2WYcJczl32etSpKnziC7lQyWe1smVPy/Lt7Kc+0vWY/gAgIIEqAN9we0pwKXreiMasxvabDQMM4riO+qxM2ogwDGOZTXxwxDiycQIcoYFBLj5K3EIaSctAq2kTYiw+ymhce7vwM9jSqO8JyVd5RH9gyTt2+J/yUmYlIR0s04n6+7Vm1ozezUeLEaUjhaDSuXHwVRgvLJn1tQ7xiuVv/ocTRF42mNgZGBgYGbwZOBiAAFGJBIMAAizAFoAAABiAGIAznjaY2BkYGAA4in8zwXi+W2+MjCzMIDApSwvXzC97Z4Ig8N/BxYGZgcgl52BCSQKAA3jCV8CAABfAAAAAAQAAEB42mNgZGBg4f3vACQZQABIMjKgAmYAKEgBXgAAeNpjYGY6wTiBgZWBg2kmUxoDA4MPhGZMYzBi1AHygVLYQUCaawqDA4PChxhmh/8ODDEsvAwHgMKMIDnGL0x7gJQCAwMAJd4MFwAAAHjaY2BgYGaA4DAGRgYQkAHyGMF8NgYrIM3JIAGVYYDT+AEjAwuDFpBmA9KMDEwMCh9i/v8H8sH0/4dQc1iAmAkALaUKLgAAAHjaTY9LDsIgEIbtgqHUPpDi3gPoBVyRTmTddOmqTXThEXqrob2gQ1FjwpDvfwCBdmdXC5AVKFu3e5MfNFJ29KTQT48Ob9/lqYwOGZxeUelN2U2R6+cArgtCJpauW7UQBqnFkUsjAY/kOU1cP+DAgvxwn1chZDwUbd6CFimGXwzwF6tPbFIcjEl+vvmM/byA48e6tWrKArm4ZJlCbdsrxksL1AwWn/yBSJKpYbq8AXaaTb8AAHja28jAwOC00ZrBeQNDQOWO//sdBBgYGRiYWYAEELEwMTE4uzo5Zzo5b2BxdnFOcALxNjA6b2ByTswC8jYwg0VlNuoCTWAMqNzMzsoK1rEhNqByEyerg5PMJlYuVueETKcd/89uBpnpvIEVomeHLoMsAAe1Id4AAAAAAAB42oWQT07CQBTGv0JBhagk7HQzKxca2sJCE1hDt4QF+9JOS0nbaaYDCQfwCJ7Au3AHj+LO13FMmm6cl7785vven0kBjHCBhfpYuNa5Ph1c0e2Xu3jEvWG7UdPDLZ4N92nOm+EBXuAbHmIMSRMs+4aUEd4Nd3CHD8NdvOLTsA2GL8M9PODbcL+hD7C1xoaHeLJSEao0FEW14ckxC+TU8TxvsY6X0eLPmRhry2WVioLpkrbp84LLQPGI7c6sOiUzpWIWS5GzlSgUzzLBSikOPFTOXqly7rqx0Z1Q5BAIoZBSFihQYQOOBEdkCOgXTOHA07HAGjGWiIjaPZNW13/+lm6S9FT7rLHFJ6fQbkATOG1j2OFMucKJJsxIVfQORl+9Jyda6Sl1dUYhSCm1dyClfoeDve4qMYdLEbfqHf3O/AdDumsjAAB42mNgYoAAZQYjBmyAGYQZmdhL8zLdDEydARfoAqIAAAABAAMABwAKABMAB///AA8AAQAAAAAAAAAAAAAAAAABAAAAAA==) format('woff'); 4 | } 5 | 6 | footer { 7 | text-align: center; 8 | margin-top: 1rem; 9 | border-top: 1px solid #dfe2e5; 10 | } 11 | 12 | .markdown { 13 | -ms-text-size-adjust: 100%; 14 | -webkit-text-size-adjust: 100%; 15 | line-height: 1.5; 16 | color: #24292e; 17 | font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 18 | font-size: 16px; 19 | line-height: 1.5; 20 | word-wrap: break-word; 21 | box-sizing: border-box; 22 | min-width: 200px; 23 | max-width: 980px; 24 | margin: 0 auto; 25 | padding: 45px; 26 | } 27 | 28 | .markdown .pl-c { 29 | color: #969896; 30 | } 31 | 32 | .markdown .pl-c1, 33 | .markdown .pl-s .pl-v { 34 | color: #0086b3; 35 | } 36 | 37 | .markdown .pl-e, 38 | .markdown .pl-en { 39 | color: #795da3; 40 | } 41 | 42 | .markdown .pl-smi, 43 | .markdown .pl-s .pl-s1 { 44 | color: #333; 45 | } 46 | 47 | .markdown .pl-ent { 48 | color: #63a35c; 49 | } 50 | 51 | .markdown .pl-k { 52 | color: #a71d5d; 53 | } 54 | 55 | .markdown .pl-s, 56 | .markdown .pl-pds, 57 | .markdown .pl-s .pl-pse .pl-s1, 58 | .markdown .pl-sr, 59 | .markdown .pl-sr .pl-cce, 60 | .markdown .pl-sr .pl-sre, 61 | .markdown .pl-sr .pl-sra { 62 | color: #183691; 63 | } 64 | 65 | .markdown .pl-v, 66 | .markdown .pl-smw { 67 | color: #ed6a43; 68 | } 69 | 70 | .markdown .pl-bu { 71 | color: #b52a1d; 72 | } 73 | 74 | .markdown .pl-ii { 75 | color: #f8f8f8; 76 | background-color: #b52a1d; 77 | } 78 | 79 | .markdown .pl-c2 { 80 | color: #f8f8f8; 81 | background-color: #b52a1d; 82 | } 83 | 84 | .markdown .pl-c2::before { 85 | content: "^M"; 86 | } 87 | 88 | .markdown .pl-sr .pl-cce { 89 | font-weight: bold; 90 | color: #63a35c; 91 | } 92 | 93 | .markdown .pl-ml { 94 | color: #693a17; 95 | } 96 | 97 | .markdown .pl-mh, 98 | .markdown .pl-mh .pl-en, 99 | .markdown .pl-ms { 100 | font-weight: bold; 101 | color: #1d3e81; 102 | } 103 | 104 | .markdown .pl-mq { 105 | color: #008080; 106 | } 107 | 108 | .markdown .pl-mi { 109 | font-style: italic; 110 | color: #333; 111 | } 112 | 113 | .markdown .pl-mb { 114 | font-weight: bold; 115 | color: #333; 116 | } 117 | 118 | .markdown .pl-md { 119 | color: #bd2c00; 120 | background-color: #ffecec; 121 | } 122 | 123 | .markdown .pl-mi1 { 124 | color: #55a532; 125 | background-color: #eaffea; 126 | } 127 | 128 | .markdown .pl-mc { 129 | color: #ef9700; 130 | background-color: #ffe3b4; 131 | } 132 | 133 | .markdown .pl-mi2 { 134 | color: #d8d8d8; 135 | background-color: #808080; 136 | } 137 | 138 | .markdown .pl-mdr { 139 | font-weight: bold; 140 | color: #795da3; 141 | } 142 | 143 | .markdown .pl-mo { 144 | color: #1d3e81; 145 | } 146 | 147 | .markdown .pl-ba { 148 | color: #595e62; 149 | } 150 | 151 | .markdown .pl-sg { 152 | color: #c0c0c0; 153 | } 154 | 155 | .markdown .pl-corl { 156 | text-decoration: underline; 157 | color: #183691; 158 | } 159 | 160 | .markdown .octicon { 161 | display: inline-block; 162 | vertical-align: text-top; 163 | fill: currentColor; 164 | } 165 | 166 | .markdown a { 167 | background-color: transparent; 168 | -webkit-text-decoration-skip: objects; 169 | } 170 | 171 | .markdown a:active, 172 | .markdown a:hover { 173 | outline-width: 0; 174 | } 175 | 176 | .markdown strong { 177 | font-weight: inherit; 178 | } 179 | 180 | .markdown strong { 181 | font-weight: bolder; 182 | } 183 | 184 | .markdown h1 { 185 | font-size: 2em; 186 | margin: 0.67em 0; 187 | } 188 | 189 | .markdown img { 190 | border-style: none; 191 | } 192 | 193 | .markdown svg:not(:root) { 194 | overflow: hidden; 195 | } 196 | 197 | .markdown code, 198 | .markdown kbd, 199 | .markdown pre { 200 | font-family: monospace, monospace; 201 | font-size: 1em; 202 | } 203 | 204 | .markdown hr { 205 | box-sizing: content-box; 206 | height: 0; 207 | overflow: visible; 208 | } 209 | 210 | .markdown input { 211 | font: inherit; 212 | margin: 0; 213 | } 214 | 215 | .markdown input { 216 | overflow: visible; 217 | } 218 | 219 | .markdown [type="checkbox"] { 220 | box-sizing: border-box; 221 | padding: 0; 222 | } 223 | 224 | .markdown * { 225 | box-sizing: border-box; 226 | } 227 | 228 | .markdown input { 229 | font-family: inherit; 230 | font-size: inherit; 231 | line-height: inherit; 232 | } 233 | 234 | .markdown a { 235 | color: #0366d6; 236 | text-decoration: none; 237 | } 238 | 239 | .markdown a:hover { 240 | text-decoration: underline; 241 | } 242 | 243 | .markdown strong { 244 | font-weight: 600; 245 | } 246 | 247 | .markdown hr { 248 | height: 0; 249 | margin: 15px 0; 250 | overflow: hidden; 251 | background: transparent; 252 | border: 0; 253 | border-bottom: 1px solid #dfe2e5; 254 | } 255 | 256 | .markdown hr::before { 257 | display: table; 258 | content: ""; 259 | } 260 | 261 | .markdown hr::after { 262 | display: table; 263 | clear: both; 264 | content: ""; 265 | } 266 | 267 | .markdown table { 268 | border-spacing: 0; 269 | border-collapse: collapse; 270 | } 271 | 272 | .markdown td, 273 | .markdown th { 274 | padding: 0; 275 | } 276 | 277 | .markdown h1, 278 | .markdown h2, 279 | .markdown h3, 280 | .markdown h4, 281 | .markdown h5, 282 | .markdown h6 { 283 | margin-top: 0; 284 | margin-bottom: 0; 285 | } 286 | 287 | .markdown h1 { 288 | font-size: 32px; 289 | font-weight: 600; 290 | } 291 | 292 | .markdown h2 { 293 | font-size: 24px; 294 | font-weight: 600; 295 | } 296 | 297 | .markdown h3 { 298 | font-size: 20px; 299 | font-weight: 600; 300 | } 301 | 302 | .markdown h4 { 303 | font-size: 16px; 304 | font-weight: 600; 305 | } 306 | 307 | .markdown h5 { 308 | font-size: 14px; 309 | font-weight: 600; 310 | } 311 | 312 | .markdown h6 { 313 | font-size: 12px; 314 | font-weight: 600; 315 | } 316 | 317 | .markdown p { 318 | margin-top: 0; 319 | margin-bottom: 10px; 320 | } 321 | 322 | .markdown blockquote { 323 | margin: 0; 324 | } 325 | 326 | .markdown ul, 327 | .markdown ol { 328 | padding-left: 0; 329 | margin-top: 0; 330 | margin-bottom: 0; 331 | } 332 | 333 | .markdown ol ol, 334 | .markdown ul ol { 335 | list-style-type: lower-roman; 336 | } 337 | 338 | .markdown ul ul ol, 339 | .markdown ul ol ol, 340 | .markdown ol ul ol, 341 | .markdown ol ol ol { 342 | list-style-type: lower-alpha; 343 | } 344 | 345 | .markdown dd { 346 | margin-left: 0; 347 | } 348 | 349 | .markdown code { 350 | font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; 351 | font-size: 12px; 352 | } 353 | 354 | .markdown pre { 355 | margin-top: 0; 356 | margin-bottom: 0; 357 | font: 12px "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; 358 | } 359 | 360 | .markdown .octicon { 361 | vertical-align: text-bottom; 362 | } 363 | 364 | .markdown .pl-0 { 365 | padding-left: 0 !important; 366 | } 367 | 368 | .markdown .pl-1 { 369 | padding-left: 4px !important; 370 | } 371 | 372 | .markdown .pl-2 { 373 | padding-left: 8px !important; 374 | } 375 | 376 | .markdown .pl-3 { 377 | padding-left: 16px !important; 378 | } 379 | 380 | .markdown .pl-4 { 381 | padding-left: 24px !important; 382 | } 383 | 384 | .markdown .pl-5 { 385 | padding-left: 32px !important; 386 | } 387 | 388 | .markdown .pl-6 { 389 | padding-left: 40px !important; 390 | } 391 | 392 | .markdown::before { 393 | display: table; 394 | content: ""; 395 | } 396 | 397 | .markdown::after { 398 | display: table; 399 | clear: both; 400 | content: ""; 401 | } 402 | 403 | .markdown>*:first-child { 404 | margin-top: 0 !important; 405 | } 406 | 407 | .markdown>*:last-child { 408 | margin-bottom: 0 !important; 409 | } 410 | 411 | .markdown a:not([href]) { 412 | color: inherit; 413 | text-decoration: none; 414 | } 415 | 416 | .markdown .anchor { 417 | float: left; 418 | padding-right: 4px; 419 | margin-left: -20px; 420 | line-height: 1; 421 | } 422 | 423 | .markdown .anchor:focus { 424 | outline: none; 425 | } 426 | 427 | .markdown p, 428 | .markdown blockquote, 429 | .markdown ul, 430 | .markdown ol, 431 | .markdown dl, 432 | .markdown table, 433 | .markdown pre { 434 | margin-top: 0; 435 | margin-bottom: 16px; 436 | } 437 | 438 | .markdown hr { 439 | height: 0.25em; 440 | padding: 0; 441 | margin: 24px 0; 442 | background-color: #e1e4e8; 443 | border: 0; 444 | } 445 | 446 | .markdown blockquote { 447 | padding: 0 1em; 448 | color: #6a737d; 449 | border-left: 0.25em solid #dfe2e5; 450 | } 451 | 452 | .markdown blockquote>:first-child { 453 | margin-top: 0; 454 | } 455 | 456 | .markdown blockquote>:last-child { 457 | margin-bottom: 0; 458 | } 459 | 460 | .markdown kbd { 461 | display: inline-block; 462 | padding: 3px 5px; 463 | font-size: 11px; 464 | line-height: 10px; 465 | color: #444d56; 466 | vertical-align: middle; 467 | background-color: #fafbfc; 468 | border: solid 1px #c6cbd1; 469 | border-bottom-color: #959da5; 470 | border-radius: 3px; 471 | box-shadow: inset 0 -1px 0 #959da5; 472 | } 473 | 474 | .markdown h1, 475 | .markdown h2, 476 | .markdown h3, 477 | .markdown h4, 478 | .markdown h5, 479 | .markdown h6 { 480 | margin-top: 24px; 481 | margin-bottom: 16px; 482 | font-weight: 600; 483 | line-height: 1.25; 484 | } 485 | 486 | .markdown h1 .octicon-link, 487 | .markdown h2 .octicon-link, 488 | .markdown h3 .octicon-link, 489 | .markdown h4 .octicon-link, 490 | .markdown h5 .octicon-link, 491 | .markdown h6 .octicon-link { 492 | color: #1b1f23; 493 | vertical-align: middle; 494 | visibility: hidden; 495 | } 496 | 497 | .markdown h1:hover .anchor, 498 | .markdown h2:hover .anchor, 499 | .markdown h3:hover .anchor, 500 | .markdown h4:hover .anchor, 501 | .markdown h5:hover .anchor, 502 | .markdown h6:hover .anchor { 503 | text-decoration: none; 504 | } 505 | 506 | .markdown h1:hover .anchor .octicon-link, 507 | .markdown h2:hover .anchor .octicon-link, 508 | .markdown h3:hover .anchor .octicon-link, 509 | .markdown h4:hover .anchor .octicon-link, 510 | .markdown h5:hover .anchor .octicon-link, 511 | .markdown h6:hover .anchor .octicon-link { 512 | visibility: visible; 513 | } 514 | 515 | .markdown h1 { 516 | padding-bottom: 0.3em; 517 | font-size: 2em; 518 | border-bottom: 1px solid #eaecef; 519 | } 520 | 521 | .markdown h2 { 522 | padding-bottom: 0.3em; 523 | font-size: 1.5em; 524 | border-bottom: 1px solid #eaecef; 525 | } 526 | 527 | .markdown h3 { 528 | font-size: 1.25em; 529 | } 530 | 531 | .markdown h4 { 532 | font-size: 1em; 533 | } 534 | 535 | .markdown h5 { 536 | font-size: 0.875em; 537 | } 538 | 539 | .markdown h6 { 540 | font-size: 0.85em; 541 | color: #6a737d; 542 | } 543 | 544 | .markdown ul, 545 | .markdown ol { 546 | padding-left: 2em; 547 | } 548 | 549 | .markdown ul ul, 550 | .markdown ul ol, 551 | .markdown ol ol, 552 | .markdown ol ul { 553 | margin-top: 0; 554 | margin-bottom: 0; 555 | } 556 | 557 | .markdown li>p { 558 | margin-top: 16px; 559 | } 560 | 561 | .markdown li+li { 562 | margin-top: 0.25em; 563 | } 564 | 565 | .markdown dl { 566 | padding: 0; 567 | } 568 | 569 | .markdown dl dt { 570 | padding: 0; 571 | margin-top: 16px; 572 | font-size: 1em; 573 | font-style: italic; 574 | font-weight: 600; 575 | } 576 | 577 | .markdown dl dd { 578 | padding: 0 16px; 579 | margin-bottom: 16px; 580 | } 581 | 582 | .markdown table { 583 | display: block; 584 | width: 100%; 585 | overflow: auto; 586 | } 587 | 588 | .markdown table th { 589 | font-weight: 600; 590 | } 591 | 592 | .markdown table th, 593 | .markdown table td { 594 | padding: 6px 13px; 595 | border: 1px solid #dfe2e5; 596 | } 597 | 598 | .markdown table tr { 599 | background-color: #fff; 600 | border-top: 1px solid #c6cbd1; 601 | } 602 | 603 | .markdown table tr:nth-child(2n) { 604 | background-color: #f6f8fa; 605 | } 606 | 607 | .markdown img { 608 | max-width: 100%; 609 | box-sizing: content-box; 610 | background-color: #fff; 611 | } 612 | 613 | .markdown code { 614 | padding: 0; 615 | padding-top: 0.2em; 616 | padding-bottom: 0.2em; 617 | margin: 0; 618 | font-size: 85%; 619 | background-color: rgba(27,31,35,0.05); 620 | border-radius: 3px; 621 | } 622 | 623 | .markdown code::before, 624 | .markdown code::after { 625 | letter-spacing: -0.2em; 626 | content: "\00a0"; 627 | } 628 | 629 | .markdown pre { 630 | word-wrap: normal; 631 | } 632 | 633 | .markdown pre>code { 634 | padding: 0; 635 | margin: 0; 636 | font-size: 100%; 637 | word-break: normal; 638 | white-space: pre; 639 | background: transparent; 640 | border: 0; 641 | } 642 | 643 | .markdown .highlight { 644 | margin-bottom: 16px; 645 | } 646 | 647 | .markdown .highlight pre { 648 | margin-bottom: 0; 649 | word-break: normal; 650 | } 651 | 652 | .markdown .highlight pre, 653 | .markdown pre { 654 | padding: 16px; 655 | overflow: auto; 656 | font-size: 85%; 657 | line-height: 1.45; 658 | background-color: #f6f8fa; 659 | border-radius: 3px; 660 | } 661 | 662 | .markdown pre code { 663 | display: inline; 664 | max-width: auto; 665 | padding: 0; 666 | margin: 0; 667 | overflow: visible; 668 | line-height: inherit; 669 | word-wrap: normal; 670 | background-color: transparent; 671 | border: 0; 672 | } 673 | 674 | .markdown pre code::before, 675 | .markdown pre code::after { 676 | content: normal; 677 | } 678 | 679 | .markdown .full-commit .btn-outline:not(:disabled):hover { 680 | color: #005cc5; 681 | border-color: #005cc5; 682 | } 683 | 684 | .markdown kbd { 685 | display: inline-block; 686 | padding: 3px 5px; 687 | font: 11px "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; 688 | line-height: 10px; 689 | color: #444d56; 690 | vertical-align: middle; 691 | background-color: #fcfcfc; 692 | border: solid 1px #c6cbd1; 693 | border-bottom-color: #959da5; 694 | border-radius: 3px; 695 | box-shadow: inset 0 -1px 0 #959da5; 696 | } 697 | 698 | .markdown :checked+.radio-label { 699 | position: relative; 700 | z-index: 1; 701 | border-color: #0366d6; 702 | } 703 | 704 | .markdown .task-list-item { 705 | list-style-type: none; 706 | } 707 | 708 | .markdown .task-list-item+.task-list-item { 709 | margin-top: 3px; 710 | } 711 | 712 | .markdown .task-list-item input { 713 | margin: 0 0.2em 0.25em -1.6em; 714 | vertical-align: middle; 715 | } 716 | 717 | .markdown hr { 718 | border-bottom-color: #eee; 719 | } 720 | -------------------------------------------------------------------------------- /controllers/badge.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /** 3 | * GET Badges 4 | * Controllers for badge routes 5 | * (C) Habib Rehman 6 | ******************************/ 7 | 8 | // Deps 9 | const req = require('request-promise-native'), 10 | logger = require('winston'), 11 | numeral = require('numeral'), 12 | parseLink = require('parse-link-header'), 13 | _ = require('lodash'), 14 | querystring = require('querystring') 15 | const {inspect} = require('util') 16 | 17 | // GitHub API Constants 18 | const GH_API_BASE_URI = 'https://api.github.com/repos/%s/%s/releases', 19 | GH_API_STATUS_URI = 'https://api.github.com/rate_limit', 20 | GH_API_OAUTH_TOKEN = process.env.GH_API_OAUTH_TOKEN || '', 21 | GH_API_PAGE_SIZE = 100, 22 | GH_API_TAGS_RPATH = 'tags', 23 | GH_API_DEFAULT_RPATH = 'latest' 24 | 25 | // Params 26 | const SHIELDS_URI_PARAM = 'badge', 27 | ALLOWED_PARAMS = [SHIELDS_URI_PARAM] 28 | 29 | // Defaults 30 | const DEFAULT_SHIELDS_URI = 'https://img.shields.io/badge/downloads-%s-green.svg', 31 | DEFAULT_PLACEHOLDER = 'X', 32 | DEFAULT_BADGE_SUFFIX = 'latest', 33 | DEFAULT_BADGE_TOTAL_SUFFIX = 'total', 34 | DEFAULT_REQ_UA = 'Badge Getter', 35 | DEFAULT_MIME_TYPE = 'image/svg+xml', 36 | DEFAULT_DB_UPDATE_OPTS = { 37 | returnOriginal: false 38 | } 39 | 40 | // HTTP stuff 41 | const HTTP_NOT_MODIFIED_CODE = 304, 42 | HTTP_FORBIDDEN_CODE = 403, 43 | HTTP_OK_CODE = 200, 44 | HTTP_PARTIAL_CONTENT_CODE = 206, 45 | HTTP_OK_REGEX = /^2/ 46 | 47 | const SUB_REGEX = /%s/g 48 | // Min update interval to update cache (request-driven) 49 | const UPDATE_INTERVAL = 60 * 60 * 1000 // 1 hour 50 | 51 | // String template substition 52 | function sub (str, ...subs) { 53 | let n = 0 54 | return str.replace(SUB_REGEX, () => { 55 | return subs[n++] 56 | }) 57 | } 58 | 59 | 60 | /** 61 | * Debug/Logging utils 62 | */ 63 | 64 | function inspectObj (obj) { 65 | return inspect(obj, { depth: null, colors: true }) 66 | } 67 | 68 | // Custom log for debugging 69 | function debug (obj, ...args) { 70 | logger.log('debug', ...args, inspectObj(obj)) 71 | } 72 | 73 | // Log the outcome of a db operation 74 | function logOutcome (val, equal, op, url) { 75 | if (_.isEqual(val, equal)) { 76 | logger.info(`${url} ${op} successful`) 77 | } else { 78 | logger.error(`${url} ${op} unsuccessfull`) 79 | } 80 | } 81 | 82 | 83 | /** 84 | * Mongodb Document builders 85 | */ 86 | 87 | function downloadDoc (path, ghURI, ghId, ghTag, ...fields) { 88 | let doc = { 89 | ghETAG: fields[0], 90 | ghLastMod: fields[1], 91 | count: fields[2], 92 | lastUpdated: fields[3], 93 | requests: fields[4] 94 | } 95 | if (path) doc.path = path 96 | if (ghURI) doc.ghURI = ghURI 97 | if (ghId) doc.ghId = ghId 98 | if (ghTag) doc.ghTag = ghTag 99 | return doc 100 | } 101 | 102 | function totalDownloadDoc (path, ghURI, ...fields) { 103 | let doc = { 104 | pages: fields[0], 105 | lastPage: fields[1], 106 | count: fields[2], 107 | lastUpdated: fields[3], 108 | requests: fields[4] 109 | } 110 | if (path) doc.path = path 111 | if (ghURI) doc.ghURI = ghURI 112 | return doc 113 | } 114 | 115 | function pageDoc (...fields) { 116 | return { 117 | page: fields[0], 118 | ghETAG: fields[1], 119 | ghURI: fields[2], 120 | lastUpdated: fields[3], 121 | count: fields[4] 122 | } 123 | } 124 | 125 | 126 | /** 127 | * Request builders 128 | */ 129 | 130 | // Builds request options for GET badge calls 131 | function buildBadgeOpts (URI) { 132 | return { 133 | uri: URI, 134 | headers: { 135 | 'User-Agent': DEFAULT_REQ_UA 136 | }, 137 | resolveWithFullResponse: true, 138 | json: true 139 | } 140 | } 141 | 142 | // Builds request options for GH API calls 143 | function buildGhApiOpts (URI, ifReqVal) { 144 | let ghApiOpts = { 145 | uri: URI, 146 | headers: { 147 | // Required for GitHub API 148 | 'User-Agent': DEFAULT_REQ_UA, 149 | 'Accept': 'application/vnd.github.v3.full+json' 150 | }, 151 | resolveWithFullResponse: true, 152 | json: true 153 | } 154 | // Only ad oauth token if set 155 | if(GH_API_OAUTH_TOKEN) { 156 | ghApiOpts['Authorization'] = `token ${GH_API_OAUTH_TOKEN}` 157 | } 158 | if (ifReqVal) { 159 | // Merge opts 160 | return _.merge(ghApiOpts, { 161 | simple: false, // to handle response filteration 162 | headers: { 163 | // For conditional request 164 | 'If-None-Match': ifReqVal 165 | } 166 | }) 167 | } 168 | return ghApiOpts 169 | } 170 | 171 | // Builds the GitHub API Request URI 172 | function buildGhURI (owner, repo, ...paths) { 173 | const apiReqBase = sub(GH_API_BASE_URI, owner, repo) 174 | if (_.isEmpty(paths)) return apiReqBase 175 | return `${apiReqBase}/${paths.join('/')}` 176 | } 177 | 178 | // Build querystring (for pagination) 179 | function buildPageQsURI (uri, page) { 180 | let qs = { 181 | per_page: GH_API_PAGE_SIZE 182 | } 183 | if (page) qs.page = page 184 | return `${uri}?${querystring.stringify(qs)}` 185 | } 186 | 187 | // Build shield uri 188 | function buildShieldsURI (shieldsURI, suffix, downloadCount) { 189 | shieldsURI = shieldsURI || DEFAULT_SHIELDS_URI 190 | let shieldsReqURI = sub(shieldsURI, numeral(downloadCount).format().concat(' ', suffix)) 191 | debug(encodeURI(shieldsReqURI), 'shieldsReqURI: ') 192 | return encodeURI(shieldsReqURI) 193 | } 194 | 195 | /** 196 | * Response setters 197 | */ 198 | 199 | function setResponseHeaders (ctx) { 200 | let now = (new Date()).toUTCString() 201 | // Allow badge to be accessed from anywhere 202 | ctx.set('Access-Control-Allow-Origin', '*') 203 | // Prevent GitHub from caching this 204 | ctx.set('Cache-Control', 'no-cache') 205 | ctx.set('Pragma', 'no-cache') 206 | ctx.set('Expires', now) 207 | // ;) 208 | ctx.set('Via', 'Badged App :)') 209 | } 210 | 211 | // Badge response 212 | function badgeResponse (ctx, badge) { 213 | setResponseHeaders(ctx) 214 | ctx.status = HTTP_OK_CODE 215 | ctx.type = badge.type 216 | ctx.body = badge.body 217 | } 218 | 219 | // GET Badge error response 220 | async function errBadgeResponse (ctx) { 221 | setResponseHeaders(ctx) 222 | ctx.status = HTTP_PARTIAL_CONTENT_CODE 223 | ctx.type = DEFAULT_MIME_TYPE 224 | ctx.body = await ctx.render('badge', {count: DEFAULT_PLACEHOLDER, writeResp: false}) 225 | } 226 | 227 | /** 228 | * Validators 229 | */ 230 | 231 | // Checks if request url is valid 232 | function isReqValid (reqUrlQuery) { 233 | // Check if only allowed params passed 234 | return _.isEmpty(_.omit(reqUrlQuery, ALLOWED_PARAMS)) 235 | } 236 | 237 | // Check if an updated is required (for cache) 238 | function isUpdateRequired (lastUpdated) { 239 | const sinceLastUpdated = (Date.now() - new Date(lastUpdated)) 240 | return sinceLastUpdated > UPDATE_INTERVAL 241 | } 242 | 243 | 244 | /** 245 | * Requesters 246 | */ 247 | 248 | // Gets the badge from the passed shields URI 249 | function getBadge (shieldsReqURI) { 250 | return req(buildBadgeOpts(shieldsReqURI)) 251 | .then((res) => { 252 | return {type: res.headers['content-type'], body: res.body} 253 | }) 254 | } 255 | 256 | // Calculates the total download count for a single release 257 | function getReleaseDownloadCount (release) { 258 | return release.assets.reduce((acc, r) => { 259 | return acc + r.download_count 260 | }, 0) 261 | } 262 | 263 | // Gets the total download count 264 | function getDownloadCount (pageBody) { 265 | if (_.isArray(pageBody)) { 266 | // Array of releases 267 | // Calculate download count for each release 268 | let totalCountArr = pageBody.map((release) => getReleaseDownloadCount(release)) 269 | // Calculate total download count for all releases and return 270 | return _.sum(totalCountArr) 271 | } else { 272 | // Single release 273 | // Calculate download count for a single release 274 | return getReleaseDownloadCount(pageBody) 275 | } 276 | } 277 | 278 | // Gets the release data with count 279 | function fetchDownloadData (url, extraOpts) { 280 | return req(buildGhApiOpts(url, extraOpts)) 281 | .then((res) => { 282 | res.count = getDownloadCount(res.body) 283 | return res 284 | }) 285 | } 286 | 287 | 288 | /** 289 | * Get a page 290 | * Handle GET page (pagination) response to yield a page doc 291 | */ 292 | function getPage (pageNo, fetchedPage, cachedPage) { 293 | switch (true) { 294 | case fetchedPage.statusCode === HTTP_NOT_MODIFIED_CODE: 295 | // Has not been modified 296 | logger.debug(`Page ${pageNo} has NOT changed. Serving from cache...`) 297 | // Serve from cache 298 | return cachedPage 299 | 300 | case fetchedPage.statusCode === HTTP_FORBIDDEN_CODE: 301 | // GitHub API Limit reached 302 | 303 | // Throw error if uncached page 304 | if (!cachedPage) throw new Error(`${fetchedPage.statusCode}: GH API Limit reached`) 305 | // Serve from cache 306 | return cachedPage 307 | 308 | case HTTP_OK_REGEX.test(fetchedPage.statusCode.toString()): 309 | // Has been modified or is new 310 | logger.debug(`Page ${pageNo} HAS changed. Updating cache & serving...`) 311 | 312 | // Create page 313 | let downloadCount = getDownloadCount(fetchedPage.body) 314 | debug(fetchedPage.request.href, 'Fetched ') 315 | // Serve from freshly fetched data 316 | return pageDoc(pageNo, fetchedPage.headers.etag, fetchedPage.request.href, fetchedPage.headers.date, downloadCount) 317 | 318 | default: 319 | let unknownError = new Error(`Got bad response ${fetchedPage.headers.status}, 320 | full response`, '\n', inspectObj(fetchedPage)) 321 | // Unknown error occurred 322 | logger.error(unknownError) 323 | // Throw error if uncached page 324 | if (!cachedPage) throw unknownError 325 | // Still serve from cache 326 | return cachedPage 327 | } 328 | } 329 | 330 | 331 | /** 332 | * Get the badge data of total (#downloads) 333 | * From the GitHub API 334 | */ 335 | async function fetchTotalDownloadData (ghURI, cachedData) { 336 | const firstPageURI = buildPageQsURI(ghURI) 337 | const firstPageNo = 1 338 | const firstCachedPage = _.hasIn(cachedData, `pages[${firstPageNo}]`) ? cachedData.pages[firstPageNo] : null 339 | const firstPageETAG = cachedData ? firstCachedPage.ghETAG : null 340 | const firstPage = await req(buildGhApiOpts(firstPageURI, firstPageETAG)) 341 | const lastUpdated = firstPage.headers.date 342 | let pages = {} // page map 343 | let count = 0 // total download count 344 | let lastPage = 1 // last page is the first by default 345 | 346 | pages[firstPageNo] = getPage(firstPageNo, firstPage, firstCachedPage) 347 | 348 | debug(firstPageURI, 'firstPageURI:') 349 | 350 | if (_.has(firstPage, 'headers.link')) { 351 | // Response is paginated 352 | logger.debug(`Response IS paginated`) 353 | let linkHeader = parseLink(firstPage.headers.link) 354 | lastPage = parseInt(linkHeader.last.page) 355 | 356 | // Traverse pages (fetch all in parallel) excl. first page 357 | const getPagePromises = _.rangeRight(lastPage, firstPageNo).map(async pageNo => { 358 | let pageURI = buildPageQsURI(ghURI, pageNo) 359 | // Only use etag when cachedData passed 360 | // If page exists use it otherwise create a new one 361 | let cachedPage = _.hasIn(cachedData, `pages[${pageNo}]`) ? cachedData.pages[pageNo] : null 362 | let fetchPageETAG = cachedPage ? cachedPage.ghETAG : null 363 | let fetchedPage = await req(buildGhApiOpts(pageURI, fetchPageETAG)) 364 | pages[pageNo] = getPage(pageNo, fetchedPage, cachedPage) 365 | return 1 366 | }) 367 | 368 | // Fetch the pages in sequence 369 | for (const getPagePromise of getPagePromises) { 370 | await getPagePromise 371 | } 372 | 373 | debug(pages, 'Pages is:\n') 374 | } else { 375 | // Response is not paginated 376 | logger.debug(`Response is NOT paginated`) 377 | } 378 | 379 | // Sum the download count for each page to calc total 380 | for (let page in pages) { 381 | count += pages[page].count 382 | } 383 | 384 | debug(count, 'Total download count:') 385 | 386 | return {count, pages, lastPage, lastUpdated} 387 | } 388 | 389 | 390 | /** 391 | * Get the badge data of total (#downloads) 392 | * From cache or via GitHub API 393 | */ 394 | async function getBadgeTotalData (ghURI, downloads, findFilter, path) { 395 | // finds by the request path by default 396 | findFilter = findFilter || {path} 397 | // Query cache for existence 398 | const cachedTotalDownloadData = await downloads.findOne(findFilter) 399 | 400 | debug(cachedTotalDownloadData, 'Cached data:', '\n') 401 | 402 | // Check if in cache 403 | if (_.isEmpty(cachedTotalDownloadData)) { 404 | // Not in cache so fetch 405 | logger.debug(path, `NOT IN cache. Fetching...`) 406 | 407 | // Get the latest total download data 408 | const totalDownloadData = await fetchTotalDownloadData(ghURI) 409 | 410 | // Update cache 411 | const outcome = await downloads.insertOne(totalDownloadDoc( 412 | path, 413 | ghURI, 414 | totalDownloadData.pages, 415 | totalDownloadData.lastPage, 416 | totalDownloadData.count, 417 | totalDownloadData.lastUpdated, 418 | 1 419 | )) 420 | 421 | logOutcome(outcome.insertedCount, 1, 'insert', path) 422 | 423 | return totalDownloadData.count 424 | } else { 425 | // In cache 426 | logger.debug(path, `IN cache`) 427 | 428 | 429 | // Check if the cache needs to be updated 430 | if (isUpdateRequired(cachedTotalDownloadData.lastUpdated)) { 431 | // Update cache 432 | logger.debug(path, 'Update required') 433 | 434 | // Get the latest total download data 435 | const totalDownloadData = await fetchTotalDownloadData(ghURI, cachedTotalDownloadData) 436 | 437 | if (totalDownloadData.count !== cachedTotalDownloadData.count) { 438 | // Data has changed so update cache 439 | logger.debug(path, 'Data HAS changed') 440 | 441 | const updatedDoc = totalDownloadDoc( 442 | null, 443 | null, 444 | totalDownloadData.pages, 445 | totalDownloadData.lastPage, 446 | totalDownloadData.count, 447 | totalDownloadData.lastUpdated, 448 | ++cachedTotalDownloadData.requests 449 | ) 450 | 451 | // Update cache 452 | const outcome = await downloads.updateOne(findFilter, {$set: updatedDoc}, DEFAULT_DB_UPDATE_OPTS) 453 | logOutcome(outcome.modifiedCount, 1, 'update totalDownload', path) 454 | 455 | // Serve from freshly fetched data 456 | return totalDownloadData.count 457 | } else { 458 | // Cache has not changed 459 | logger.debug(path, 'Data has NOT changed') 460 | // Serve from cache 461 | return cachedTotalDownloadData.count 462 | } 463 | } else { 464 | // No update required 465 | logger.debug(path, 'No update required') 466 | // Serve from cache 467 | return cachedTotalDownloadData.count 468 | } 469 | 470 | } 471 | } 472 | 473 | 474 | /** 475 | * Get the badge data (#downloads) 476 | * From cache or via GitHub API 477 | */ 478 | async function getBadgeData (ghURI, downloads, findFilter, path) { 479 | // finds by the request path by default 480 | findFilter = findFilter || {path} 481 | // Query cache for existence 482 | const cachedDownloadData = await downloads.findOne(findFilter) 483 | 484 | debug(cachedDownloadData, 'Cached data:', '\n') 485 | 486 | // Check if in cache 487 | if (_.isEmpty(cachedDownloadData)) { 488 | // Not in cache so fetch 489 | logger.debug(path, `NOT IN cache`) 490 | 491 | // Get downloads for a release 492 | 493 | // Fetch release data 494 | var downloadData = await fetchDownloadData(ghURI) 495 | 496 | // Update cache 497 | var outcome = await downloads.insertOne(downloadDoc( 498 | path, 499 | ghURI, 500 | downloadData.body.id, 501 | downloadData.body.tag_name, 502 | downloadData.headers.etag, 503 | downloadData.headers['last-modified'], 504 | downloadData.count, 505 | downloadData.headers.date, 506 | 1 507 | )) 508 | 509 | logOutcome(outcome.insertedCount, 1, 'insert', path) 510 | 511 | return downloadData.count 512 | } else { 513 | // In cache 514 | logger.debug(path, `IN cache`) 515 | 516 | // Check if the cache needs to be updated 517 | if (isUpdateRequired(cachedDownloadData.lastUpdated)) { 518 | // Update cache 519 | 520 | // Fetch release data 521 | let downloadData = await req(buildGhApiOpts(ghURI, cachedDownloadData.ghETAG)) 522 | const statusCode = downloadData.statusCode 523 | 524 | debug(downloadData.headers.status, `Got response`) 525 | // Check if outdated 526 | switch (true) { 527 | case statusCode === HTTP_NOT_MODIFIED_CODE: 528 | // Has not been modified 529 | logger.debug(path, `has NOT changed. Serving from cache...`) 530 | // Update cache requests 531 | var outcome = await downloads.updateOne(findFilter, {$inc: {requests: 1}}, DEFAULT_DB_UPDATE_OPTS) 532 | logOutcome(outcome.modifiedCount, 1, 'update requests', path) 533 | // Serve from cache 534 | return cachedDownloadData.count 535 | 536 | case statusCode === HTTP_FORBIDDEN_CODE: 537 | // GitHub API Limit reached 538 | var outcome = await downloads.updateOne(findFilter, {$inc: {requests: 1}}, DEFAULT_DB_UPDATE_OPTS) 539 | logOutcome(outcome.modifiedCount, 1, 'update requests', path) 540 | // Serve from cache 541 | return cachedDownloadData.count 542 | 543 | case HTTP_OK_REGEX.test(statusCode.toString()): 544 | // Has been modified 545 | logger.debug(path, `HAS changed. Updating cache & serving...`) 546 | downloadData.count = getDownloadCount(downloadData.body) 547 | 548 | let updatedDownload = downloadDoc( 549 | null, 550 | null, 551 | downloadData.body.id, 552 | downloadData.body.tag_name, 553 | downloadData.headers.etag, 554 | downloadData.headers['last-modified'], 555 | downloadData.count, 556 | downloadData.headers.date, 557 | ++cachedDownloadData.requests 558 | ) 559 | 560 | // Update cache 561 | var outcome = await downloads.updateOne(findFilter, {$set: updatedDownload}, DEFAULT_DB_UPDATE_OPTS) 562 | logOutcome(outcome.modifiedCount, 1, 'update download', path) 563 | // Serve from freshly fetched data 564 | return downloadData.count 565 | 566 | default: 567 | // Unknown error occurred 568 | logger.error(new Error(`Got bad response ${downloadData.headers.status}, 569 | full response`, '\n', inspectObj(downloadData))) 570 | // Still serve from cache 571 | return cachedDownloadData.count 572 | } 573 | } else { 574 | // No update required 575 | logger.debug('No update required') 576 | // Serve from cache 577 | return cachedDownloadData.count 578 | } 579 | } 580 | } 581 | 582 | 583 | 584 | /** 585 | * Controllers 586 | *************/ 587 | 588 | /** 589 | * GET a badge for a single release 590 | * DEFAULTs to latest 591 | */ 592 | exports.release = async function(ctx, next) { 593 | const ghURI = buildGhURI(ctx.params.owner, ctx.params.repo, GH_API_DEFAULT_RPATH) 594 | 595 | logger.info(`GH API Request URL ${ghURI}`) 596 | 597 | try { 598 | let badgeDownloads = await getBadgeData(ghURI, ctx.downloads, null, ctx.path) 599 | let badge = await getBadge(buildShieldsURI(ctx.query[SHIELDS_URI_PARAM], DEFAULT_BADGE_SUFFIX, badgeDownloads)) 600 | // Response 601 | badgeResponse(ctx, badge) 602 | } catch (e) { 603 | logger.error(e) 604 | await errBadgeResponse(ctx) 605 | } 606 | } 607 | 608 | 609 | /** 610 | * GET a badge for a single release by id 611 | * DEFAULTs to latest 612 | */ 613 | exports.releaseById = async function(ctx, next) { 614 | const ghURI = buildGhURI(ctx.params.owner, ctx.params.repo, ctx.params.id) 615 | 616 | logger.info(`GH API Request URL ${ghURI}`) 617 | 618 | try { 619 | let badgeDownloads = await getBadgeData(ghURI, ctx.downloads, {ghId: parseInt(ctx.params.id)}, ctx.path) 620 | let badge = await getBadge(buildShieldsURI(ctx.query[SHIELDS_URI_PARAM], ctx.params.id, badgeDownloads)) 621 | // Response 622 | badgeResponse(ctx, badge) 623 | } catch (e) { 624 | logger.error(e) 625 | await errBadgeResponse(ctx) 626 | } 627 | } 628 | 629 | 630 | /** 631 | * GET a badge for a single release by tag 632 | * DEFAULTs to latest 633 | */ 634 | exports.releaseByTag = async function(ctx, next) { 635 | const ghURI = buildGhURI(ctx.params.owner, ctx.params.repo, GH_API_TAGS_RPATH , ctx.params.tag) 636 | 637 | logger.info(`GH API Request URL ${ghURI}`) 638 | 639 | try { 640 | let badgeDownloads = await getBadgeData(ghURI, ctx.downloads, {ghTag: ctx.params.tag}, ctx.path) 641 | let badge = await getBadge(buildShieldsURI(ctx.query[SHIELDS_URI_PARAM], ctx.params.tag, badgeDownloads)) 642 | // Response 643 | badgeResponse(ctx, badge) 644 | } catch (e) { 645 | logger.error(e) 646 | await errBadgeResponse(ctx) 647 | } 648 | } 649 | 650 | 651 | /** 652 | * GET a badge of all-time total download count (all releases) 653 | */ 654 | exports.total = async function(ctx, next) { 655 | const ghURI = buildGhURI(ctx.params.owner, ctx.params.repo) 656 | 657 | logger.info(`GH API Request URL ${ghURI}`) 658 | 659 | try { 660 | let badgeDownloads = await getBadgeTotalData(ghURI, ctx.downloads, null, ctx.path) 661 | let badge = await getBadge(buildShieldsURI(ctx.query[SHIELDS_URI_PARAM], DEFAULT_BADGE_TOTAL_SUFFIX, badgeDownloads)) 662 | // Response 663 | badgeResponse(ctx, badge) 664 | } catch (e) { 665 | logger.error(e) 666 | await errBadgeResponse(ctx) 667 | } 668 | } 669 | 670 | 671 | /** 672 | * GET status of GH API usage 673 | */ 674 | exports.status = async function(ctx, next) { 675 | try { 676 | let res = await req(buildGhApiOpts(GH_API_STATUS_URI)) 677 | let resBody = 'GH API Status
' 678 | for (var stat in res.body.rate) { 679 | if (stat === 'reset') resBody += `${stat}: ${new Date(res.body.rate[stat] * 1000).toLocaleString()}
` 680 | else resBody += `${stat}: ${res.body.rate[stat]}
` 681 | } 682 | ctx.type = 'text/html' 683 | ctx.body = resBody 684 | } catch (e) { 685 | logger.error(e) 686 | } 687 | } 688 | --------------------------------------------------------------------------------