Compare commits
25 Commits
server-202
...
express
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc68b88a04 | ||
|
|
ca6ae88504 | ||
|
|
fc13e8e90a | ||
|
|
1761aab020 | ||
|
|
416ef3920a | ||
|
|
25ab4806c0 | ||
|
|
9d6b3e0985 | ||
|
|
18c76e392f | ||
|
|
78b886c010 | ||
|
|
b19ca203e8 | ||
|
|
00df3e1136 | ||
|
|
b4f7ec383e | ||
|
|
6d77534709 | ||
|
|
e8f59a2645 | ||
|
|
8cbc8cb926 | ||
|
|
be9d49083d | ||
|
|
aa046cb510 | ||
|
|
903bef2a4c | ||
|
|
15043dfc92 | ||
|
|
cf6b5b14c7 | ||
|
|
aeebfaa51f | ||
|
|
c3097aad0d | ||
|
|
6e6cec9b2b | ||
|
|
7c071e352e | ||
|
|
6d72fd68e8 |
14
.github/actions/close-bot/package-lock.json
generated
vendored
14
.github/actions/close-bot/package-lock.json
generated
vendored
@@ -9,14 +9,14 @@
|
||||
"version": "0.0.0",
|
||||
"license": "CC0",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.7.0",
|
||||
"@actions/core": "^1.6.0",
|
||||
"@actions/github": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/core": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.7.0.tgz",
|
||||
"integrity": "sha512-7fPSS7yKOhTpgLMbw7lBLc1QJWvJBBAgyTX2PEhagWcKK8t0H8AKCoPMfnrHqIm5cRYH4QFPqD1/ruhuUE7YcQ==",
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.6.0.tgz",
|
||||
"integrity": "sha512-NB1UAZomZlCV/LmJqkLhNTqtKfFXJZAUPcfl/zqG7EfsQdeUJtaWO98SGbuQ3pydJ3fHl2CvI/51OKYlCYYcaw==",
|
||||
"dependencies": {
|
||||
"@actions/http-client": "^1.0.11"
|
||||
}
|
||||
@@ -226,9 +226,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.7.0.tgz",
|
||||
"integrity": "sha512-7fPSS7yKOhTpgLMbw7lBLc1QJWvJBBAgyTX2PEhagWcKK8t0H8AKCoPMfnrHqIm5cRYH4QFPqD1/ruhuUE7YcQ==",
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.6.0.tgz",
|
||||
"integrity": "sha512-NB1UAZomZlCV/LmJqkLhNTqtKfFXJZAUPcfl/zqG7EfsQdeUJtaWO98SGbuQ3pydJ3fHl2CvI/51OKYlCYYcaw==",
|
||||
"requires": {
|
||||
"@actions/http-client": "^1.0.11"
|
||||
}
|
||||
|
||||
2
.github/actions/close-bot/package.json
vendored
2
.github/actions/close-bot/package.json
vendored
@@ -10,7 +10,7 @@
|
||||
"author": "chris48s",
|
||||
"license": "CC0",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.7.0",
|
||||
"@actions/core": "^1.6.0",
|
||||
"@actions/github": "^5.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,6 @@ Note: this changelog is for the shields.io server. The changelog for the badge-m
|
||||
|
||||
---
|
||||
|
||||
## server-2022-05-03
|
||||
|
||||
- [OSSFScorecard] Create scorecard badge service [#7687](https://github.com/badges/shields/issues/7687)
|
||||
- Stringify [githublanguagecount] message [#7881](https://github.com/badges/shields/issues/7881)
|
||||
- Stringify and trim whitespace from a few services [#7880](https://github.com/badges/shields/issues/7880)
|
||||
- add labels to Dockerfile [#7862](https://github.com/badges/shields/issues/7862)
|
||||
- handle missing 'fly-client-ip' [#7814](https://github.com/badges/shields/issues/7814)
|
||||
- Dependency updates
|
||||
|
||||
## server-2022-04-03
|
||||
|
||||
- Breaking change: This release updates ioredis from v4 to v5.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use strict'
|
||||
|
||||
const { normalizeColor, toSvgColor } = require('./color')
|
||||
const { toSvgColor } = require('./color')
|
||||
const badgeRenderers = require('./badge-renderers')
|
||||
const { stripXmlWhitespace } = require('./xml')
|
||||
|
||||
@@ -9,7 +9,6 @@ note: makeBadge() is fairly thinly wrapped so if we are making changes here
|
||||
it is likely this will impact on the package's public interface in index.js
|
||||
*/
|
||||
module.exports = function makeBadge({
|
||||
format,
|
||||
style = 'flat',
|
||||
label,
|
||||
message,
|
||||
@@ -24,22 +23,6 @@ module.exports = function makeBadge({
|
||||
label = `${label}`.trim()
|
||||
message = `${message}`.trim()
|
||||
|
||||
// This ought to be the responsibility of the server, not `makeBadge`.
|
||||
if (format === 'json') {
|
||||
return JSON.stringify({
|
||||
label,
|
||||
message,
|
||||
logoWidth,
|
||||
// Only call normalizeColor for the JSON case: this is handled
|
||||
// internally by toSvgColor in the SVG case.
|
||||
color: normalizeColor(color),
|
||||
labelColor: normalizeColor(labelColor),
|
||||
link: links,
|
||||
name: label,
|
||||
value: message,
|
||||
})
|
||||
}
|
||||
|
||||
const render = badgeRenderers[style]
|
||||
if (!render) {
|
||||
throw new Error(`Unknown badge style: '${style}'`)
|
||||
|
||||
@@ -1,143 +1,48 @@
|
||||
'use strict'
|
||||
|
||||
const { test, given, forCases } = require('sazerac')
|
||||
const { expect } = require('chai')
|
||||
const snapshot = require('snap-shot-it')
|
||||
const isSvg = require('is-svg')
|
||||
const prettier = require('prettier')
|
||||
const makeBadge = require('./make-badge')
|
||||
|
||||
function expectBadgeToMatchSnapshot(format) {
|
||||
snapshot(prettier.format(makeBadge(format), { parser: 'html' }))
|
||||
}
|
||||
|
||||
function testColor(color = '', colorAttr = 'color') {
|
||||
return JSON.parse(
|
||||
makeBadge({
|
||||
label: 'name',
|
||||
message: 'Bob',
|
||||
[colorAttr]: color,
|
||||
format: 'json',
|
||||
})
|
||||
).color
|
||||
function expectBadgeToMatchSnapshot(badgeData) {
|
||||
snapshot(prettier.format(makeBadge(badgeData), { parser: 'html' }))
|
||||
}
|
||||
|
||||
describe('The badge generator', function () {
|
||||
describe('color test', function () {
|
||||
test(testColor, () => {
|
||||
// valid hex
|
||||
forCases([
|
||||
given('#4c1'),
|
||||
given('#4C1'),
|
||||
given('4C1'),
|
||||
given('4c1'),
|
||||
]).expect('#4c1')
|
||||
forCases([
|
||||
given('#abc123'),
|
||||
given('#ABC123'),
|
||||
given('abc123'),
|
||||
given('ABC123'),
|
||||
]).expect('#abc123')
|
||||
// valid rgb(a)
|
||||
given('rgb(0,128,255)').expect('rgb(0,128,255)')
|
||||
given('rgb(220,128,255,0.5)').expect('rgb(220,128,255,0.5)')
|
||||
given('rgba(0,0,255)').expect('rgba(0,0,255)')
|
||||
given('rgba(0,128,255,0)').expect('rgba(0,128,255,0)')
|
||||
// valid hsl(a)
|
||||
given('hsl(100, 56%, 10%)').expect('hsl(100, 56%, 10%)')
|
||||
given('hsl(360,50%,50%,0.5)').expect('hsl(360,50%,50%,0.5)')
|
||||
given('hsla(25,20%,0%,0.1)').expect('hsla(25,20%,0%,0.1)')
|
||||
given('hsla(0,50%,101%)').expect('hsla(0,50%,101%)')
|
||||
// CSS named color.
|
||||
given('papayawhip').expect('papayawhip')
|
||||
// Shields named color.
|
||||
given('red').expect('red')
|
||||
given('green').expect('green')
|
||||
given('blue').expect('blue')
|
||||
given('yellow').expect('yellow')
|
||||
// Semantic color alias
|
||||
given('success').expect('brightgreen')
|
||||
given('informational').expect('blue')
|
||||
|
||||
forCases(
|
||||
// invalid hex
|
||||
given('#123red'), // contains letter above F
|
||||
given('#red'), // contains letter above F
|
||||
// neither a css named color nor colorscheme
|
||||
given('notacolor'),
|
||||
given('bluish'),
|
||||
given('almostred'),
|
||||
given('brightmaroon'),
|
||||
given('cactus')
|
||||
).expect(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('color aliases', function () {
|
||||
test(testColor, () => {
|
||||
forCases([given('#4c1', 'color')]).expect('#4c1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SVG', function () {
|
||||
it('should produce SVG', function () {
|
||||
expect(makeBadge({ label: 'cactus', message: 'grown', format: 'svg' }))
|
||||
expect(makeBadge({ label: 'cactus', message: 'grown' }))
|
||||
.to.satisfy(isSvg)
|
||||
.and.to.include('cactus')
|
||||
.and.to.include('grown')
|
||||
})
|
||||
|
||||
it('should match snapshot', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('JSON', function () {
|
||||
it('should produce the expected JSON', function () {
|
||||
const json = makeBadge({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'json',
|
||||
links: ['https://example.com/', 'https://other.example.com/'],
|
||||
})
|
||||
expect(JSON.parse(json)).to.deep.equal({
|
||||
name: 'cactus',
|
||||
label: 'cactus',
|
||||
value: 'grown',
|
||||
message: 'grown',
|
||||
link: ['https://example.com/', 'https://other.example.com/'],
|
||||
})
|
||||
expectBadgeToMatchSnapshot({ label: 'cactus', message: 'grown' })
|
||||
})
|
||||
|
||||
it('should replace undefined svg badge style with "flat"', function () {
|
||||
const jsonBadgeWithUnknownStyle = makeBadge({
|
||||
label: 'name',
|
||||
message: 'Bob',
|
||||
format: 'svg',
|
||||
})
|
||||
const jsonBadgeWithDefaultStyle = makeBadge({
|
||||
label: 'name',
|
||||
message: 'Bob',
|
||||
format: 'svg',
|
||||
style: 'flat',
|
||||
})
|
||||
expect(jsonBadgeWithUnknownStyle)
|
||||
.to.equal(jsonBadgeWithDefaultStyle)
|
||||
.and.to.satisfy(isSvg)
|
||||
expect(
|
||||
makeBadge({
|
||||
label: 'name',
|
||||
message: 'Bob',
|
||||
})
|
||||
)
|
||||
.to.satisfy(isSvg)
|
||||
.and.to.equal(
|
||||
makeBadge({
|
||||
label: 'name',
|
||||
message: 'Bob',
|
||||
style: 'flat',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should fail with unknown svg badge style', function () {
|
||||
expect(() =>
|
||||
makeBadge({
|
||||
label: 'name',
|
||||
message: 'Bob',
|
||||
format: 'svg',
|
||||
style: 'unknown_style',
|
||||
})
|
||||
makeBadge({ label: 'name', message: 'Bob', style: 'unknown_style' })
|
||||
).to.throw(Error, "Unknown badge style: 'unknown_style'")
|
||||
})
|
||||
})
|
||||
@@ -147,7 +52,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'flat',
|
||||
color: '#b3e',
|
||||
labelColor: '#0f0',
|
||||
@@ -158,7 +62,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'flat',
|
||||
color: '#b3e',
|
||||
labelColor: '#0f0',
|
||||
@@ -170,7 +73,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: '',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'flat',
|
||||
color: '#b3e',
|
||||
})
|
||||
@@ -180,7 +82,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: '',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'flat',
|
||||
color: '#b3e',
|
||||
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
|
||||
@@ -191,7 +92,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: '',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'flat',
|
||||
color: '#b3e',
|
||||
labelColor: '#0f0',
|
||||
@@ -203,7 +103,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'flat',
|
||||
color: '#b3e',
|
||||
labelColor: '#0f0',
|
||||
@@ -215,7 +114,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'flat',
|
||||
color: '#000',
|
||||
labelColor: '#f3f3f3',
|
||||
@@ -226,7 +124,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'flat',
|
||||
color: '#e2ffe1',
|
||||
labelColor: '#000',
|
||||
@@ -239,7 +136,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'flat-square',
|
||||
color: '#b3e',
|
||||
labelColor: '#0f0',
|
||||
@@ -250,7 +146,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'flat-square',
|
||||
color: '#b3e',
|
||||
labelColor: '#0f0',
|
||||
@@ -262,7 +157,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: '',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'flat-square',
|
||||
color: '#b3e',
|
||||
})
|
||||
@@ -272,7 +166,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: '',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'flat-square',
|
||||
color: '#b3e',
|
||||
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
|
||||
@@ -283,7 +176,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: '',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'flat-square',
|
||||
color: '#b3e',
|
||||
labelColor: '#0f0',
|
||||
@@ -295,7 +187,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'flat-square',
|
||||
color: '#b3e',
|
||||
labelColor: '#0f0',
|
||||
@@ -307,7 +198,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'flat-square',
|
||||
color: '#000',
|
||||
labelColor: '#f3f3f3',
|
||||
@@ -318,7 +208,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'flat-square',
|
||||
color: '#e2ffe1',
|
||||
labelColor: '#000',
|
||||
@@ -331,7 +220,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'plastic',
|
||||
color: '#b3e',
|
||||
labelColor: '#0f0',
|
||||
@@ -342,7 +230,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'plastic',
|
||||
color: '#b3e',
|
||||
labelColor: '#0f0',
|
||||
@@ -354,7 +241,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: '',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'plastic',
|
||||
color: '#b3e',
|
||||
})
|
||||
@@ -364,7 +250,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: '',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'plastic',
|
||||
color: '#b3e',
|
||||
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
|
||||
@@ -375,7 +260,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: '',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'plastic',
|
||||
color: '#b3e',
|
||||
labelColor: '#0f0',
|
||||
@@ -387,7 +271,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'plastic',
|
||||
color: '#b3e',
|
||||
labelColor: '#0f0',
|
||||
@@ -399,7 +282,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'plastic',
|
||||
color: '#000',
|
||||
labelColor: '#f3f3f3',
|
||||
@@ -410,7 +292,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'plastic',
|
||||
color: '#e2ffe1',
|
||||
labelColor: '#000',
|
||||
@@ -425,7 +306,6 @@ describe('The badge generator', function () {
|
||||
makeBadge({
|
||||
label: 1998,
|
||||
message: 1999,
|
||||
format: 'svg',
|
||||
style: 'for-the-badge',
|
||||
})
|
||||
)
|
||||
@@ -438,7 +318,6 @@ describe('The badge generator', function () {
|
||||
makeBadge({
|
||||
label: 'Label',
|
||||
message: '1 string',
|
||||
format: 'svg',
|
||||
style: 'for-the-badge',
|
||||
})
|
||||
)
|
||||
@@ -450,7 +329,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'for-the-badge',
|
||||
color: '#b3e',
|
||||
labelColor: '#0f0',
|
||||
@@ -461,7 +339,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'for-the-badge',
|
||||
color: '#b3e',
|
||||
labelColor: '#0f0',
|
||||
@@ -473,7 +350,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: '',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'for-the-badge',
|
||||
color: '#b3e',
|
||||
})
|
||||
@@ -483,7 +359,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: '',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'for-the-badge',
|
||||
color: '#b3e',
|
||||
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
|
||||
@@ -494,7 +369,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: '',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'for-the-badge',
|
||||
color: '#b3e',
|
||||
labelColor: '#0f0',
|
||||
@@ -506,7 +380,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'for-the-badge',
|
||||
color: '#b3e',
|
||||
labelColor: '#0f0',
|
||||
@@ -518,7 +391,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'for-the-badge',
|
||||
color: '#000',
|
||||
labelColor: '#f3f3f3',
|
||||
@@ -529,7 +401,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'for-the-badge',
|
||||
color: '#e2ffe1',
|
||||
labelColor: '#000',
|
||||
@@ -543,7 +414,6 @@ describe('The badge generator', function () {
|
||||
makeBadge({
|
||||
label: 'some-key',
|
||||
message: 'some-value',
|
||||
format: 'svg',
|
||||
style: 'social',
|
||||
})
|
||||
)
|
||||
@@ -557,11 +427,10 @@ describe('The badge generator', function () {
|
||||
makeBadge({
|
||||
label: '',
|
||||
message: 'some-value',
|
||||
format: 'json',
|
||||
style: 'social',
|
||||
})
|
||||
)
|
||||
.to.include('""')
|
||||
.to.include('></text>')
|
||||
.and.to.include('some-value')
|
||||
})
|
||||
|
||||
@@ -569,7 +438,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'social',
|
||||
color: '#b3e',
|
||||
labelColor: '#0f0',
|
||||
@@ -580,7 +448,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'social',
|
||||
color: '#b3e',
|
||||
labelColor: '#0f0',
|
||||
@@ -592,7 +459,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: '',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'social',
|
||||
color: '#b3e',
|
||||
})
|
||||
@@ -602,7 +468,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: '',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'social',
|
||||
color: '#b3e',
|
||||
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
|
||||
@@ -613,7 +478,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: '',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'social',
|
||||
color: '#b3e',
|
||||
labelColor: '#0f0',
|
||||
@@ -625,7 +489,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'social',
|
||||
color: '#b3e',
|
||||
labelColor: '#0f0',
|
||||
@@ -639,7 +502,6 @@ describe('The badge generator', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'label',
|
||||
message: 'message',
|
||||
format: 'svg',
|
||||
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,20 +6,13 @@ public:
|
||||
enabled: true
|
||||
url: https://metrics.shields.io/telegraf
|
||||
instanceIdFrom: env-var
|
||||
instanceIdEnvVarName: FLY_ALLOC_ID
|
||||
instanceIdEnvVarName: HEROKU_DYNO_ID
|
||||
envLabel: shields-production
|
||||
|
||||
ssl:
|
||||
isSecure: false
|
||||
isSecure: true
|
||||
|
||||
cors:
|
||||
allowedOrigin: ['http://shields.io', 'https://shields.io']
|
||||
|
||||
services:
|
||||
gitlab:
|
||||
authorizedOrigins: 'https://gitlab.com'
|
||||
|
||||
rasterUrl: 'https://raster.shields.io'
|
||||
userAgentBase: 'Shields.io'
|
||||
requireCloudflare: true
|
||||
requestTimeoutSeconds: 20
|
||||
|
||||
@@ -1,58 +1,29 @@
|
||||
import makeBadge from '../../badge-maker/lib/make-badge.js'
|
||||
import BaseService from './base.js'
|
||||
import {
|
||||
serverHasBeenUpSinceResourceCached,
|
||||
setCacheHeadersForStaticResource,
|
||||
} from './cache-headers.js'
|
||||
import { makeSend } from './legacy-result-sender.js'
|
||||
import { MetricHelper } from './metric-helper.js'
|
||||
import coalesceBadge from './coalesce-badge.js'
|
||||
import { prepareRoute, namedParamsForMatch } from './route.js'
|
||||
import { prepareRoute } from './route.js'
|
||||
|
||||
export default class BaseStaticService extends BaseService {
|
||||
static register({ camp, metricInstance }, serviceConfig) {
|
||||
const { regex, captureNames } = prepareRoute(this.route)
|
||||
static _applyCacheHeaders({ res }) {
|
||||
setCacheHeadersForStaticResource(res)
|
||||
}
|
||||
|
||||
const metricHelper = MetricHelper.create({
|
||||
metricInstance,
|
||||
ServiceClass: this,
|
||||
})
|
||||
|
||||
camp.route(regex, async (queryParams, match, end, ask) => {
|
||||
if (serverHasBeenUpSinceResourceCached(ask.req)) {
|
||||
// Send Not Modified.
|
||||
ask.res.statusCode = 304
|
||||
ask.res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const metricHandle = metricHelper.startRequest()
|
||||
|
||||
const namedParams = namedParamsForMatch(captureNames, match, this)
|
||||
const serviceData = await this.invoke(
|
||||
{},
|
||||
serviceConfig,
|
||||
namedParams,
|
||||
queryParams
|
||||
)
|
||||
|
||||
const badgeData = coalesceBadge(
|
||||
queryParams,
|
||||
serviceData,
|
||||
this.defaultBadgeData,
|
||||
this
|
||||
)
|
||||
|
||||
// The final capture group is the extension.
|
||||
const format = (match.slice(-1)[0] || '.svg').replace(/^\./, '')
|
||||
badgeData.format = format
|
||||
|
||||
setCacheHeadersForStaticResource(ask.res)
|
||||
|
||||
const svg = makeBadge(badgeData)
|
||||
makeSend(format, ask.res, end)(svg)
|
||||
|
||||
metricHandle.noteResponseSent()
|
||||
})
|
||||
static register({ app, ...serviceContext }, serviceConfig) {
|
||||
const { regex } = prepareRoute(this.route)
|
||||
app.get(
|
||||
regex,
|
||||
(req, res, next) => {
|
||||
if (serverHasBeenUpSinceResourceCached(req)) {
|
||||
// Send Not Modified.
|
||||
res.status(304)
|
||||
res.end()
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
},
|
||||
this.makeExpressHandler(serviceContext, serviceConfig)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,13 @@
|
||||
import emojic from 'emojic'
|
||||
import Joi from 'joi'
|
||||
import log from '../server/log.js'
|
||||
import makeBadge from '../../badge-maker/lib/make-badge.js'
|
||||
import { AuthHelper } from './auth-helper.js'
|
||||
import { MetricHelper, MetricNames } from './metric-helper.js'
|
||||
import {
|
||||
coalesceCacheLength,
|
||||
setHeadersForCacheLength,
|
||||
} from './cache-headers.js'
|
||||
import { assertValidCategory } from './categories.js'
|
||||
import checkErrorResponse from './check-error-response.js'
|
||||
import coalesceBadge from './coalesce-badge.js'
|
||||
@@ -21,11 +26,12 @@ import {
|
||||
} from './errors.js'
|
||||
import { validateExample, transformExample } from './examples.js'
|
||||
import { fetch } from './got.js'
|
||||
import { makeJsonBadge } from './make-json-badge.js'
|
||||
import {
|
||||
makeFullUrl,
|
||||
assertValidRoute,
|
||||
paramsForReq,
|
||||
prepareRoute,
|
||||
namedParamsForMatch,
|
||||
getQueryParamNames,
|
||||
} from './route.js'
|
||||
import { assertValidServiceDefinition } from './service-definitions.js'
|
||||
@@ -423,60 +429,90 @@ class BaseService {
|
||||
return serviceData
|
||||
}
|
||||
|
||||
static register(
|
||||
{
|
||||
camp,
|
||||
handleRequest,
|
||||
githubApiProvider,
|
||||
librariesIoApiProvider,
|
||||
metricInstance,
|
||||
},
|
||||
// `defaultCacheLengthSeconds` can be overridden by
|
||||
// `serviceDefaultCacheLengthSeconds` (either by category or on a badge-
|
||||
// by-badge basis). Then in turn that can be overridden by
|
||||
// `serviceOverrideCacheLengthSeconds` (which we expect to be used only in
|
||||
// the dynamic badge) but only if `serviceOverrideCacheLengthSeconds` is
|
||||
// longer than `serviceDefaultCacheLengthSeconds` and then the `cacheSeconds`
|
||||
// query param can also override both of those but again only if `cacheSeconds`
|
||||
// is longer.
|
||||
//
|
||||
// Ref: https://github.com/badges/shields/pull/2755
|
||||
static _applyCacheHeaders({
|
||||
cacheHeaderConfig,
|
||||
req,
|
||||
res,
|
||||
serviceOverrideCacheLengthSeconds,
|
||||
}) {
|
||||
const cacheLengthSeconds = coalesceCacheLength({
|
||||
cacheHeaderConfig,
|
||||
serviceDefaultCacheLengthSeconds: this._cacheLength,
|
||||
serviceOverrideCacheLengthSeconds,
|
||||
queryParams: req.query,
|
||||
})
|
||||
setHeadersForCacheLength(res, cacheLengthSeconds)
|
||||
}
|
||||
|
||||
static makeExpressHandler(
|
||||
{ githubApiProvider, librariesIoApiProvider, metricInstance },
|
||||
serviceConfig
|
||||
) {
|
||||
const { cacheHeaders: cacheHeaderConfig } = serviceConfig
|
||||
const { regex, captureNames } = prepareRoute(this.route)
|
||||
const queryParams = getQueryParamNames(this.route)
|
||||
|
||||
const metricHelper = MetricHelper.create({
|
||||
metricInstance,
|
||||
ServiceClass: this,
|
||||
})
|
||||
const { captureNames } = prepareRoute(this.route)
|
||||
const { cacheHeaders: cacheHeaderConfig } = serviceConfig
|
||||
|
||||
camp.route(
|
||||
regex,
|
||||
handleRequest(cacheHeaderConfig, {
|
||||
queryParams,
|
||||
handler: async (queryParams, match, sendBadge) => {
|
||||
const metricHandle = metricHelper.startRequest()
|
||||
return async (req, res) => {
|
||||
const metricHandle = metricHelper.startRequest()
|
||||
|
||||
const namedParams = namedParamsForMatch(captureNames, match, this)
|
||||
const serviceData = await this.invoke(
|
||||
{
|
||||
requestFetcher: fetch,
|
||||
githubApiProvider,
|
||||
librariesIoApiProvider,
|
||||
metricHelper,
|
||||
},
|
||||
serviceConfig,
|
||||
namedParams,
|
||||
queryParams
|
||||
)
|
||||
|
||||
const badgeData = coalesceBadge(
|
||||
queryParams,
|
||||
serviceData,
|
||||
this.defaultBadgeData,
|
||||
this
|
||||
)
|
||||
// The final capture group is the extension.
|
||||
const format = (match.slice(-1)[0] || '.svg').replace(/^\./, '')
|
||||
sendBadge(format, badgeData)
|
||||
|
||||
metricHandle.noteResponseSent()
|
||||
const { namedParams, format } = paramsForReq(captureNames, req, this)
|
||||
const serviceData = await this.invoke(
|
||||
{
|
||||
requestFetcher: fetch,
|
||||
githubApiProvider,
|
||||
librariesIoApiProvider,
|
||||
metricHelper,
|
||||
},
|
||||
cacheLength: this._cacheLength,
|
||||
serviceConfig,
|
||||
namedParams,
|
||||
req.query
|
||||
)
|
||||
|
||||
const badgeData = coalesceBadge(
|
||||
req.query,
|
||||
serviceData,
|
||||
this.defaultBadgeData,
|
||||
this
|
||||
)
|
||||
|
||||
this._applyCacheHeaders({
|
||||
cacheHeaderConfig,
|
||||
req,
|
||||
res,
|
||||
serviceOverrideCacheLengthSeconds: badgeData.cacheLengthSeconds,
|
||||
})
|
||||
)
|
||||
|
||||
if (format === 'svg') {
|
||||
res.setHeader('Content-Type', 'image/svg+xml')
|
||||
res.send(makeBadge(badgeData))
|
||||
} else if (format === 'json') {
|
||||
res.json(makeJsonBadge(badgeData))
|
||||
} else {
|
||||
throw Error(`Unrecognized format: ${format}`)
|
||||
}
|
||||
|
||||
res.end()
|
||||
|
||||
metricHandle.noteResponseSent()
|
||||
}
|
||||
}
|
||||
|
||||
static register({ app, ...serviceContext }, serviceConfig) {
|
||||
const { regex } = prepareRoute(this.route)
|
||||
app.get(regex, this.makeExpressHandler(serviceContext, serviceConfig))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import Joi from 'joi'
|
||||
import chai from 'chai'
|
||||
import isSvg from 'is-svg'
|
||||
import sinon from 'sinon'
|
||||
import prometheus from 'prom-client'
|
||||
import chaiAsPromised from 'chai-as-promised'
|
||||
import PrometheusMetrics from '../server/prometheus-metrics.js'
|
||||
import { ExpressTestHarness } from '../express-test-harness.js'
|
||||
import trace from './trace.js'
|
||||
import {
|
||||
NotFound,
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
import BaseService from './base.js'
|
||||
import { MetricHelper, MetricNames } from './metric-helper.js'
|
||||
import '../register-chai-plugins.spec.js'
|
||||
|
||||
const { expect } = chai
|
||||
chai.use(chaiAsPromised)
|
||||
|
||||
@@ -59,9 +62,12 @@ class DummyServiceWithServiceResponseSizeMetricEnabled extends DummyService {
|
||||
|
||||
describe('BaseService', function () {
|
||||
const defaultConfig = {
|
||||
handleInternalErrors: false,
|
||||
cacheHeaders: { defaultCacheLengthSeconds: 120 },
|
||||
public: {
|
||||
handleInternalErrors: false,
|
||||
services: {},
|
||||
cacheHeaders: { defaultCacheLengthSeconds: 120 },
|
||||
},
|
||||
private: {},
|
||||
}
|
||||
@@ -321,62 +327,45 @@ describe('BaseService', function () {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ScoutCamp integration', function () {
|
||||
// TODO Strangly, without the useless escape the regexes do not match in Node 12.
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
const expectedRouteRegex = /^\/foo(?:\/([^\/#\?]+?))(|\.svg|\.json)$/
|
||||
describe('Express integration', function () {
|
||||
let harness
|
||||
beforeEach(async function () {
|
||||
harness = new ExpressTestHarness()
|
||||
DummyService.register({ app: harness.app }, defaultConfig)
|
||||
await harness.start()
|
||||
})
|
||||
|
||||
let mockCamp
|
||||
let mockHandleRequest
|
||||
afterEach(async function () {
|
||||
await harness.stop()
|
||||
})
|
||||
|
||||
beforeEach(function () {
|
||||
mockCamp = {
|
||||
route: sinon.spy(),
|
||||
}
|
||||
mockHandleRequest = sinon.spy()
|
||||
DummyService.register(
|
||||
{ camp: mockCamp, handleRequest: mockHandleRequest },
|
||||
defaultConfig
|
||||
it('fulfills the request for an SVG badge', async function () {
|
||||
const { headers, body } = await harness.get(
|
||||
'/foo/bar.svg?queryParamA=%3F'
|
||||
)
|
||||
|
||||
expect(headers).to.include({
|
||||
'content-type': 'image/svg+xml; charset=utf-8',
|
||||
})
|
||||
|
||||
expect(body)
|
||||
.to.satisfy(isSvg)
|
||||
.and.to.include('cat: Hello namedParamA: bar with queryParamA: ?')
|
||||
})
|
||||
|
||||
it('registers the service', function () {
|
||||
expect(mockCamp.route).to.have.been.calledOnce
|
||||
expect(mockCamp.route).to.have.been.calledWith(expectedRouteRegex)
|
||||
})
|
||||
it('fulfills the request for a JSON badge', async function () {
|
||||
const { headers, body } = await harness.get(
|
||||
'/foo/bar.json?queryParamA=%3F',
|
||||
{ responseType: 'json' }
|
||||
)
|
||||
|
||||
it('handles the request', async function () {
|
||||
expect(mockHandleRequest).to.have.been.calledOnce
|
||||
expect(headers).to.include({
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
})
|
||||
|
||||
const { queryParams: serviceQueryParams, handler: requestHandler } =
|
||||
mockHandleRequest.getCall(0).args[1]
|
||||
expect(serviceQueryParams).to.deep.equal([
|
||||
'queryParamA',
|
||||
'legacyQueryParamA',
|
||||
])
|
||||
|
||||
const mockSendBadge = sinon.spy()
|
||||
const mockRequest = {
|
||||
asPromise: sinon.spy(),
|
||||
}
|
||||
const queryParams = { queryParamA: '?' }
|
||||
const match = '/foo/bar.svg'.match(expectedRouteRegex)
|
||||
await requestHandler(queryParams, match, mockSendBadge, mockRequest)
|
||||
|
||||
const expectedFormat = 'svg'
|
||||
expect(mockSendBadge).to.have.been.calledOnce
|
||||
expect(mockSendBadge).to.have.been.calledWith(expectedFormat, {
|
||||
expect(body).to.include({
|
||||
label: 'cat',
|
||||
message: 'Hello namedParamA: bar with queryParamA: ?',
|
||||
color: 'lightgrey',
|
||||
style: 'flat',
|
||||
namedLogo: undefined,
|
||||
logo: undefined,
|
||||
logoWidth: undefined,
|
||||
logoPosition: undefined,
|
||||
links: [],
|
||||
labelColor: undefined,
|
||||
cacheLengthSeconds: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -574,9 +563,7 @@ describe('BaseService', function () {
|
||||
},
|
||||
private: {},
|
||||
},
|
||||
{
|
||||
namedParamA: 'bar.bar.bar',
|
||||
}
|
||||
{ namedParamA: 'bar.bar.bar' }
|
||||
)
|
||||
).to.deep.equal({
|
||||
color: 'lightgray',
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import makeBadge from '../../badge-maker/lib/make-badge.js'
|
||||
import { setCacheHeaders } from './cache-headers.js'
|
||||
import { makeSend } from './legacy-result-sender.js'
|
||||
import coalesceBadge from './coalesce-badge.js'
|
||||
|
||||
// These query parameters are available to any badge. They are handled by
|
||||
// `coalesceBadge`.
|
||||
const globalQueryParams = new Set([
|
||||
'label',
|
||||
'style',
|
||||
'link',
|
||||
'logo',
|
||||
'logoColor',
|
||||
'logoPosition',
|
||||
'logoWidth',
|
||||
'link',
|
||||
'colorA',
|
||||
'colorB',
|
||||
'color',
|
||||
'labelColor',
|
||||
])
|
||||
|
||||
function flattenQueryParams(queryParams) {
|
||||
const union = new Set(globalQueryParams)
|
||||
;(queryParams || []).forEach(name => {
|
||||
union.add(name)
|
||||
})
|
||||
return Array.from(union).sort()
|
||||
}
|
||||
|
||||
// handlerOptions can contain:
|
||||
// - handler: The service's request handler function
|
||||
// - queryParams: An array of the field names of any custom query parameters
|
||||
// the service uses
|
||||
// - cacheLength: An optional badge or category-specific cache length
|
||||
// (in number of seconds) to be used in preference to the default
|
||||
//
|
||||
// For safety, the service must declare the query parameters it wants to use.
|
||||
// Only the declared parameters (and the global parameters) are provided to
|
||||
// the service. Consequently, failure to declare a parameter results in the
|
||||
// parameter not working at all (which is undesirable, but easy to debug)
|
||||
// rather than indeterminate behavior that depends on the cache state
|
||||
// (undesirable and hard to debug).
|
||||
//
|
||||
// Pass just the handler function as shorthand.
|
||||
function handleRequest(cacheHeaderConfig, handlerOptions) {
|
||||
if (!cacheHeaderConfig) {
|
||||
throw Error('cacheHeaderConfig is required')
|
||||
}
|
||||
|
||||
if (typeof handlerOptions === 'function') {
|
||||
handlerOptions = { handler: handlerOptions }
|
||||
}
|
||||
|
||||
const allowedKeys = flattenQueryParams(handlerOptions.queryParams)
|
||||
const { cacheLength: serviceDefaultCacheLengthSeconds } = handlerOptions
|
||||
|
||||
return (queryParams, match, end, ask) => {
|
||||
/*
|
||||
This is here for legacy reasons. The badge server and frontend used to live
|
||||
on two different servers. When we merged them there was a conflict so we
|
||||
did this to avoid moving the endpoint docs to another URL.
|
||||
|
||||
Never ever do this again.
|
||||
*/
|
||||
if (match[0] === '/endpoint' && Object.keys(queryParams).length === 0) {
|
||||
ask.res.statusCode = 301
|
||||
ask.res.setHeader('Location', '/endpoint/')
|
||||
ask.res.end()
|
||||
return
|
||||
}
|
||||
|
||||
// `defaultCacheLengthSeconds` can be overridden by
|
||||
// `serviceDefaultCacheLengthSeconds` (either by category or on a badge-
|
||||
// by-badge basis). Then in turn that can be overridden by
|
||||
// `serviceOverrideCacheLengthSeconds` (which we expect to be used only in
|
||||
// the dynamic badge) but only if `serviceOverrideCacheLengthSeconds` is
|
||||
// longer than `serviceDefaultCacheLengthSeconds` and then the `cacheSeconds`
|
||||
// query param can also override both of those but again only if `cacheSeconds`
|
||||
// is longer.
|
||||
//
|
||||
// When the legacy services have been rewritten, all the code in here
|
||||
// will go away, which should achieve this goal in a simpler way.
|
||||
//
|
||||
// Ref: https://github.com/badges/shields/pull/2755
|
||||
function setCacheHeadersOnResponse(res, serviceOverrideCacheLengthSeconds) {
|
||||
setCacheHeaders({
|
||||
cacheHeaderConfig,
|
||||
serviceDefaultCacheLengthSeconds,
|
||||
serviceOverrideCacheLengthSeconds,
|
||||
queryParams,
|
||||
res,
|
||||
})
|
||||
}
|
||||
|
||||
const filteredQueryParams = {}
|
||||
allowedKeys.forEach(key => {
|
||||
filteredQueryParams[key] = queryParams[key]
|
||||
})
|
||||
|
||||
// In case our vendor servers are unresponsive.
|
||||
let serverUnresponsive = false
|
||||
const serverResponsive = setTimeout(() => {
|
||||
serverUnresponsive = true
|
||||
ask.res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||||
const badgeData = coalesceBadge(
|
||||
filteredQueryParams,
|
||||
{ label: 'vendor', message: 'unresponsive' },
|
||||
{}
|
||||
)
|
||||
const svg = makeBadge(badgeData)
|
||||
const extension = (match.slice(-1)[0] || '.svg').replace(/^\./, '')
|
||||
setCacheHeadersOnResponse(ask.res)
|
||||
makeSend(extension, ask.res, end)(svg)
|
||||
}, 25000)
|
||||
|
||||
const result = handlerOptions.handler(
|
||||
filteredQueryParams,
|
||||
match,
|
||||
// eslint-disable-next-line mocha/prefer-arrow-callback
|
||||
function sendBadge(format, badgeData) {
|
||||
if (serverUnresponsive) {
|
||||
return
|
||||
}
|
||||
clearTimeout(serverResponsive)
|
||||
// Add format to badge data.
|
||||
badgeData.format = format
|
||||
const svg = makeBadge(badgeData)
|
||||
setCacheHeadersOnResponse(ask.res, badgeData.cacheLengthSeconds)
|
||||
makeSend(format, ask.res, end)(svg)
|
||||
}
|
||||
)
|
||||
// eslint-disable-next-line promise/prefer-await-to-then
|
||||
if (result && result.catch) {
|
||||
// eslint-disable-next-line promise/prefer-await-to-then
|
||||
result.catch(err => {
|
||||
throw err
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { handleRequest }
|
||||
@@ -1,251 +0,0 @@
|
||||
import { expect } from 'chai'
|
||||
import portfinder from 'portfinder'
|
||||
import Camp from '@shields_io/camp'
|
||||
import got from '../got-test-client.js'
|
||||
import coalesceBadge from './coalesce-badge.js'
|
||||
import { handleRequest } from './legacy-request-handler.js'
|
||||
|
||||
async function performTwoRequests(baseUrl, first, second) {
|
||||
expect((await got(`${baseUrl}${first}`)).statusCode).to.equal(200)
|
||||
expect((await got(`${baseUrl}${second}`)).statusCode).to.equal(200)
|
||||
}
|
||||
|
||||
function fakeHandler(queryParams, match, sendBadge, request) {
|
||||
const [, someValue, format] = match
|
||||
const badgeData = coalesceBadge(
|
||||
queryParams,
|
||||
{
|
||||
label: 'testing',
|
||||
message: someValue,
|
||||
},
|
||||
{}
|
||||
)
|
||||
sendBadge(format, badgeData)
|
||||
}
|
||||
|
||||
function createFakeHandlerWithCacheLength(cacheLengthSeconds) {
|
||||
return function fakeHandler(queryParams, match, sendBadge, request) {
|
||||
const [, someValue, format] = match
|
||||
const badgeData = coalesceBadge(
|
||||
queryParams,
|
||||
{
|
||||
label: 'testing',
|
||||
message: someValue,
|
||||
},
|
||||
{},
|
||||
{
|
||||
_cacheLength: cacheLengthSeconds,
|
||||
}
|
||||
)
|
||||
sendBadge(format, badgeData)
|
||||
}
|
||||
}
|
||||
|
||||
describe('The request handler', function () {
|
||||
let port, baseUrl
|
||||
beforeEach(async function () {
|
||||
port = await portfinder.getPortPromise()
|
||||
baseUrl = `http://127.0.0.1:${port}`
|
||||
})
|
||||
|
||||
let camp
|
||||
beforeEach(function (done) {
|
||||
camp = Camp.start({ port, hostname: '::' })
|
||||
camp.on('listening', () => done())
|
||||
})
|
||||
afterEach(function (done) {
|
||||
if (camp) {
|
||||
camp.close(() => done())
|
||||
camp = null
|
||||
}
|
||||
})
|
||||
|
||||
const standardCacheHeaders = { defaultCacheLengthSeconds: 120 }
|
||||
|
||||
describe('the options object calling style', function () {
|
||||
beforeEach(function () {
|
||||
camp.route(
|
||||
/^\/testing\/([^/]+)\.(svg|png|gif|jpg|json)$/,
|
||||
handleRequest(standardCacheHeaders, { handler: fakeHandler })
|
||||
)
|
||||
})
|
||||
|
||||
it('should return the expected response', async function () {
|
||||
const { statusCode, body } = await got(`${baseUrl}/testing/123.json`, {
|
||||
responseType: 'json',
|
||||
})
|
||||
expect(statusCode).to.equal(200)
|
||||
expect(body).to.deep.equal({
|
||||
name: 'testing',
|
||||
value: '123',
|
||||
label: 'testing',
|
||||
message: '123',
|
||||
color: 'lightgrey',
|
||||
link: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('the function shorthand calling style', function () {
|
||||
beforeEach(function () {
|
||||
camp.route(
|
||||
/^\/testing\/([^/]+)\.(svg|png|gif|jpg|json)$/,
|
||||
handleRequest(standardCacheHeaders, fakeHandler)
|
||||
)
|
||||
})
|
||||
|
||||
it('should return the expected response', async function () {
|
||||
const { statusCode, body } = await got(`${baseUrl}/testing/123.json`, {
|
||||
responseType: 'json',
|
||||
})
|
||||
expect(statusCode).to.equal(200)
|
||||
expect(body).to.deep.equal({
|
||||
name: 'testing',
|
||||
value: '123',
|
||||
label: 'testing',
|
||||
message: '123',
|
||||
color: 'lightgrey',
|
||||
link: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('caching', function () {
|
||||
describe('standard query parameters', function () {
|
||||
function register({ cacheHeaderConfig }) {
|
||||
camp.route(
|
||||
/^\/testing\/([^/]+)\.(svg|png|gif|jpg|json)$/,
|
||||
handleRequest(
|
||||
cacheHeaderConfig,
|
||||
(queryParams, match, sendBadge, request) => {
|
||||
fakeHandler(queryParams, match, sendBadge, request)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
it('should set the expires header to current time + defaultCacheLengthSeconds', async function () {
|
||||
register({ cacheHeaderConfig: { defaultCacheLengthSeconds: 900 } })
|
||||
const { headers } = await got(`${baseUrl}/testing/123.json`)
|
||||
const expectedExpiry = new Date(
|
||||
+new Date(headers.date) + 900000
|
||||
).toGMTString()
|
||||
expect(headers.expires).to.equal(expectedExpiry)
|
||||
expect(headers['cache-control']).to.equal('max-age=900, s-maxage=900')
|
||||
})
|
||||
|
||||
it('should set the expected cache headers on cached responses', async function () {
|
||||
register({ cacheHeaderConfig: { defaultCacheLengthSeconds: 900 } })
|
||||
|
||||
// Make first request.
|
||||
await got(`${baseUrl}/testing/123.json`)
|
||||
|
||||
const { headers } = await got(`${baseUrl}/testing/123.json`)
|
||||
const expectedExpiry = new Date(
|
||||
+new Date(headers.date) + 900000
|
||||
).toGMTString()
|
||||
expect(headers.expires).to.equal(expectedExpiry)
|
||||
expect(headers['cache-control']).to.equal('max-age=900, s-maxage=900')
|
||||
})
|
||||
|
||||
it('should let live service data override the default cache headers with longer value', async function () {
|
||||
camp.route(
|
||||
/^\/testing\/([^/]+)\.(svg|png|gif|jpg|json)$/,
|
||||
handleRequest(
|
||||
{ defaultCacheLengthSeconds: 300 },
|
||||
(queryParams, match, sendBadge, request) => {
|
||||
createFakeHandlerWithCacheLength(400)(
|
||||
queryParams,
|
||||
match,
|
||||
sendBadge,
|
||||
request
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const { headers } = await got(`${baseUrl}/testing/123.json`)
|
||||
expect(headers['cache-control']).to.equal('max-age=400, s-maxage=400')
|
||||
})
|
||||
|
||||
it('should not let live service data override the default cache headers with shorter value', async function () {
|
||||
camp.route(
|
||||
/^\/testing\/([^/]+)\.(svg|png|gif|jpg|json)$/,
|
||||
handleRequest(
|
||||
{ defaultCacheLengthSeconds: 300 },
|
||||
(queryParams, match, sendBadge, request) => {
|
||||
createFakeHandlerWithCacheLength(200)(
|
||||
queryParams,
|
||||
match,
|
||||
sendBadge,
|
||||
request
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const { headers } = await got(`${baseUrl}/testing/123.json`)
|
||||
expect(headers['cache-control']).to.equal('max-age=300, s-maxage=300')
|
||||
})
|
||||
|
||||
it('should set the expires header to current time + cacheSeconds', async function () {
|
||||
register({ cacheHeaderConfig: { defaultCacheLengthSeconds: 0 } })
|
||||
const { headers } = await got(
|
||||
`${baseUrl}/testing/123.json?cacheSeconds=3600`
|
||||
)
|
||||
const expectedExpiry = new Date(
|
||||
+new Date(headers.date) + 3600000
|
||||
).toGMTString()
|
||||
expect(headers.expires).to.equal(expectedExpiry)
|
||||
expect(headers['cache-control']).to.equal('max-age=3600, s-maxage=3600')
|
||||
})
|
||||
|
||||
it('should ignore cacheSeconds when shorter than defaultCacheLengthSeconds', async function () {
|
||||
register({ cacheHeaderConfig: { defaultCacheLengthSeconds: 600 } })
|
||||
const { headers } = await got(
|
||||
`${baseUrl}/testing/123.json?cacheSeconds=300`
|
||||
)
|
||||
const expectedExpiry = new Date(
|
||||
+new Date(headers.date) + 600000
|
||||
).toGMTString()
|
||||
expect(headers.expires).to.equal(expectedExpiry)
|
||||
expect(headers['cache-control']).to.equal('max-age=600, s-maxage=600')
|
||||
})
|
||||
|
||||
it('should set Cache-Control: no-cache, no-store, must-revalidate if cache seconds is 0', async function () {
|
||||
register({ cacheHeaderConfig: { defaultCacheLengthSeconds: 0 } })
|
||||
const { headers } = await got(`${baseUrl}/testing/123.json`)
|
||||
expect(headers.expires).to.equal(headers.date)
|
||||
expect(headers['cache-control']).to.equal(
|
||||
'no-cache, no-store, must-revalidate'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom query parameters', function () {
|
||||
let handlerCallCount
|
||||
beforeEach(function () {
|
||||
handlerCallCount = 0
|
||||
camp.route(
|
||||
/^\/testing\/([^/]+)\.(svg|png|gif|jpg|json)$/,
|
||||
handleRequest(standardCacheHeaders, {
|
||||
queryParams: ['foo'],
|
||||
handler: (queryParams, match, sendBadge, request) => {
|
||||
++handlerCallCount
|
||||
fakeHandler(queryParams, match, sendBadge, request)
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should differentiate them', async function () {
|
||||
await performTwoRequests(
|
||||
baseUrl,
|
||||
'/testing/123.svg?foo=1',
|
||||
'/testing/123.svg?foo=2'
|
||||
)
|
||||
expect(handlerCallCount).to.equal(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,35 +0,0 @@
|
||||
import stream from 'stream'
|
||||
|
||||
function streamFromString(str) {
|
||||
const newStream = new stream.Readable()
|
||||
newStream._read = () => {
|
||||
newStream.push(str)
|
||||
newStream.push(null)
|
||||
}
|
||||
return newStream
|
||||
}
|
||||
|
||||
function sendSVG(res, askres, end) {
|
||||
askres.setHeader('Content-Type', 'image/svg+xml;charset=utf-8')
|
||||
askres.setHeader('Content-Length', Buffer.byteLength(res, 'utf8'))
|
||||
end(null, { template: streamFromString(res) })
|
||||
}
|
||||
|
||||
function sendJSON(res, askres, end) {
|
||||
askres.setHeader('Content-Type', 'application/json')
|
||||
askres.setHeader('Access-Control-Allow-Origin', '*')
|
||||
askres.setHeader('Content-Length', Buffer.byteLength(res, 'utf8'))
|
||||
end(null, { template: streamFromString(res) })
|
||||
}
|
||||
|
||||
function makeSend(format, askres, end) {
|
||||
if (format === 'svg') {
|
||||
return res => sendSVG(res, askres, end)
|
||||
} else if (format === 'json') {
|
||||
return res => sendJSON(res, askres, end)
|
||||
} else {
|
||||
throw Error(`Unrecognized format: ${format}`)
|
||||
}
|
||||
}
|
||||
|
||||
export { makeSend }
|
||||
16
core/base-service/make-json-badge.js
Normal file
16
core/base-service/make-json-badge.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { normalizeColor } from 'badge-maker/lib/color.js'
|
||||
|
||||
export function makeJsonBadge(badgeData) {
|
||||
const { label, message, logoWidth, color, labelColor, links } = badgeData
|
||||
|
||||
return {
|
||||
label,
|
||||
message,
|
||||
logoWidth,
|
||||
color: normalizeColor(color),
|
||||
labelColor: normalizeColor(labelColor),
|
||||
link: links,
|
||||
name: label,
|
||||
value: message,
|
||||
}
|
||||
}
|
||||
23
core/base-service/make-json-badge.spec.js
Normal file
23
core/base-service/make-json-badge.spec.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { expect } from 'chai'
|
||||
import { makeJsonBadge } from './make-json-badge.js'
|
||||
|
||||
describe('makeJsonBadge()', function () {
|
||||
it('should produce the expected JSON', function () {
|
||||
expect(
|
||||
makeJsonBadge({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
links: ['https://example.com/', 'https://other.example.com/'],
|
||||
})
|
||||
).to.deep.equal({
|
||||
name: 'cactus',
|
||||
label: 'cactus',
|
||||
value: 'grown',
|
||||
message: 'grown',
|
||||
link: ['https://example.com/', 'https://other.example.com/'],
|
||||
color: undefined,
|
||||
labelColor: undefined,
|
||||
logoWidth: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import url from 'url'
|
||||
import camelcase from 'camelcase'
|
||||
import emojic from 'emojic'
|
||||
import Joi from 'joi'
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
} from './cache-headers.js'
|
||||
import { isValidCategory } from './categories.js'
|
||||
import { MetricHelper } from './metric-helper.js'
|
||||
import { isValidRoute, prepareRoute, namedParamsForMatch } from './route.js'
|
||||
import { isValidRoute, prepareRoute, paramsForReq } from './route.js'
|
||||
import trace from './trace.js'
|
||||
|
||||
const attrSchema = Joi.object({
|
||||
@@ -54,7 +55,7 @@ export default function redirector(attrs) {
|
||||
static route = route
|
||||
static examples = examples
|
||||
|
||||
static register({ camp, metricInstance }, { rasterUrl }) {
|
||||
static register({ app, metricInstance }, { rasterUrl }) {
|
||||
const { regex, captureNames } = prepareRoute({
|
||||
...this.route,
|
||||
withPng: Boolean(rasterUrl),
|
||||
@@ -65,17 +66,17 @@ export default function redirector(attrs) {
|
||||
ServiceClass: this,
|
||||
})
|
||||
|
||||
camp.route(regex, async (queryParams, match, end, ask) => {
|
||||
if (serverHasBeenUpSinceResourceCached(ask.req)) {
|
||||
app.get(regex, async (req, res) => {
|
||||
if (serverHasBeenUpSinceResourceCached(req)) {
|
||||
// Send Not Modified.
|
||||
ask.res.statusCode = 304
|
||||
ask.res.end()
|
||||
res.status(304)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const metricHandle = metricHelper.startRequest()
|
||||
|
||||
const namedParams = namedParamsForMatch(captureNames, match, this)
|
||||
const { namedParams, format } = paramsForReq(captureNames, req, this)
|
||||
trace.logTrace(
|
||||
'inbound',
|
||||
emojic.arrowHeadingUp,
|
||||
@@ -83,12 +84,12 @@ export default function redirector(attrs) {
|
||||
route.base
|
||||
)
|
||||
trace.logTrace('inbound', emojic.ticket, 'Named params', namedParams)
|
||||
trace.logTrace('inbound', emojic.crayon, 'Query params', queryParams)
|
||||
trace.logTrace('inbound', emojic.crayon, 'Query params', req.query)
|
||||
|
||||
const targetPath = encodeURI(transformPath(namedParams))
|
||||
trace.logTrace('validate', emojic.dart, 'Target', targetPath)
|
||||
|
||||
let urlSuffix = ask.uri.search || ''
|
||||
let urlSuffix = url.parse(req.url).search ?? '' // eslint-disable-line node/no-deprecated-api
|
||||
|
||||
if (transformQueryParams) {
|
||||
const specifiedParams = queryString.parse(urlSuffix)
|
||||
@@ -100,21 +101,18 @@ export default function redirector(attrs) {
|
||||
urlSuffix = `?${outQueryString}`
|
||||
}
|
||||
|
||||
// The final capture group is the extension.
|
||||
const format = (match.slice(-1)[0] || '.svg').replace(/^\./, '')
|
||||
const redirectUrl = `${
|
||||
format === 'png' ? rasterUrl : ''
|
||||
}${targetPath}.${format}${urlSuffix}`
|
||||
const baseUrl = format === 'png' ? rasterUrl : ''
|
||||
const redirectUrl = `${baseUrl}${targetPath}.${format}${urlSuffix}`
|
||||
trace.logTrace('outbound', emojic.shield, 'Redirect URL', redirectUrl)
|
||||
|
||||
ask.res.statusCode = 301
|
||||
ask.res.setHeader('Location', redirectUrl)
|
||||
res.status(301)
|
||||
res.setHeader('Location', redirectUrl)
|
||||
|
||||
// To avoid caching mistakes for a long time, and to make this simpler
|
||||
// to reason about, use the same cache semantics as the static badge.
|
||||
setCacheHeadersForStaticResource(ask.res)
|
||||
setCacheHeadersForStaticResource(res)
|
||||
|
||||
ask.res.end()
|
||||
res.end()
|
||||
|
||||
metricHandle.noteResponseSent()
|
||||
})
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import Camp from '@shields_io/camp'
|
||||
import portfinder from 'portfinder'
|
||||
import { expect } from 'chai'
|
||||
import got from '../got-test-client.js'
|
||||
import { ExpressTestHarness } from '../express-test-harness.js'
|
||||
import redirector from './redirector.js'
|
||||
|
||||
describe('Redirector', function () {
|
||||
@@ -63,28 +61,12 @@ describe('Redirector', function () {
|
||||
expect(redirector({ ...attrs, examples }).examples).to.equal(examples)
|
||||
})
|
||||
|
||||
describe('ScoutCamp integration', function () {
|
||||
let port, baseUrl
|
||||
beforeEach(async function () {
|
||||
port = await portfinder.getPortPromise()
|
||||
baseUrl = `http://127.0.0.1:${port}`
|
||||
})
|
||||
|
||||
let camp
|
||||
beforeEach(async function () {
|
||||
camp = Camp.start({ port, hostname: '::' })
|
||||
await new Promise(resolve => camp.on('listening', () => resolve()))
|
||||
})
|
||||
afterEach(async function () {
|
||||
if (camp) {
|
||||
await new Promise(resolve => camp.close(resolve))
|
||||
camp = undefined
|
||||
}
|
||||
})
|
||||
|
||||
describe('Express integration', function () {
|
||||
const transformPath = ({ namedParamA }) => `/new/service/${namedParamA}`
|
||||
|
||||
beforeEach(function () {
|
||||
let harness
|
||||
beforeEach(async function () {
|
||||
harness = new ExpressTestHarness()
|
||||
const ServiceClass = redirector({
|
||||
category,
|
||||
route,
|
||||
@@ -92,17 +74,20 @@ describe('Redirector', function () {
|
||||
dateAdded,
|
||||
})
|
||||
ServiceClass.register(
|
||||
{ camp },
|
||||
{ app: harness.app },
|
||||
{ rasterUrl: 'http://raster.example.test' }
|
||||
)
|
||||
await harness.start()
|
||||
})
|
||||
|
||||
afterEach(async function () {
|
||||
await harness.stop()
|
||||
})
|
||||
|
||||
it('should redirect as configured', async function () {
|
||||
const { statusCode, headers } = await got(
|
||||
`${baseUrl}/very/old/service/hello-world.svg`,
|
||||
{
|
||||
followRedirect: false,
|
||||
}
|
||||
const { statusCode, headers } = await harness.get(
|
||||
'/very/old/service/hello-world.svg',
|
||||
{ followRedirect: false }
|
||||
)
|
||||
|
||||
expect(statusCode).to.equal(301)
|
||||
@@ -110,11 +95,9 @@ describe('Redirector', function () {
|
||||
})
|
||||
|
||||
it('should redirect raster extensions to the canonical path as configured', async function () {
|
||||
const { statusCode, headers } = await got(
|
||||
`${baseUrl}/very/old/service/hello-world.png`,
|
||||
{
|
||||
followRedirect: false,
|
||||
}
|
||||
const { statusCode, headers } = await harness.get(
|
||||
'/very/old/service/hello-world.png',
|
||||
{ followRedirect: false }
|
||||
)
|
||||
|
||||
expect(statusCode).to.equal(301)
|
||||
@@ -124,11 +107,9 @@ describe('Redirector', function () {
|
||||
})
|
||||
|
||||
it('should forward the query params', async function () {
|
||||
const { statusCode, headers } = await got(
|
||||
`${baseUrl}/very/old/service/hello-world.svg?color=123&style=flat-square`,
|
||||
{
|
||||
followRedirect: false,
|
||||
}
|
||||
const { statusCode, headers } = await harness.get(
|
||||
'/very/old/service/hello-world.svg?color=123&style=flat-square',
|
||||
{ followRedirect: false }
|
||||
)
|
||||
|
||||
expect(statusCode).to.equal(301)
|
||||
@@ -138,11 +119,9 @@ describe('Redirector', function () {
|
||||
})
|
||||
|
||||
it('should correctly encode the redirect URL', async function () {
|
||||
const { statusCode, headers } = await got(
|
||||
`${baseUrl}/very/old/service/hello%0Dworld.svg?foobar=a%0Db`,
|
||||
{
|
||||
followRedirect: false,
|
||||
}
|
||||
const { statusCode, headers } = await harness.get(
|
||||
'/very/old/service/hello%0Dworld.svg?foobar=a%0Db',
|
||||
{ followRedirect: false }
|
||||
)
|
||||
|
||||
expect(statusCode).to.equal(301)
|
||||
@@ -166,15 +145,13 @@ describe('Redirector', function () {
|
||||
transformQueryParams,
|
||||
dateAdded,
|
||||
})
|
||||
ServiceClass.register({ camp }, {})
|
||||
ServiceClass.register({ app: harness.app }, {})
|
||||
})
|
||||
|
||||
it('should forward the transformed query params', async function () {
|
||||
const { statusCode, headers } = await got(
|
||||
`${baseUrl}/another/old/service/token/abc123/hello-world.svg`,
|
||||
{
|
||||
followRedirect: false,
|
||||
}
|
||||
const { statusCode, headers } = await harness.get(
|
||||
'/another/old/service/token/abc123/hello-world.svg',
|
||||
{ followRedirect: false }
|
||||
)
|
||||
|
||||
expect(statusCode).to.equal(301)
|
||||
@@ -184,11 +161,9 @@ describe('Redirector', function () {
|
||||
})
|
||||
|
||||
it('should forward the specified and transformed query params', async function () {
|
||||
const { statusCode, headers } = await got(
|
||||
`${baseUrl}/another/old/service/token/abc123/hello-world.svg?color=123&style=flat-square`,
|
||||
{
|
||||
followRedirect: false,
|
||||
}
|
||||
const { statusCode, headers } = await harness.get(
|
||||
'/another/old/service/token/abc123/hello-world.svg?color=123&style=flat-square',
|
||||
{ followRedirect: false }
|
||||
)
|
||||
|
||||
expect(statusCode).to.equal(301)
|
||||
@@ -198,11 +173,9 @@ describe('Redirector', function () {
|
||||
})
|
||||
|
||||
it('should use transformed query params on param conflicts by default', async function () {
|
||||
const { statusCode, headers } = await got(
|
||||
`${baseUrl}/another/old/service/token/abc123/hello-world.svg?color=123&style=flat-square&token=def456`,
|
||||
{
|
||||
followRedirect: false,
|
||||
}
|
||||
const { statusCode, headers } = await harness.get(
|
||||
'/another/old/service/token/abc123/hello-world.svg?color=123&style=flat-square&token=def456',
|
||||
{ followRedirect: false }
|
||||
)
|
||||
|
||||
expect(statusCode).to.equal(301)
|
||||
@@ -224,12 +197,10 @@ describe('Redirector', function () {
|
||||
overrideTransformedQueryParams: true,
|
||||
dateAdded,
|
||||
})
|
||||
ServiceClass.register({ camp }, {})
|
||||
const { statusCode, headers } = await got(
|
||||
`${baseUrl}/override/service/token/abc123/hello-world.svg?style=flat-square&token=def456`,
|
||||
{
|
||||
followRedirect: false,
|
||||
}
|
||||
ServiceClass.register({ app: harness.app }, {})
|
||||
const { statusCode, headers } = await harness.get(
|
||||
'/override/service/token/abc123/hello-world.svg?style=flat-square&token=def456',
|
||||
{ followRedirect: false }
|
||||
)
|
||||
|
||||
expect(statusCode).to.equal(301)
|
||||
|
||||
@@ -44,23 +44,29 @@ function prepareRoute({ base, pattern, format, capture, withPng }) {
|
||||
return { regex, captureNames }
|
||||
}
|
||||
|
||||
function namedParamsForMatch(captureNames = [], match, ServiceClass) {
|
||||
// Assume the last match is the format, and drop match[0], which is the
|
||||
// entire match.
|
||||
const captures = match.slice(1, -1)
|
||||
|
||||
if (captureNames.length !== captures.length) {
|
||||
function paramsForReq(captureNames = [], req, ServiceClass) {
|
||||
// In addition to the parameters declared by the service, we have one match
|
||||
// for the format.
|
||||
const expectedNamedParamCount = Object.keys(req.params).length - 1
|
||||
if (captureNames.length !== expectedNamedParamCount) {
|
||||
throw new Error(
|
||||
`Service ${ServiceClass.name} declares incorrect number of named params ` +
|
||||
`(expected ${captures.length}, got ${captureNames.length})`
|
||||
`(expected ${expectedNamedParamCount}, got ${captureNames.length})`
|
||||
)
|
||||
}
|
||||
|
||||
const result = {}
|
||||
const namedParams = {}
|
||||
captureNames.forEach((name, index) => {
|
||||
result[name] = captures[index]
|
||||
namedParams[name] = req.params[index]
|
||||
})
|
||||
return result
|
||||
|
||||
// The final capture group is the extension.
|
||||
const format = (req.params[expectedNamedParamCount] || '.svg').replace(
|
||||
/^\./,
|
||||
''
|
||||
)
|
||||
|
||||
return { namedParams, format }
|
||||
}
|
||||
|
||||
function getQueryParamNames({ queryParamSchema }) {
|
||||
@@ -77,6 +83,6 @@ export {
|
||||
isValidRoute,
|
||||
assertValidRoute,
|
||||
prepareRoute,
|
||||
namedParamsForMatch,
|
||||
paramsForReq,
|
||||
getQueryParamNames,
|
||||
}
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import { expect } from 'chai'
|
||||
import Joi from 'joi'
|
||||
import { test, given, forCases } from 'sazerac'
|
||||
import {
|
||||
prepareRoute,
|
||||
namedParamsForMatch,
|
||||
getQueryParamNames,
|
||||
} from './route.js'
|
||||
import { test, given } from 'sazerac'
|
||||
import { prepareRoute, paramsForReq, getQueryParamNames } from './route.js'
|
||||
|
||||
function paramsForPath({ regex, captureNames, ServiceClass }, path) {
|
||||
// Prepare a mock express `req` object.
|
||||
const params = {}
|
||||
regex.exec(path).forEach((param, i) => {
|
||||
// regex.exec(path)[0] contains the entire path. We want [1] ... [n].
|
||||
if (i > 0) {
|
||||
params[i - 1] = param
|
||||
}
|
||||
})
|
||||
const req = { params }
|
||||
|
||||
return paramsForReq(captureNames, req, ServiceClass)
|
||||
}
|
||||
|
||||
describe('Route helpers', function () {
|
||||
const ServiceClass = { name: 'MyService' }
|
||||
|
||||
context('A `pattern` with a named param is declared', function () {
|
||||
const { regex, captureNames } = prepareRoute({
|
||||
base: 'foo',
|
||||
@@ -15,22 +27,31 @@ describe('Route helpers', function () {
|
||||
queryParamSchema: Joi.object({ queryParamA: Joi.string() }).required(),
|
||||
})
|
||||
|
||||
const regexExec = str => regex.exec(str)
|
||||
const regexExec = path => regex.exec(path)
|
||||
test(regexExec, () => {
|
||||
given('/foo/bar/bar.svg').expect(null)
|
||||
})
|
||||
|
||||
const namedParams = str =>
|
||||
namedParamsForMatch(captureNames, regex.exec(str))
|
||||
test(namedParams, () => {
|
||||
forCases([
|
||||
given('/foo/bar.bar.bar.svg'),
|
||||
given('/foo/bar.bar.bar.json'),
|
||||
]).expect({ namedParamA: 'bar.bar.bar' })
|
||||
|
||||
const params = path =>
|
||||
paramsForPath({ regex, captureNames, ServiceClass }, path)
|
||||
test(params, () => {
|
||||
given('/foo/bar.bar.bar.svg').expect({
|
||||
namedParams: { namedParamA: 'bar.bar.bar' },
|
||||
format: 'svg',
|
||||
})
|
||||
given('/foo/bar.bar.bar.json').expect({
|
||||
namedParams: { namedParamA: 'bar.bar.bar' },
|
||||
format: 'json',
|
||||
})
|
||||
// This pattern catches bugs related to escaping the extension separator.
|
||||
given('/foo/bar.bar.bar_svg').expect({ namedParamA: 'bar.bar.bar_svg' })
|
||||
given('/foo/bar.bar.bar.zip').expect({ namedParamA: 'bar.bar.bar.zip' })
|
||||
given('/foo/bar.bar.bar_svg').expect({
|
||||
namedParams: { namedParamA: 'bar.bar.bar_svg' },
|
||||
format: 'svg',
|
||||
})
|
||||
given('/foo/bar.bar.bar.zip').expect({
|
||||
namedParams: { namedParamA: 'bar.bar.bar.zip' },
|
||||
format: 'svg',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,33 +67,41 @@ describe('Route helpers', function () {
|
||||
given('/foo/bar/bar.svg').expect(null)
|
||||
})
|
||||
|
||||
const namedParams = str =>
|
||||
namedParamsForMatch(captureNames, regex.exec(str))
|
||||
test(namedParams, () => {
|
||||
forCases([
|
||||
given('/foo/bar.bar.bar.svg'),
|
||||
given('/foo/bar.bar.bar.json'),
|
||||
]).expect({ namedParamA: 'bar.bar.bar' })
|
||||
const params = path =>
|
||||
paramsForPath({ regex, captureNames, ServiceClass }, path)
|
||||
test(params, () => {
|
||||
given('/foo/bar.bar.bar.svg').expect({
|
||||
namedParams: { namedParamA: 'bar.bar.bar' },
|
||||
format: 'svg',
|
||||
})
|
||||
given('/foo/bar.bar.bar.json').expect({
|
||||
namedParams: { namedParamA: 'bar.bar.bar' },
|
||||
format: 'json',
|
||||
})
|
||||
|
||||
// This pattern catches bugs related to escaping the extension separator.
|
||||
given('/foo/bar.bar.bar_svg').expect({ namedParamA: 'bar.bar.bar_svg' })
|
||||
given('/foo/bar.bar.bar.zip').expect({ namedParamA: 'bar.bar.bar.zip' })
|
||||
given('/foo/bar.bar.bar_svg').expect({
|
||||
namedParams: { namedParamA: 'bar.bar.bar_svg' },
|
||||
format: 'svg',
|
||||
})
|
||||
given('/foo/bar.bar.bar.zip').expect({
|
||||
namedParams: { namedParamA: 'bar.bar.bar.zip' },
|
||||
format: 'svg',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
context('No named params are declared', function () {
|
||||
const { regex, captureNames } = prepareRoute({
|
||||
base: 'foo',
|
||||
format: '(?:[^/]+)',
|
||||
format: '(?:[^/]+?)',
|
||||
})
|
||||
|
||||
const namedParams = str =>
|
||||
namedParamsForMatch(captureNames, regex.exec(str))
|
||||
test(namedParams, () => {
|
||||
forCases([
|
||||
given('/foo/bar.bar.bar.svg'),
|
||||
given('/foo/bar.bar.bar.json'),
|
||||
]).expect({})
|
||||
const params = path =>
|
||||
paramsForPath({ regex, captureNames, ServiceClass }, path)
|
||||
test(params, () => {
|
||||
given('/foo/bar.bar.bar.svg').expect({ namedParams: {}, format: 'svg' })
|
||||
given('/foo/bar.bar.bar.json').expect({ namedParams: {}, format: 'json' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -83,13 +112,13 @@ describe('Route helpers', function () {
|
||||
capture: ['namedParamA'],
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
namedParamsForMatch(captureNames, regex.exec('/foo/bar/baz.svg'), {
|
||||
name: 'MyService',
|
||||
})
|
||||
).to.throw(
|
||||
'Service MyService declares incorrect number of named params (expected 2, got 1)'
|
||||
)
|
||||
it('Throws the expected error', function () {
|
||||
expect(() =>
|
||||
paramsForPath({ regex, captureNames, ServiceClass }, '/foo/bar/baz.svg')
|
||||
).to.throw(
|
||||
'Service MyService declares incorrect number of named params (expected 2, got 1)'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('getQueryParamNames', function () {
|
||||
|
||||
37
core/express-test-harness.js
Normal file
37
core/express-test-harness.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import express from 'express'
|
||||
import portfinder from 'portfinder'
|
||||
import got from './got-test-client.js'
|
||||
|
||||
export class ExpressTestHarness {
|
||||
constructor() {
|
||||
this.app = express()
|
||||
}
|
||||
|
||||
async start() {
|
||||
const port = (this.port = await portfinder.getPortPromise())
|
||||
this.baseUrl = `http://127.0.0.1:${port}`
|
||||
await new Promise(resolve => {
|
||||
this.server = this.app.listen({ host: '::', port }, () => resolve())
|
||||
})
|
||||
}
|
||||
|
||||
async stop() {
|
||||
await new Promise(resolve => this.server.close(resolve))
|
||||
}
|
||||
|
||||
ensureStarted() {
|
||||
if (!this.server) {
|
||||
throw Error('Server has not been started')
|
||||
}
|
||||
}
|
||||
|
||||
async get(url, options) {
|
||||
this.ensureStarted()
|
||||
return got.get(`${this.baseUrl}${url}`, options)
|
||||
}
|
||||
|
||||
async post(url, options) {
|
||||
this.ensureStarted()
|
||||
return got.post(`${this.baseUrl}${url}`, options)
|
||||
}
|
||||
}
|
||||
@@ -37,12 +37,13 @@ export default class PrometheusMetrics {
|
||||
})
|
||||
}
|
||||
|
||||
async registerMetricsEndpoint(server) {
|
||||
async registerMetricsEndpoint(app) {
|
||||
const { register } = this
|
||||
|
||||
server.route(/^\/metrics$/, async (data, match, end, ask) => {
|
||||
ask.res.setHeader('Content-Type', register.contentType)
|
||||
ask.res.end(await register.metrics())
|
||||
app.get('/metrics', async (req, res) => {
|
||||
res.setHeader('Content-Type', register.contentType)
|
||||
res.send(await register.metrics())
|
||||
res.end()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +1,24 @@
|
||||
import { expect } from 'chai'
|
||||
import Camp from '@shields_io/camp'
|
||||
import portfinder from 'portfinder'
|
||||
import got from '../got-test-client.js'
|
||||
import { ExpressTestHarness } from '../express-test-harness.js'
|
||||
import Metrics from './prometheus-metrics.js'
|
||||
|
||||
describe('Prometheus metrics route', function () {
|
||||
let port, baseUrl, camp, metrics
|
||||
let harness, metrics
|
||||
beforeEach(async function () {
|
||||
port = await portfinder.getPortPromise()
|
||||
baseUrl = `http://127.0.0.1:${port}`
|
||||
camp = Camp.start({ port, hostname: '::' })
|
||||
await new Promise(resolve => camp.on('listening', () => resolve()))
|
||||
harness = new ExpressTestHarness()
|
||||
|
||||
metrics = new Metrics()
|
||||
metrics.registerMetricsEndpoint(harness.app)
|
||||
|
||||
await harness.start()
|
||||
})
|
||||
|
||||
afterEach(async function () {
|
||||
if (metrics) {
|
||||
metrics.stop()
|
||||
}
|
||||
if (camp) {
|
||||
await new Promise(resolve => camp.close(resolve))
|
||||
camp = undefined
|
||||
}
|
||||
await harness.stop()
|
||||
})
|
||||
|
||||
it('returns default metrics', async function () {
|
||||
metrics = new Metrics()
|
||||
metrics.registerMetricsEndpoint(camp)
|
||||
|
||||
const { statusCode, body } = await got(`${baseUrl}/metrics`)
|
||||
const { statusCode, body } = await harness.get('/metrics')
|
||||
|
||||
expect(statusCode).to.be.equal(200)
|
||||
expect(body).to.contain('nodejs_version_info')
|
||||
|
||||
@@ -2,19 +2,20 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import http from 'http'
|
||||
import https from 'https'
|
||||
import path from 'path'
|
||||
import url, { fileURLToPath } from 'url'
|
||||
import express from 'express'
|
||||
import { bootstrap } from 'global-agent'
|
||||
import cloudflareMiddleware from 'cloudflare-middleware'
|
||||
import Camp from '@shields_io/camp'
|
||||
import originalJoi from 'joi'
|
||||
import makeBadge from '../../badge-maker/lib/make-badge.js'
|
||||
import GithubConstellation from '../../services/github/github-constellation.js'
|
||||
import LibrariesIoConstellation from '../../services/librariesio/librariesio-constellation.js'
|
||||
import { setRoutes } from '../../services/suggest.js'
|
||||
import { setRoutes as setSuggestRoutes } from '../../services/suggest.js'
|
||||
import { loadServiceClasses } from '../base-service/loader.js'
|
||||
import { makeSend } from '../base-service/legacy-result-sender.js'
|
||||
import { handleRequest } from '../base-service/legacy-request-handler.js'
|
||||
import { makeJsonBadge } from '../base-service/make-json-badge.js'
|
||||
import { clearResourceCache } from '../base-service/resource-cache.js'
|
||||
import { rasterRedirectUrl } from '../badge-urls/make-badge-url.js'
|
||||
import { fileSize, nonNegativeInteger } from '../../services/validators.js'
|
||||
@@ -140,7 +141,9 @@ const publicConfigSchema = Joi.object({
|
||||
weblate: defaultService,
|
||||
trace: Joi.boolean().required(),
|
||||
}).required(),
|
||||
cacheHeaders: { defaultCacheLengthSeconds: nonNegativeInteger },
|
||||
cacheHeaders: Joi.object({
|
||||
defaultCacheLengthSeconds: nonNegativeInteger,
|
||||
}).required(),
|
||||
handleInternalErrors: Joi.boolean().required(),
|
||||
fetchLimit: fileSize,
|
||||
userAgentBase: Joi.string().required(),
|
||||
@@ -197,23 +200,11 @@ const privateMetricsInfluxConfigSchema = privateConfigSchema.append({
|
||||
influx_password: Joi.string().required(),
|
||||
})
|
||||
|
||||
function addHandlerAtIndex(camp, index, handlerFn) {
|
||||
camp.stack.splice(index, 0, handlerFn)
|
||||
}
|
||||
|
||||
function isOnHeroku() {
|
||||
return !!process.env.DYNO
|
||||
}
|
||||
|
||||
function isOnFly() {
|
||||
return !!process.env.FLY_APP_NAME
|
||||
}
|
||||
|
||||
/**
|
||||
* The Server is based on the web framework Scoutcamp. It creates
|
||||
* an http server, sets up helpers for token persistence and monitoring.
|
||||
* Then it loads all the services, injecting dependencies as it
|
||||
* asks each one to register its route with Scoutcamp.
|
||||
* The Server is based on Express. It creates an http server and sets up helpers
|
||||
* for token persistence and monitoring. Then it loads all the services,
|
||||
* injecting dependencies, as it asks each one to register its route with
|
||||
* Express.
|
||||
*/
|
||||
class Server {
|
||||
/**
|
||||
@@ -306,45 +297,25 @@ class Server {
|
||||
|
||||
// See https://www.viget.com/articles/heroku-cloudflare-the-right-way/
|
||||
requireCloudflare() {
|
||||
// Set `req.ip`, which is expected by `cloudflareMiddleware()`. This is set
|
||||
// by Express but not Scoutcamp.
|
||||
addHandlerAtIndex(this.camp, 0, function (req, res, next) {
|
||||
if (isOnHeroku()) {
|
||||
// On Heroku, `req.socket.remoteAddress` is the Heroku router. However,
|
||||
// the router ensures that the last item in the `X-Forwarded-For` header
|
||||
// is the real origin.
|
||||
// https://stackoverflow.com/a/18517550/893113
|
||||
req.ip = req.headers['x-forwarded-for'].split(', ').pop()
|
||||
} else if (isOnFly()) {
|
||||
// On Fly we can use the Fly-Client-IP header
|
||||
// https://fly.io/docs/reference/runtime-environment/#request-headers
|
||||
req.ip = req.headers['fly-client-ip']
|
||||
? req.headers['fly-client-ip']
|
||||
: req.socket.remoteAddress
|
||||
} else {
|
||||
req.ip = req.socket.remoteAddress
|
||||
}
|
||||
next()
|
||||
})
|
||||
addHandlerAtIndex(this.camp, 1, cloudflareMiddleware())
|
||||
const { app } = this
|
||||
app.use(cloudflareMiddleware())
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up Scoutcamp routes for 404/not found responses
|
||||
* Set up Express routes for 404/not found responses.
|
||||
*/
|
||||
registerErrorHandlers() {
|
||||
const { camp, config } = this
|
||||
const { app, config } = this
|
||||
const {
|
||||
public: { rasterUrl },
|
||||
} = config
|
||||
|
||||
camp.route(/\.(gif|jpg)$/, (query, match, end, request) => {
|
||||
const [, format] = match
|
||||
makeSend(
|
||||
'svg',
|
||||
request.res,
|
||||
end
|
||||
)(
|
||||
app.get(/\.(gif|jpg)$/, (req, res) => {
|
||||
res.status(410)
|
||||
res.setHeader('Content-Type', 'image/svg+xml;charset=utf-8')
|
||||
|
||||
const format = req.params[0]
|
||||
res.send(
|
||||
makeBadge({
|
||||
label: '410',
|
||||
message: `${format} no longer available`,
|
||||
@@ -352,41 +323,53 @@ class Server {
|
||||
format: 'svg',
|
||||
})
|
||||
)
|
||||
|
||||
res.end()
|
||||
})
|
||||
|
||||
if (!rasterUrl) {
|
||||
camp.route(/\.png$/, (query, match, end, request) => {
|
||||
makeSend(
|
||||
'svg',
|
||||
request.res,
|
||||
end
|
||||
)(
|
||||
app.get(/\.png$/, (req, res) => {
|
||||
res.status(404)
|
||||
res.setHeader('Content-Type', 'image/svg+xml;charset=utf-8')
|
||||
res.send(
|
||||
makeBadge({
|
||||
label: '404',
|
||||
message: 'raster badges not available',
|
||||
color: 'lightgray',
|
||||
format: 'svg',
|
||||
})
|
||||
)
|
||||
res.end()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
camp.notfound(/(\.svg|\.json|)$/, (query, match, end, request) => {
|
||||
const [, extension] = match
|
||||
const format = (extension || '.svg').replace(/^\./, '')
|
||||
registerNotFoundHandlers() {
|
||||
const { app } = this
|
||||
|
||||
makeSend(
|
||||
format,
|
||||
request.res,
|
||||
end
|
||||
)(
|
||||
app.get(/\.json$/, (req, res) => {
|
||||
res.status(404)
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.json(
|
||||
makeJsonBadge({
|
||||
label: '404',
|
||||
message: 'badge not found',
|
||||
color: 'red',
|
||||
})
|
||||
)
|
||||
res.end()
|
||||
})
|
||||
|
||||
app.get(/(?:\.svg|)$/, (req, res) => {
|
||||
res.status(404)
|
||||
res.setHeader('Content-Type', 'image/svg+xml;charset=utf-8')
|
||||
res.send(
|
||||
makeBadge({
|
||||
label: '404',
|
||||
message: 'badge not found',
|
||||
color: 'red',
|
||||
format,
|
||||
})
|
||||
)
|
||||
res.end()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -398,54 +381,62 @@ class Server {
|
||||
* to {@link https://shields.io/} )
|
||||
*/
|
||||
registerRedirects() {
|
||||
const { config, camp } = this
|
||||
const { config, app } = this
|
||||
const {
|
||||
public: { rasterUrl, redirectUrl },
|
||||
} = config
|
||||
|
||||
if (rasterUrl) {
|
||||
// Redirect to the raster server for raster versions of modern badges.
|
||||
camp.route(/\.png$/, (queryParams, match, end, ask) => {
|
||||
ask.res.statusCode = 301
|
||||
ask.res.setHeader(
|
||||
'Location',
|
||||
rasterRedirectUrl({ rasterUrl }, ask.req.url)
|
||||
)
|
||||
app.get(/\.png$/, (req, res) => {
|
||||
res.status(301)
|
||||
res.setHeader('Location', rasterRedirectUrl({ rasterUrl }, req.url))
|
||||
|
||||
const cacheDuration = (30 * 24 * 3600) | 0 // 30 days.
|
||||
ask.res.setHeader('Cache-Control', `max-age=${cacheDuration}`)
|
||||
res.setHeader('Cache-Control', `max-age=${cacheDuration}`)
|
||||
|
||||
ask.res.end()
|
||||
res.end()
|
||||
})
|
||||
}
|
||||
|
||||
if (redirectUrl) {
|
||||
camp.route(/^\/$/, (data, match, end, ask) => {
|
||||
ask.res.statusCode = 302
|
||||
ask.res.setHeader('Location', redirectUrl)
|
||||
ask.res.end()
|
||||
app.get('/', (req, res) => {
|
||||
res.status(302)
|
||||
res.setHeader('Location', redirectUrl)
|
||||
res.end()
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
This is here for legacy reasons. The badge server and frontend used to live
|
||||
on two different servers. When we merged them there was a conflict so we did
|
||||
this to avoid moving the endpoint docs to another URL.
|
||||
|
||||
Never ever do this again.
|
||||
*/
|
||||
app.use('/endpoint', (req, res, next) => {
|
||||
if (Object.keys(req.query).length === 0) {
|
||||
res.status(301)
|
||||
res.setHeader('Location', '/endpoint/')
|
||||
res.end()
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate all the service classes defined in /services,
|
||||
* load each service and register a Scoutcamp route for each service.
|
||||
* load each service and register an Express route for each service.
|
||||
*/
|
||||
async registerServices() {
|
||||
const { config, camp, metricInstance } = this
|
||||
const { app, config, metricInstance } = this
|
||||
const { apiProvider: githubApiProvider } = this.githubConstellation
|
||||
const { apiProvider: librariesIoApiProvider } =
|
||||
this.librariesioConstellation
|
||||
;(await loadServiceClasses()).forEach(serviceClass =>
|
||||
serviceClass.register(
|
||||
{
|
||||
camp,
|
||||
handleRequest,
|
||||
githubApiProvider,
|
||||
librariesIoApiProvider,
|
||||
metricInstance,
|
||||
},
|
||||
{ app, githubApiProvider, librariesIoApiProvider, metricInstance },
|
||||
{
|
||||
handleInternalErrors: config.public.handleInternalErrors,
|
||||
cacheHeaders: config.public.cacheHeaders,
|
||||
@@ -478,11 +469,14 @@ class Server {
|
||||
|
||||
/**
|
||||
* Start the HTTP server:
|
||||
* Bootstrap Scoutcamp,
|
||||
* Bootstrap Express,
|
||||
* Register handlers,
|
||||
* Start listening for requests on this.baseUrl()
|
||||
*
|
||||
* @param {Function} registerExtras Optional function to register additional
|
||||
* routes, used for testing.
|
||||
*/
|
||||
async start() {
|
||||
async start(registerExtras) {
|
||||
const {
|
||||
bind: { port, address: hostname },
|
||||
ssl: { isSecure: secure, cert, key },
|
||||
@@ -494,25 +488,17 @@ class Server {
|
||||
|
||||
log.log(`Server is starting up: ${this.baseUrl}`)
|
||||
|
||||
const camp = (this.camp = Camp.create({
|
||||
documentRoot: this.config.public.documentRoot,
|
||||
port,
|
||||
hostname,
|
||||
secure,
|
||||
staticMaxAge: 300,
|
||||
cert,
|
||||
key,
|
||||
}))
|
||||
const app = (this.app = express())
|
||||
|
||||
if (requireCloudflare) {
|
||||
this.requireCloudflare()
|
||||
}
|
||||
|
||||
const { githubConstellation, metricInstance } = this
|
||||
await githubConstellation.initialize(camp)
|
||||
await githubConstellation.initialize(app)
|
||||
if (metricInstance) {
|
||||
if (this.config.public.metrics.prometheus.endpointEnabled) {
|
||||
metricInstance.registerMetricsEndpoint(camp)
|
||||
metricInstance.registerMetricsEndpoint(app)
|
||||
}
|
||||
if (this.influxMetrics) {
|
||||
this.influxMetrics.startPushingMetrics()
|
||||
@@ -520,39 +506,47 @@ class Server {
|
||||
}
|
||||
|
||||
const { apiProvider: githubApiProvider } = this.githubConstellation
|
||||
setRoutes(allowedOrigin, githubApiProvider, camp)
|
||||
setSuggestRoutes(allowedOrigin, githubApiProvider, app)
|
||||
|
||||
// https://github.com/badges/shields/issues/3273
|
||||
camp.handle((req, res, next) => {
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
next()
|
||||
})
|
||||
|
||||
this.registerErrorHandlers()
|
||||
this.registerRedirects()
|
||||
await this.registerServices()
|
||||
|
||||
camp.timeout = this.config.public.requestTimeoutSeconds * 1000
|
||||
if (this.config.public.requestTimeoutSeconds > 0) {
|
||||
camp.on('timeout', socket => {
|
||||
const maxAge = this.config.public.requestTimeoutMaxAgeSeconds
|
||||
socket.write('HTTP/1.1 408 Request Timeout\r\n')
|
||||
socket.write('Content-Type: text/html; charset=UTF-8\r\n')
|
||||
socket.write('Content-Encoding: UTF-8\r\n')
|
||||
socket.write(`Cache-Control: max-age=${maxAge}, s-maxage=${maxAge}\r\n`)
|
||||
socket.write('Connection: close\r\n\r\n')
|
||||
socket.write('Request Timeout')
|
||||
socket.end()
|
||||
app.use(
|
||||
express.static(this.config.public.documentRoot, {
|
||||
// Since express's `maxAge` parameter sets `Cache-Control: public`, set
|
||||
// the headers manually insetad.
|
||||
cacheControl: false,
|
||||
setHeaders: res =>
|
||||
res.setHeader('Cache-Control', 'max-age=300, s-maxage=300'),
|
||||
})
|
||||
)
|
||||
await this.registerServices()
|
||||
if (registerExtras) {
|
||||
registerExtras(app)
|
||||
}
|
||||
camp.listenAsConfigured()
|
||||
this.registerNotFoundHandlers()
|
||||
|
||||
await new Promise(resolve => camp.on('listening', () => resolve()))
|
||||
if (secure) {
|
||||
this.server = https.createServer({ hostname, cert, key }, app)
|
||||
} else {
|
||||
this.server = http.createServer({ hostname }, app)
|
||||
}
|
||||
|
||||
this.server.setTimeout(this.config.public.requestTimeoutSeconds * 1000)
|
||||
|
||||
await new Promise(resolve =>
|
||||
this.server.listen({ host: hostname, port }, () => resolve())
|
||||
)
|
||||
}
|
||||
|
||||
static resetGlobalState() {
|
||||
// This state should be migrated to instance state. When possible, do not add new
|
||||
// global state.
|
||||
// TODO: This state should be migrated to instance state. When possible, do
|
||||
// not add new global state.
|
||||
clearResourceCache()
|
||||
}
|
||||
|
||||
@@ -564,10 +558,11 @@ class Server {
|
||||
* Stop the HTTP server and clean up helpers
|
||||
*/
|
||||
async stop() {
|
||||
if (this.camp) {
|
||||
await new Promise(resolve => this.camp.close(resolve))
|
||||
this.camp = undefined
|
||||
if (this.server) {
|
||||
await new Promise(resolve => this.server.close(() => resolve()))
|
||||
this.server = undefined
|
||||
}
|
||||
this.app = undefined
|
||||
|
||||
if (this.cleanupMonitor) {
|
||||
this.cleanupMonitor()
|
||||
|
||||
@@ -73,9 +73,7 @@ describe('The server', function () {
|
||||
it('should redirect colorscheme PNG badges as configured', async function () {
|
||||
const { statusCode, headers } = await got(
|
||||
`${baseUrl}:fruit-apple-green.png`,
|
||||
{
|
||||
followRedirect: false,
|
||||
}
|
||||
{ followRedirect: false }
|
||||
)
|
||||
expect(statusCode).to.equal(301)
|
||||
expect(headers.location).to.equal(
|
||||
@@ -98,7 +96,7 @@ describe('The server', function () {
|
||||
`${baseUrl}:fruit-apple-green.svg`
|
||||
)
|
||||
expect(statusCode).to.equal(200)
|
||||
expect(headers['content-type']).to.equal('image/svg+xml;charset=utf-8')
|
||||
expect(headers['content-type']).to.equal('image/svg+xml; charset=utf-8')
|
||||
expect(headers['content-length']).to.equal('1130')
|
||||
})
|
||||
|
||||
@@ -112,7 +110,9 @@ describe('The server', function () {
|
||||
`${baseUrl}:fruit-apple-green.json`
|
||||
)
|
||||
expect(statusCode).to.equal(200)
|
||||
expect(headers['content-type']).to.equal('application/json')
|
||||
expect(headers['content-type']).to.equal(
|
||||
'application/json; charset=utf-8'
|
||||
)
|
||||
expect(headers['access-control-allow-origin']).to.equal('*')
|
||||
expect(headers['content-length']).to.equal('92')
|
||||
expect(() => JSON.parse(body)).not.to.throw()
|
||||
@@ -200,19 +200,12 @@ describe('The server', function () {
|
||||
const { statusCode, body } = await got(`${baseUrl}npm/v/express.jpg`, {
|
||||
throwHttpErrors: false,
|
||||
})
|
||||
// TODO It would be nice if this were 404 or 410.
|
||||
expect(statusCode).to.equal(200)
|
||||
expect(statusCode).to.equal(410)
|
||||
expect(body)
|
||||
.to.satisfy(isSvg)
|
||||
.and.to.include('410')
|
||||
.and.to.include('jpg no longer available')
|
||||
})
|
||||
|
||||
it('should return cors header for the request', async function () {
|
||||
const { statusCode, headers } = await got(`${baseUrl}npm/v/express.svg`)
|
||||
expect(statusCode).to.equal(200)
|
||||
expect(headers['access-control-allow-origin']).to.equal('*')
|
||||
})
|
||||
})
|
||||
|
||||
context('`requireCloudflare` is enabled', function () {
|
||||
@@ -245,22 +238,12 @@ describe('The server', function () {
|
||||
|
||||
// configure server to time out requests that take >2 seconds
|
||||
server = await createTestServer({ public: { requestTimeoutSeconds: 2 } })
|
||||
await server.start()
|
||||
await server.start(app => {
|
||||
// /fast returns a 200 OK after a 1 second delay
|
||||
app.get('/fast', (req, res) => setTimeout(() => res.end(), 1000))
|
||||
|
||||
// /fast returns a 200 OK after a 1 second delay
|
||||
server.camp.route(/^\/fast$/, (data, match, end, ask) => {
|
||||
setTimeout(() => {
|
||||
ask.res.statusCode = 200
|
||||
ask.res.end()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
// /slow returns a 200 OK after a 3 second delay
|
||||
server.camp.route(/^\/slow$/, (data, match, end, ask) => {
|
||||
setTimeout(() => {
|
||||
ask.res.statusCode = 200
|
||||
ask.res.end()
|
||||
}, 3000)
|
||||
// /slow returns a 200 OK after a 3 second delay
|
||||
app.get('/slow', (req, res) => setTimeout(() => res.end(), 3000))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -273,11 +256,9 @@ describe('The server', function () {
|
||||
|
||||
it('should time out slow requests', async function () {
|
||||
this.timeout(10000)
|
||||
const { statusCode, body } = await got(`${server.baseUrl}slow`, {
|
||||
throwHttpErrors: false,
|
||||
})
|
||||
expect(statusCode).to.be.equal(408)
|
||||
expect(body).to.equal('Request Timeout')
|
||||
await expect(got(`${server.baseUrl}slow`)).to.be.rejectedWith(
|
||||
'socket hang up'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not time out fast requests', async function () {
|
||||
|
||||
@@ -80,29 +80,22 @@ test this kind of logic through unit tests (e.g. of `render()` and
|
||||
reporting, loads config, and creates an instance of the server.
|
||||
|
||||
2. The Server, which is defined in
|
||||
[`core/server/server.js`][core/server/server], is based on the web
|
||||
framework [Scoutcamp][]. It creates an http server, sets up helpers for
|
||||
token persistence and monitoring. Then it loads all the services,
|
||||
injecting dependencies as it asks each one to register its route
|
||||
with Scoutcamp.
|
||||
[`core/server/server.js`][core/server/server], is based on [Express][].
|
||||
It creates an http server, sets up helpers for token persistence and
|
||||
monitoring. Then it loads all the services, injecting dependencies as it
|
||||
asks each one to register its route with the Express app.
|
||||
|
||||
3. The service registration continues in `BaseService.register`. From its
|
||||
`route` property, it derives a regular expression to match the route
|
||||
path, and invokes `camp.route` with this value.
|
||||
path, and invokes `app.get` with this value.
|
||||
|
||||
4. At this point the situation gets gnarly and hard to follow. For the
|
||||
purpose of initialization, suffice it to say that `camp.route` invokes a
|
||||
callback with the four parameters `( queryParams, match, end, ask )` which
|
||||
is created in a legacy helper function in
|
||||
[`legacy-request-handler.js`][legacy-request-handler]. This callback
|
||||
delegates to a callback in `BaseService.register` with four different
|
||||
parameters `( queryParams, match, sendBadge )`, which
|
||||
then runs `BaseService.invoke`. `BaseService.invoke` instantiates the
|
||||
service and runs `BaseService#handle`.
|
||||
4. TODO: Explain what happens here (i.e. now that we've migrated from Scoutcamp
|
||||
to Express). `BaseService.invoke` instantiates the service and runs
|
||||
`BaseService#handle`.
|
||||
|
||||
[entrypoint]: https://github.com/badges/shields/blob/master/server.js
|
||||
[core/server/server]: https://github.com/badges/shields/blob/master/core/server/server.js
|
||||
[scoutcamp]: https://github.com/espadrine/sc
|
||||
[express]: https://expressjs.com/
|
||||
[legacy-request-handler]: https://github.com/badges/shields/blob/master/core/base-service/legacy-request-handler.js
|
||||
|
||||
## Downstream caching
|
||||
@@ -119,24 +112,15 @@ test this kind of logic through unit tests (e.g. of `render()` and
|
||||
|
||||
## How the server makes a badge
|
||||
|
||||
1. An HTTPS request arrives. Scoutcamp inspects the URL path and matches it
|
||||
against the regexes for all the registered routes until it finds one that
|
||||
matches. (See *Initialization* above for an explanation of how routes are
|
||||
1. An HTTPS request arrives. Express inspects the URL path and matches it
|
||||
against all the registered routes until it finds one that matches. (See
|
||||
*Initialization* above for an explanation of how routes are
|
||||
registered.)
|
||||
2. Scoutcamp invokes a callback with the four parameters:
|
||||
`( queryParams, match, end, ask )`. This callback is defined in
|
||||
[`legacy-request-handler`][legacy-request-handler]. A timeout is set to
|
||||
handle unresponsive service code and the next callback is invoked: the
|
||||
legacy handler function.
|
||||
3. The legacy handler function receives
|
||||
`( queryParams, match, sendBadge )`. Its job is to extract data
|
||||
from the regex `match` and `queryParams`, and then invoke `sendBadge`
|
||||
with the result.
|
||||
4. The implementation of this function is in `BaseService.register`. It
|
||||
works by running `BaseService.invoke`, which instantiates the service,
|
||||
injects more dependencies, and invokes `BaseService.handle` which is
|
||||
implemented by the service subclass.
|
||||
5. The job of `handle()`, which should be implemented by each service
|
||||
2. Invoke the request handler function, defined in `BaseService.register`,
|
||||
which handles the request. It runs `BaseService.invoke`, which instantiates
|
||||
the service, injects more dependencies, and invokes `BaseService.handle`
|
||||
which is implemented by the service subclass.
|
||||
3. The job of `handle()`, which should be implemented by each service
|
||||
subclass, is to return an object which partially describes a badge or
|
||||
throw one of the handled error classes. "Partially rendered" most
|
||||
commonly means a non-empty message and an optional color. In the case
|
||||
@@ -146,7 +130,7 @@ test this kind of logic through unit tests (e.g. of `render()` and
|
||||
Throwing any other error is a programmer error which will be
|
||||
[reported][error reporting] and described to the user as a **shields
|
||||
internal error**.
|
||||
6. A typical `handle()` function delegates to one or more helpers to
|
||||
4. A typical `handle()` function delegates to one or more helpers to
|
||||
handle stages of the request:
|
||||
1. **fetch**: load the needed data from the upstream service and
|
||||
validate it
|
||||
@@ -154,13 +138,13 @@ test this kind of logic through unit tests (e.g. of `render()` and
|
||||
into a few properties which will be displayed on the badge
|
||||
3. **render**: given a few properties, return a message, optional
|
||||
color, and optional label.
|
||||
7. When an error is thrown, BaseService steps in and converts the error
|
||||
5. When an error is thrown, BaseService steps in and converts the error
|
||||
object to renderable properties: `{ isError, message, color }`.
|
||||
8. The service invokes [`coalesceBadge`][coalescebadge] whose job is to
|
||||
6. The service invokes [`coalesceBadge`][coalescebadge] whose job is to
|
||||
coalesce query string overrides with values from the service and the
|
||||
service’s defaults to produce an object that fully describes the badge to
|
||||
be rendered.
|
||||
9. `sendBadge` is invoked with that object. It does some housekeeping on the
|
||||
7. `sendBadge` is invoked with that object. It does some housekeeping on the
|
||||
timeout. Then it renders the badge to svg or raster and pushes out the
|
||||
result over the HTTPS connection.
|
||||
|
||||
|
||||
2109
package-lock.json
generated
2109
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
39
package.json
39
package.json
@@ -24,8 +24,7 @@
|
||||
"@fontsource/lato": "^4.5.5",
|
||||
"@fontsource/lekton": "^4.5.6",
|
||||
"@renovate/pep440": "^1.0.0",
|
||||
"@sentry/node": "^6.19.7",
|
||||
"@shields_io/camp": "^18.1.1",
|
||||
"@sentry/node": "^6.19.6",
|
||||
"badge-maker": "file:badge-maker",
|
||||
"bytes": "^3.1.2",
|
||||
"camelcase": "^6.3.0",
|
||||
@@ -37,10 +36,11 @@
|
||||
"decamelize": "^3.2.0",
|
||||
"emojic": "^1.1.17",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"express": "^4.17.3",
|
||||
"fast-xml-parser": "^4.0.7",
|
||||
"glob": "^8.0.1",
|
||||
"global-agent": "^3.0.0",
|
||||
"got": "^12.0.4",
|
||||
"got": "^12.0.3",
|
||||
"graphql": "^15.6.1",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"ioredis": "5.0.4",
|
||||
@@ -51,7 +51,8 @@
|
||||
"lodash.countby": "^4.6.0",
|
||||
"lodash.groupby": "^4.6.0",
|
||||
"lodash.times": "^4.3.2",
|
||||
"moment": "^2.29.3",
|
||||
"moment": "^2.29.2",
|
||||
"multer": "^1.4.4",
|
||||
"node-env-flag": "^0.1.0",
|
||||
"parse-link-header": "^2.0.0",
|
||||
"path-to-regexp": "^6.2.0",
|
||||
@@ -61,7 +62,7 @@
|
||||
"qs": "^6.10.3",
|
||||
"query-string": "^7.1.1",
|
||||
"semver": "~7.3.7",
|
||||
"simple-icons": "6.19.0",
|
||||
"simple-icons": "6.18.0",
|
||||
"webextension-store-meta": "^1.0.5",
|
||||
"xmldom": "~0.6.0",
|
||||
"xpath": "~0.0.32"
|
||||
@@ -147,19 +148,19 @@
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
"@mapbox/react-click-to-select": "^2.2.1",
|
||||
"@types/chai": "^4.3.1",
|
||||
"@types/lodash.debounce": "^4.0.7",
|
||||
"@types/lodash.groupby": "^4.6.7",
|
||||
"@types/mocha": "^9.1.1",
|
||||
"@types/lodash.debounce": "^4.0.6",
|
||||
"@types/lodash.groupby": "^4.6.6",
|
||||
"@types/mocha": "^9.1.0",
|
||||
"@types/node": "^16.7.10",
|
||||
"@types/react-helmet": "^6.1.5",
|
||||
"@types/react-modal": "^3.13.1",
|
||||
"@types/react-select": "^4.0.17",
|
||||
"@types/styled-components": "5.1.25",
|
||||
"@typescript-eslint/eslint-plugin": "^5.21.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.19.0",
|
||||
"@typescript-eslint/parser": "^5.15.0",
|
||||
"babel-plugin-inline-react-svg": "^2.0.1",
|
||||
"babel-preset-gatsby": "^2.13.0",
|
||||
"c8": "^7.11.2",
|
||||
"babel-preset-gatsby": "^2.11.1",
|
||||
"c8": "^7.11.0",
|
||||
"caller": "^1.1.0",
|
||||
"chai": "^4.3.6",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
@@ -168,8 +169,8 @@
|
||||
"child-process-promise": "^2.2.1",
|
||||
"clipboard-copy": "^4.0.1",
|
||||
"concurrently": "^7.1.0",
|
||||
"cypress": "^9.6.0",
|
||||
"danger": "^11.0.5",
|
||||
"cypress": "^9.5.4",
|
||||
"danger": "^11.0.2",
|
||||
"danger-plugin-no-test-shortcuts": "^2.0.0",
|
||||
"deepmerge": "^4.2.2",
|
||||
"eslint": "^7.32.0",
|
||||
@@ -180,13 +181,13 @@
|
||||
"eslint-plugin-chai-friendly": "^0.7.2",
|
||||
"eslint-plugin-cypress": "^2.12.1",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jsdoc": "^39.2.9",
|
||||
"eslint-plugin-jsdoc": "^39.2.7",
|
||||
"eslint-plugin-mocha": "^10.0.3",
|
||||
"eslint-plugin-no-extension-in-require": "^0.2.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-promise": "^5.2.0",
|
||||
"eslint-plugin-react": "^7.29.4",
|
||||
"eslint-plugin-react-hooks": "^4.5.0",
|
||||
"eslint-plugin-react-hooks": "^4.4.0",
|
||||
"eslint-plugin-sort-class-members": "^1.14.1",
|
||||
"fetch-ponyfill": "^7.1.0",
|
||||
"form-data": "^4.0.0",
|
||||
@@ -203,7 +204,7 @@
|
||||
"is-svg": "^4.3.2",
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
"jsdoc": "^3.6.10",
|
||||
"lint-staged": "^12.4.1",
|
||||
"lint-staged": "^12.3.8",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.difference": "^4.5.0",
|
||||
"minimist": "^1.2.6",
|
||||
@@ -213,7 +214,7 @@
|
||||
"mocha-yaml-loader": "^1.0.3",
|
||||
"nock": "13.2.4",
|
||||
"node-mocks-http": "^1.11.0",
|
||||
"nodemon": "^2.0.16",
|
||||
"nodemon": "^2.0.15",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"open-cli": "^7.0.1",
|
||||
"portfinder": "^1.0.28",
|
||||
@@ -222,7 +223,7 @@
|
||||
"react-dom": "^17.0.2",
|
||||
"react-error-overlay": "^6.0.11",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-modal": "^3.15.1",
|
||||
"react-modal": "^3.14.4",
|
||||
"react-pose": "^4.0.10",
|
||||
"react-select": "^4.3.1",
|
||||
"read-all-stdin-sync": "^1.0.5",
|
||||
@@ -237,7 +238,7 @@
|
||||
"styled-components": "^5.3.5",
|
||||
"ts-mocha": "^9.0.2",
|
||||
"tsd": "^0.20.0",
|
||||
"typescript": "^4.6.4",
|
||||
"typescript": "^4.6.3",
|
||||
"url": "^0.11.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import queryString from 'query-string'
|
||||
import multer from 'multer'
|
||||
import { fetch } from '../../../core/base-service/got.js'
|
||||
import log from '../../../core/server/log.js'
|
||||
|
||||
function setRoutes({ server, authHelper, onTokenAccepted }) {
|
||||
function setRoutes({ app, authHelper, onTokenAccepted }) {
|
||||
const baseUrl = process.env.GATSBY_BASE_URL || 'https://img.shields.io'
|
||||
|
||||
server.route(/^\/github-auth$/, (data, match, end, ask) => {
|
||||
ask.res.statusCode = 302 // Found.
|
||||
app.post('/github-auth', (req, res) => {
|
||||
res.status(302) // Found.
|
||||
const query = queryString.stringify({
|
||||
// TODO The `_user` property bypasses security checks in AuthHelper.
|
||||
// (e.g: enforceStrictSsl and shouldAuthenticateRequest).
|
||||
@@ -15,56 +16,64 @@ function setRoutes({ server, authHelper, onTokenAccepted }) {
|
||||
client_id: authHelper._user,
|
||||
redirect_uri: `${baseUrl}/github-auth/done`,
|
||||
})
|
||||
ask.res.setHeader(
|
||||
res.setHeader(
|
||||
'Location',
|
||||
`https://github.com/login/oauth/authorize?${query}`
|
||||
)
|
||||
end('')
|
||||
res.end()
|
||||
})
|
||||
|
||||
server.route(/^\/github-auth\/done$/, async (data, match, end, ask) => {
|
||||
if (!data.code) {
|
||||
log.log(`GitHub OAuth data: ${JSON.stringify(data)}`)
|
||||
return end('GitHub OAuth authentication failed to provide a code.')
|
||||
}
|
||||
app.post('/github-auth/done', multer().none(), async (req, res) => {
|
||||
const code = (req.body ?? {}).code
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
},
|
||||
form: {
|
||||
// TODO The `_user` and `_pass` properties bypass security checks in
|
||||
// AuthHelper (e.g: enforceStrictSsl and shouldAuthenticateRequest).
|
||||
// Do not use them elsewhere. It would be better to clean
|
||||
// this up so it's not setting a bad example.
|
||||
client_id: authHelper._user,
|
||||
client_secret: authHelper._pass,
|
||||
code: data.code,
|
||||
},
|
||||
if (!code) {
|
||||
log.log(`GitHub OAuth data: ${JSON.stringify(req.body)}`)
|
||||
res.send('GitHub OAuth authentication failed to provide a code.')
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
let resp
|
||||
try {
|
||||
resp = await fetch('https://github.com/login/oauth/access_token', options)
|
||||
resp = await fetch('https://github.com/login/oauth/access_token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
},
|
||||
form: {
|
||||
// TODO The `_user` and `_pass` properties bypass security checks in
|
||||
// AuthHelper (e.g: enforceStrictSsl and shouldAuthenticateRequest).
|
||||
// Do not use them elsewhere. It would be better to clean
|
||||
// this up so it's not setting a bad example.
|
||||
client_id: authHelper._user,
|
||||
client_secret: authHelper._pass,
|
||||
code,
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
return end('The connection to GitHub failed.')
|
||||
res.send('The connection to GitHub failed.')
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
let content
|
||||
try {
|
||||
content = queryString.parse(resp.buffer)
|
||||
} catch (e) {
|
||||
return end('The GitHub OAuth token could not be parsed.')
|
||||
res.send('The GitHub OAuth token could not be parsed.')
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const { access_token: token } = content
|
||||
if (!token) {
|
||||
return end('The GitHub OAuth process did not return a user token.')
|
||||
res.send('The GitHub OAuth process did not return a user token.')
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
ask.res.setHeader('Content-Type', 'text/html')
|
||||
end(
|
||||
res.setHeader('Content-Type', 'text/html')
|
||||
res.send(
|
||||
'<p>Shields.io has received your app-specific GitHub user token. ' +
|
||||
'You can revoke it by going to ' +
|
||||
'<a href="https://github.com/settings/applications">GitHub</a>.</p>' +
|
||||
@@ -75,6 +84,7 @@ function setRoutes({ server, authHelper, onTokenAccepted }) {
|
||||
'everyone!</p>' +
|
||||
'<p><a href="/">Back to the website</a></p>'
|
||||
)
|
||||
res.end()
|
||||
|
||||
onTokenAccepted(token)
|
||||
})
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { expect } from 'chai'
|
||||
import Camp from '@shields_io/camp'
|
||||
import FormData from 'form-data'
|
||||
import sinon from 'sinon'
|
||||
import portfinder from 'portfinder'
|
||||
import queryString from 'query-string'
|
||||
import nock from 'nock'
|
||||
import got from '../../../core/got-test-client.js'
|
||||
import { ExpressTestHarness } from '../../../core/express-test-harness.js'
|
||||
import GithubConstellation from '../github-constellation.js'
|
||||
import { setRoutes } from './acceptor.js'
|
||||
|
||||
@@ -17,36 +15,26 @@ describe('Github token acceptor', function () {
|
||||
private: { gh_client_id: fakeClientId, gh_client_secret: fakeClientSecret },
|
||||
})
|
||||
|
||||
let port, baseUrl
|
||||
let harness, onTokenAccepted
|
||||
beforeEach(async function () {
|
||||
port = await portfinder.getPortPromise()
|
||||
baseUrl = `http://127.0.0.1:${port}`
|
||||
})
|
||||
harness = new ExpressTestHarness()
|
||||
|
||||
let camp
|
||||
beforeEach(async function () {
|
||||
camp = Camp.start({ port, hostname: '::' })
|
||||
await new Promise(resolve => camp.on('listening', () => resolve()))
|
||||
})
|
||||
afterEach(async function () {
|
||||
if (camp) {
|
||||
await new Promise(resolve => camp.close(resolve))
|
||||
camp = undefined
|
||||
}
|
||||
})
|
||||
|
||||
let onTokenAccepted
|
||||
beforeEach(function () {
|
||||
onTokenAccepted = sinon.stub()
|
||||
setRoutes({
|
||||
server: camp,
|
||||
app: harness.app,
|
||||
authHelper: oauthHelper,
|
||||
onTokenAccepted,
|
||||
})
|
||||
|
||||
await harness.start()
|
||||
})
|
||||
|
||||
afterEach(async function () {
|
||||
await harness.stop()
|
||||
})
|
||||
|
||||
it('should start the OAuth process', async function () {
|
||||
const res = await got(`${baseUrl}/github-auth`, { followRedirect: false })
|
||||
const res = await harness.post('/github-auth', { followRedirect: false })
|
||||
|
||||
expect(res.statusCode).to.equal(302)
|
||||
|
||||
@@ -61,8 +49,8 @@ describe('Github token acceptor', function () {
|
||||
describe('Finishing the OAuth process', function () {
|
||||
context('no code is provided', function () {
|
||||
it('should return an error', async function () {
|
||||
const res = await got(`${baseUrl}/github-auth/done`)
|
||||
expect(res.body).to.equal(
|
||||
const { body } = await harness.post('/github-auth/done')
|
||||
expect(body).to.equal(
|
||||
'GitHub OAuth authentication failed to provide a code.'
|
||||
)
|
||||
})
|
||||
@@ -111,9 +99,7 @@ describe('Github token acceptor', function () {
|
||||
const form = new FormData()
|
||||
form.append('code', fakeCode)
|
||||
|
||||
const res = await got.post(`${baseUrl}/github-auth/done`, {
|
||||
body: form,
|
||||
})
|
||||
const res = await harness.post('/github-auth/done', { body: form })
|
||||
expect(res.body).to.startWith(
|
||||
'<p>Shields.io has received your app-specific GitHub user token.'
|
||||
)
|
||||
|
||||
@@ -50,7 +50,7 @@ class GithubConstellation {
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(server) {
|
||||
async initialize(app) {
|
||||
if (!this.apiProvider.withPooling) {
|
||||
return
|
||||
}
|
||||
@@ -74,7 +74,7 @@ class GithubConstellation {
|
||||
|
||||
if (this.oauthHelper.isConfigured) {
|
||||
setAcceptorRoutes({
|
||||
server,
|
||||
app,
|
||||
authHelper: this.oauthHelper,
|
||||
onTokenAccepted: tokenString => this.onTokenAdded(tokenString),
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ t.create('package not verified publisher').get('/utf.json').expectBadge({
|
||||
color: 'lightgrey',
|
||||
})
|
||||
|
||||
t.create('package not found').get('/doesnotexist.json').expectBadge({
|
||||
t.create('package not found').get('/does-not-exist.json').expectBadge({
|
||||
label: 'publisher',
|
||||
message: 'not found',
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ t.create('package pre-release version')
|
||||
message: isVPlusTripleDottedVersion,
|
||||
})
|
||||
|
||||
t.create('package not found').get('/v/doesnotexist.json').expectBadge({
|
||||
t.create('package not found').get('/v/does-not-exist.json').expectBadge({
|
||||
label: 'pub',
|
||||
message: 'not found',
|
||||
})
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { expect } from 'chai'
|
||||
import Camp from '@shields_io/camp'
|
||||
import portfinder from 'portfinder'
|
||||
import config from 'config'
|
||||
import got from '../core/got-test-client.js'
|
||||
import { ExpressTestHarness } from '../core/express-test-harness.js'
|
||||
import { setRoutes } from './suggest.js'
|
||||
import GithubApiProvider from './github/github-api-provider.js'
|
||||
|
||||
describe('Badge suggestions for', function () {
|
||||
describe('Badge suggestions', function () {
|
||||
const githubApiBaseUrl = process.env.GITHUB_URL || 'https://api.github.com'
|
||||
|
||||
let token, apiProvider
|
||||
@@ -22,38 +20,27 @@ describe('Badge suggestions for', function () {
|
||||
})
|
||||
})
|
||||
|
||||
let port, baseUrl
|
||||
before(async function () {
|
||||
port = await portfinder.getPortPromise()
|
||||
baseUrl = `http://127.0.0.1:${port}`
|
||||
})
|
||||
|
||||
let camp
|
||||
before(async function () {
|
||||
camp = Camp.start({ port, hostname: '::' })
|
||||
await new Promise(resolve => camp.on('listening', () => resolve()))
|
||||
})
|
||||
after(async function () {
|
||||
if (camp) {
|
||||
await new Promise(resolve => camp.close(resolve))
|
||||
camp = undefined
|
||||
}
|
||||
})
|
||||
|
||||
const origin = 'https://example.test'
|
||||
before(function () {
|
||||
setRoutes([origin], apiProvider, camp)
|
||||
|
||||
let harness
|
||||
before(async function () {
|
||||
harness = new ExpressTestHarness()
|
||||
setRoutes([origin], apiProvider, harness.app)
|
||||
await harness.start()
|
||||
})
|
||||
|
||||
after(async function () {
|
||||
await harness.stop()
|
||||
})
|
||||
|
||||
describe('GitHub', function () {
|
||||
context('with an existing project', function () {
|
||||
it('returns the expected suggestions', async function () {
|
||||
const { statusCode, body } = await got(
|
||||
`${baseUrl}/$suggest/v1?url=${encodeURIComponent(
|
||||
const { statusCode, body } = await harness.get(
|
||||
`/$suggest/v1?url=${encodeURIComponent(
|
||||
'https://github.com/atom/atom'
|
||||
)}`,
|
||||
{
|
||||
responseType: 'json',
|
||||
}
|
||||
{ responseType: 'json' }
|
||||
)
|
||||
expect(statusCode).to.equal(200)
|
||||
expect(body).to.deep.equal({
|
||||
@@ -117,13 +104,11 @@ describe('Badge suggestions for', function () {
|
||||
it('returns the expected suggestions', async function () {
|
||||
this.timeout(5000)
|
||||
|
||||
const { statusCode, body } = await got(
|
||||
`${baseUrl}/$suggest/v1?url=${encodeURIComponent(
|
||||
const { statusCode, body } = await harness.get(
|
||||
`/$suggest/v1?url=${encodeURIComponent(
|
||||
'https://github.com/badges/not-a-real-project'
|
||||
)}`,
|
||||
{
|
||||
responseType: 'json',
|
||||
}
|
||||
{ responseType: 'json' }
|
||||
)
|
||||
expect(statusCode).to.equal(200)
|
||||
expect(body).to.deep.equal({
|
||||
@@ -187,13 +172,11 @@ describe('Badge suggestions for', function () {
|
||||
describe('GitLab', function () {
|
||||
context('with an existing project', function () {
|
||||
it('returns the expected suggestions', async function () {
|
||||
const { statusCode, body } = await got(
|
||||
`${baseUrl}/$suggest/v1?url=${encodeURIComponent(
|
||||
const { statusCode, body } = await harness.get(
|
||||
`/$suggest/v1?url=${encodeURIComponent(
|
||||
'https://gitlab.com/gitlab-org/gitlab'
|
||||
)}`,
|
||||
{
|
||||
responseType: 'json',
|
||||
}
|
||||
{ responseType: 'json' }
|
||||
)
|
||||
expect(statusCode).to.equal(200)
|
||||
expect(body).to.deep.equal({
|
||||
@@ -228,8 +211,8 @@ describe('Badge suggestions for', function () {
|
||||
|
||||
context('with an nonexisting project', function () {
|
||||
it('returns the expected suggestions', async function () {
|
||||
const { statusCode, body } = await got(
|
||||
`${baseUrl}/$suggest/v1?url=${encodeURIComponent(
|
||||
const { statusCode, body } = await harness.get(
|
||||
`/$suggest/v1?url=${encodeURIComponent(
|
||||
'https://gitlab.com/gitlab-org/not-gitlab'
|
||||
)}`,
|
||||
{
|
||||
|
||||
@@ -146,8 +146,8 @@ async function findSuggestions(githubApiProvider, url) {
|
||||
// - link: target as a string URL
|
||||
// - preview: object (optional)
|
||||
// - style: string
|
||||
function setRoutes(allowedOrigin, githubApiProvider, server) {
|
||||
server.ajax.on('suggest/v1', (data, end, ask) => {
|
||||
function setRoutes(allowedOrigin, githubApiProvider, app) {
|
||||
app.get('/[$]suggest/v1', (req, res) => {
|
||||
// The typical dev and production setups are cross-origin. However, in
|
||||
// Heroku deploys and some self-hosted deploys these requests may come from
|
||||
// the same host. Chrome does not send an Origin header on same-origin
|
||||
@@ -155,23 +155,25 @@ function setRoutes(allowedOrigin, githubApiProvider, server) {
|
||||
//
|
||||
// It would be better to solve this problem using some well-tested
|
||||
// middleware.
|
||||
const origin = ask.req.headers.origin
|
||||
const origin = req.headers.origin
|
||||
if (origin) {
|
||||
let host
|
||||
try {
|
||||
host = new URL(origin).hostname
|
||||
} catch (e) {
|
||||
ask.res.setHeader('Access-Control-Allow-Origin', 'null')
|
||||
end({ err: 'Disallowed' })
|
||||
res.setHeader('Access-Control-Allow-Origin', 'null')
|
||||
res.json({ err: 'Disallowed' })
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
if (host !== ask.req.headers.host) {
|
||||
if (host !== req.headers.host) {
|
||||
if (allowedOrigin.includes(origin)) {
|
||||
ask.res.setHeader('Access-Control-Allow-Origin', origin)
|
||||
res.setHeader('Access-Control-Allow-Origin', origin)
|
||||
} else {
|
||||
ask.res.setHeader('Access-Control-Allow-Origin', 'null')
|
||||
end({ err: 'Disallowed' })
|
||||
res.setHeader('Access-Control-Allow-Origin', 'null')
|
||||
res.json({ err: 'Disallowed' })
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -179,9 +181,10 @@ function setRoutes(allowedOrigin, githubApiProvider, server) {
|
||||
|
||||
let url
|
||||
try {
|
||||
url = new URL(data.url)
|
||||
url = new URL(req.query.url)
|
||||
} catch (e) {
|
||||
end({ err: `${e}` })
|
||||
res.json({ err: `${e}` })
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -189,11 +192,13 @@ function setRoutes(allowedOrigin, githubApiProvider, server) {
|
||||
// This interacts with callback code and can't use async/await.
|
||||
// eslint-disable-next-line promise/prefer-await-to-then
|
||||
.then(suggestions => {
|
||||
end({ suggestions })
|
||||
res.json({ suggestions })
|
||||
res.end()
|
||||
})
|
||||
// eslint-disable-next-line promise/prefer-await-to-then
|
||||
.catch(err => {
|
||||
end({ suggestions: [], err })
|
||||
res.json({ suggestions: [], err })
|
||||
res.end()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import Camp from '@shields_io/camp'
|
||||
import { expect } from 'chai'
|
||||
import nock from 'nock'
|
||||
import portfinder from 'portfinder'
|
||||
import got from '../core/got-test-client.js'
|
||||
import { ExpressTestHarness } from '../core/express-test-harness.js'
|
||||
import { setRoutes, githubLicense } from './suggest.js'
|
||||
import GithubApiProvider from './github/github-api-provider.js'
|
||||
|
||||
@@ -67,28 +65,20 @@ describe('Badge suggestions', function () {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Scoutcamp integration', function () {
|
||||
let port, baseUrl
|
||||
before(async function () {
|
||||
port = await portfinder.getPortPromise()
|
||||
baseUrl = `http://127.0.0.1:${port}`
|
||||
})
|
||||
|
||||
let camp
|
||||
before(async function () {
|
||||
camp = Camp.start({ port, hostname: '::' })
|
||||
await new Promise(resolve => camp.on('listening', () => resolve()))
|
||||
})
|
||||
after(async function () {
|
||||
if (camp) {
|
||||
await new Promise(resolve => camp.close(resolve))
|
||||
camp = undefined
|
||||
}
|
||||
describe('Express integration', function () {
|
||||
let harness
|
||||
beforeEach(async function () {
|
||||
harness = new ExpressTestHarness()
|
||||
await harness.start()
|
||||
})
|
||||
|
||||
const origin = 'https://example.test'
|
||||
before(function () {
|
||||
setRoutes([origin], apiProvider, camp)
|
||||
beforeEach(function () {
|
||||
setRoutes([origin], apiProvider, harness.app)
|
||||
})
|
||||
|
||||
afterEach(async function () {
|
||||
await harness.stop()
|
||||
})
|
||||
|
||||
context('without an origin header', function () {
|
||||
@@ -106,13 +96,11 @@ describe('Badge suggestions', function () {
|
||||
},
|
||||
})
|
||||
|
||||
const { statusCode, body } = await got(
|
||||
`${baseUrl}/$suggest/v1?url=${encodeURIComponent(
|
||||
const { statusCode, body } = await harness.get(
|
||||
`/$suggest/v1?url=${encodeURIComponent(
|
||||
'https://github.com/atom/atom'
|
||||
)}`,
|
||||
{
|
||||
responseType: 'json',
|
||||
}
|
||||
{ responseType: 'json' }
|
||||
)
|
||||
expect(statusCode).to.equal(200)
|
||||
expect(body).to.deep.equal({
|
||||
|
||||
Reference in New Issue
Block a user