├── .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 |
--------------------------------------------------------------------------------
/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 |
4 |
5 | Badged
6 |
7 |
8 |
9 |
10 |
11 | Customizable GitHub Release downloads badge service
12 |
13 |
14 |
15 |
17 |
18 |
19 |
21 |
22 |
23 |
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 |
61 | ```
62 | Markdown
63 | ```markdown
64 | 
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` 
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` 
113 | - Downloads badge for release by id `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` 
115 | - Downloads badge for all releases `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 |
--------------------------------------------------------------------------------