Compare commits

..

2 Commits

Author SHA1 Message Date
Paul Melnikow
332a496e84 Match unit test to value used by API 2020-09-22 15:57:24 -04:00
Paul Melnikow
5f28ac34cc [PyPI] When Python version classifiers are absent, fall back to requires_python 2020-09-22 15:54:16 -04:00
667 changed files with 27476 additions and 59235 deletions

View File

@@ -86,6 +86,33 @@ services_steps: &services_steps
- store_test_results:
path: junit
run_package_tests: &run_package_tests
when: always
command: |
# https://discuss.circleci.com/t/switch-nodejs-version-on-machine-executor-solved/26675/3
set +e
export NVM_DIR="/opt/circleci/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
nvm install $NODE_VERSION
nvm use $NODE_VERSION
node --version
# install the shields.io dependencies
npm ci
# run the package tests
npm run test:package
npm run check-types:package
# delete the sheilds.io dependencies
rm -rf node_modules/
# run a smoke test (render a badge with the CLI)
# with only the package dependencies installed
cd badge-maker
npm link
badge cactus grown :green @flat
package_steps: &package_steps
steps:
- checkout
@@ -105,31 +132,31 @@ package_steps: &package_steps
# https://nodejs.org/en/about/releases/
- run:
<<: *run_package_tests
environment:
mocha_reporter: mocha-junit-reporter
MOCHA_FILE: junit/badge-maker/v10/results.xml
NODE_VERSION: v10
CYPRESS_INSTALL_BINARY: 0
name: Run package tests on Node 10
command: scripts/run_package_tests.sh
- run:
<<: *run_package_tests
environment:
mocha_reporter: mocha-junit-reporter
MOCHA_FILE: junit/badge-maker/v12/results.xml
NODE_VERSION: v12
CYPRESS_INSTALL_BINARY: 0
name: Run package tests on Node 12
command: scripts/run_package_tests.sh
- run:
<<: *run_package_tests
environment:
mocha_reporter: mocha-junit-reporter
MOCHA_FILE: junit/badge-maker/v14/results.xml
NODE_VERSION: v14
CYPRESS_INSTALL_BINARY: 0
name: Run package tests on Node 14
command: scripts/run_package_tests.sh
- store_test_results:
path: junit

View File

@@ -14,6 +14,9 @@ update_configs:
- match:
dependency_name: 'eslint*'
update_type: 'semver:minor'
- match:
dependency_name: 'enzyme*'
update_type: 'semver:minor'
- match:
dependency_name: 'mocha*'
update_type: 'semver:minor'
@@ -31,8 +34,3 @@ update_configs:
- package_manager: 'javascript'
directory: '/badge-maker'
update_schedule: 'weekly'
# approve-bot package dependencies
- package_manager: 'javascript'
directory: '.github/actions/approve-bot'
update_schedule: 'weekly'

View File

@@ -4,4 +4,3 @@
/__snapshots__
/public
badge-maker/node_modules/
!.github/

View File

@@ -1,14 +1,13 @@
extends:
- standard
- standard-jsx
- standard-react
- plugin:@typescript-eslint/recommended
- prettier
- prettier/@typescript-eslint
- prettier/standard
- prettier/react
- eslint:recommended
globals:
JSX: 'readonly'
parserOptions:
# Override eslint-config-standard, which incorrectly sets this to "module",
# though that setting is only for ES6 modules, not CommonJS modules.
@@ -43,7 +42,6 @@ overrides:
es6: true
rules:
no-console: 'off'
'@typescript-eslint/explicit-module-boundary-types': 'off'
- files:
- '**/*.@(ts|tsx)'
@@ -59,7 +57,6 @@ overrides:
'@typescript-eslint/no-object-literal-type-assertion': 'off'
'@typescript-eslint/no-explicit-any': 'error'
'@typescript-eslint/ban-ts-ignore': 'off'
'@typescript-eslint/explicit-module-boundary-types': 'off'
- files:
- core/**/*.ts
@@ -127,9 +124,6 @@ rules:
'@typescript-eslint/no-var-requires': 'off'
'@typescript-eslint/no-use-before-define': 'error'
no-use-before-define: 'off'
# These should be disabled by eslint-config-prettier, but are not.
no-extra-semi: 'off'
@@ -146,20 +140,6 @@ rules:
new-cap: ['error', { 'capIsNew': true }]
import/order: ['error', { 'newlines-between': 'never' }]
# Account for destructuring responses from upstream services,
# many of which do not follow camelcase
# Based on original rule configuration from eslint-config-standard
camelcase:
[
'error',
{
ignoreDestructuring: true,
properties: 'never',
ignoreGlobals: true,
allow: ['^UNSAFE_'],
},
]
# Chai friendly.
no-unused-expressions: 'off'
chai-friendly/no-unused-expressions: 'error'

View File

@@ -1,7 +1,4 @@
contact_links:
- name: 🎨 Simple Icons
url: https://github.com/badges/shields/discussions/5369
about: Please read this before posting a question about SimpleIcons
- name: ❓ Support Question
url: https://github.com/badges/shields/discussions
about: Ask a question about Shields.io

View File

@@ -1,12 +0,0 @@
name: 'Auto Approve'
description: 'Automatically approve/close selected pull requests for shields.io'
branding:
icon: 'check-circle'
color: 'green'
inputs:
github-token:
description: 'The GITHUB_TOKEN secret'
required: true
runs:
using: 'node12'
main: 'index.js'

View File

@@ -1,65 +0,0 @@
'use strict'
function findChangelogStart(lines) {
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (
line === '<summary>Changelog</summary>' &&
lines[i + 2] === '<blockquote>'
) {
return i + 3
}
}
return null
}
function findChangelogEnd(lines, start) {
for (let i = start; i < lines.length; i++) {
const line = lines[i]
if (line === '</blockquote>') {
return i
}
}
return null
}
function allChangelogLinesAreVersionBump(changelogLines) {
return (
changelogLines.length > 0 &&
changelogLines.length ===
changelogLines.filter(line =>
line.includes('Version bump only for package')
).length
)
}
function isPointlessGatsbyBump(body) {
const lines = body.split(/\r?\n/)
if (
!lines[0].includes('https://github.com/gatsbyjs/gatsby') // lgtm [js/incomplete-url-substring-sanitization]
) {
return false
}
const start = findChangelogStart(lines)
const end = findChangelogEnd(lines, start)
if (!start || !end) {
return false
}
const changelogLines = lines
.slice(start, end)
.filter(line => !line.startsWith('<h'))
.filter(line => !line.startsWith('<p>All notable changes'))
.filter(
line => !line.startsWith('See <a href="https://conventionalcommits.org">')
)
.filter(line => !line.startsWith('<!--'))
return allChangelogLinesAreVersionBump(changelogLines)
}
function shouldAutoMerge(body) {
return body.includes(
'If all status checks pass Dependabot will automatically merge this pull request'
)
}
module.exports = { isPointlessGatsbyBump, shouldAutoMerge }

View File

@@ -1,56 +0,0 @@
'use strict'
const core = require('@actions/core')
const github = require('@actions/github')
const { isPointlessGatsbyBump, shouldAutoMerge } = require('./helpers')
async function run() {
try {
const token = core.getInput('github-token', { required: true })
const { pull_request: pr } = github.context.payload
if (!pr) {
throw new Error('Event payload missing `pull_request`')
}
const client = github.getOctokit(token)
if (
['dependabot[bot]', 'dependabot-preview[bot]'].includes(pr.user.login)
) {
if (isPointlessGatsbyBump(pr.body)) {
core.debug(`Closing pull request #${pr.number}`)
await client.pulls.update({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
pull_number: pr.number,
state: 'closed',
})
core.debug(`Done.`)
} else if (shouldAutoMerge(pr.body)) {
core.debug(`Adding label to pull request #${pr.number}`)
await client.issues.addLabels({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
issue_number: pr.number,
labels: ['squash when passing'],
})
core.debug(`Creating approving review for pull request #${pr.number}`)
await client.pulls.createReview({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
pull_number: pr.number,
event: 'APPROVE',
})
core.debug(`Done.`)
}
}
} catch (error) {
core.setFailed(error.message)
}
}
run()

View File

@@ -1,177 +0,0 @@
{
"name": "approve-bot",
"version": "0.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@actions/core": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.2.7.tgz",
"integrity": "sha512-kzLFD5BgEvq6ubcxdgPbRKGD2Qrgya/5j+wh4LZzqT915I0V3rED+MvjH6NXghbvk1MXknpNNQ3uKjXSEN00Ig=="
},
"@actions/github": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@actions/github/-/github-4.0.0.tgz",
"integrity": "sha512-Ej/Y2E+VV6sR9X7pWL5F3VgEWrABaT292DRqRU6R4hnQjPtC/zD3nagxVdXWiRQvYDh8kHXo7IDmG42eJ/dOMA==",
"requires": {
"@actions/http-client": "^1.0.8",
"@octokit/core": "^3.0.0",
"@octokit/plugin-paginate-rest": "^2.2.3",
"@octokit/plugin-rest-endpoint-methods": "^4.0.0"
}
},
"@actions/http-client": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-1.0.9.tgz",
"integrity": "sha512-0O4SsJ7q+MK0ycvXPl2e6bMXV7dxAXOGjrXS1eTF9s2S401Tp6c/P3c3Joz04QefC1J6Gt942Wl2jbm3f4mLcg==",
"requires": {
"tunnel": "0.0.6"
}
},
"@octokit/auth-token": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.4.5.tgz",
"integrity": "sha512-BpGYsPgJt05M7/L/5FoE1PiAbdxXFZkX/3kDYcsvd1v6UhlnE5e96dTDr0ezX/EFwciQxf3cNV0loipsURU+WA==",
"requires": {
"@octokit/types": "^6.0.3"
}
},
"@octokit/core": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-3.2.5.tgz",
"integrity": "sha512-+DCtPykGnvXKWWQI0E1XD+CCeWSBhB6kwItXqfFmNBlIlhczuDPbg+P6BtLnVBaRJDAjv+1mrUJuRsFSjktopg==",
"requires": {
"@octokit/auth-token": "^2.4.4",
"@octokit/graphql": "^4.5.8",
"@octokit/request": "^5.4.12",
"@octokit/types": "^6.0.3",
"before-after-hook": "^2.1.0",
"universal-user-agent": "^6.0.0"
}
},
"@octokit/endpoint": {
"version": "6.0.11",
"resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-6.0.11.tgz",
"integrity": "sha512-fUIPpx+pZyoLW4GCs3yMnlj2LfoXTWDUVPTC4V3MUEKZm48W+XYpeWSZCv+vYF1ZABUm2CqnDVf1sFtIYrj7KQ==",
"requires": {
"@octokit/types": "^6.0.3",
"is-plain-object": "^5.0.0",
"universal-user-agent": "^6.0.0"
}
},
"@octokit/graphql": {
"version": "4.5.9",
"resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-4.5.9.tgz",
"integrity": "sha512-c+0yofIugUNqo+ktrLaBlWSbjSq/UF8ChAyxQzbD3X74k1vAuyLKdDJmPwVExUFSp6+U1FzWe+3OkeRsIqV0vg==",
"requires": {
"@octokit/request": "^5.3.0",
"@octokit/types": "^6.0.3",
"universal-user-agent": "^6.0.0"
}
},
"@octokit/openapi-types": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-3.3.0.tgz",
"integrity": "sha512-s3dd32gagPmKaSLNJ9aPNok7U+tl69YLESf6DgQz5Ml/iipPZtif3GLvWpNXoA6qspFm1LFUZX+C3SqWX/Y/TQ=="
},
"@octokit/plugin-paginate-rest": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.9.0.tgz",
"integrity": "sha512-XxbOg45r2n/2QpU6hnGDxQNDRrJ7gjYpMXeDbUCigWTHECmjoyFLizkFO2jMEtidMkfiELn7AF8GBAJ/cbPTnA==",
"requires": {
"@octokit/types": "^6.6.0"
}
},
"@octokit/plugin-rest-endpoint-methods": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-4.9.0.tgz",
"integrity": "sha512-EAr2epvY8JfXSi/cdMsyyfBctdKkolDH7xSgu3MKBqPRm0WfQ2QvI050jz61XZXoVK3ZgrhdMCyd1GgOFz7CSw==",
"requires": {
"@octokit/types": "^6.6.0",
"deprecation": "^2.3.1"
}
},
"@octokit/request": {
"version": "5.4.13",
"resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.4.13.tgz",
"integrity": "sha512-WcNRH5XPPtg7i1g9Da5U9dvZ6YbTffw9BN2rVezYiE7couoSyaRsw0e+Tl8uk1fArHE7Dn14U7YqUDy59WaqEw==",
"requires": {
"@octokit/endpoint": "^6.0.1",
"@octokit/request-error": "^2.0.0",
"@octokit/types": "^6.0.3",
"deprecation": "^2.0.0",
"is-plain-object": "^5.0.0",
"node-fetch": "^2.6.1",
"once": "^1.4.0",
"universal-user-agent": "^6.0.0"
}
},
"@octokit/request-error": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-2.0.5.tgz",
"integrity": "sha512-T/2wcCFyM7SkXzNoyVNWjyVlUwBvW3igM3Btr/eKYiPmucXTtkxt2RBsf6gn3LTzaLSLTQtNmvg+dGsOxQrjZg==",
"requires": {
"@octokit/types": "^6.0.3",
"deprecation": "^2.0.0",
"once": "^1.4.0"
}
},
"@octokit/types": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.6.0.tgz",
"integrity": "sha512-nmFoU3HCbw1AmnZU/eto2VvzV06+N7oAqXwMmAHGlNDF+KFykksh/VlAl85xc1P5T7Mw8fKYvXNaImNHCCH/rg==",
"requires": {
"@octokit/openapi-types": "^3.3.0",
"@types/node": ">= 8"
}
},
"@types/node": {
"version": "14.14.22",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.22.tgz",
"integrity": "sha512-g+f/qj/cNcqKkc3tFqlXOYjrmZA+jNBiDzbP3kH+B+otKFqAdPgVTGP1IeKRdMml/aE69as5S4FqtxAbl+LaMw=="
},
"before-after-hook": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.1.0.tgz",
"integrity": "sha512-IWIbu7pMqyw3EAJHzzHbWa85b6oud/yfKYg5rqB5hNE8CeMi3nX+2C2sj0HswfblST86hpVEOAb9x34NZd6P7A=="
},
"deprecation": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz",
"integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ=="
},
"is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="
},
"node-fetch": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
"integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw=="
},
"once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
"requires": {
"wrappy": "1"
}
},
"tunnel": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz",
"integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="
},
"universal-user-agent": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.0.tgz",
"integrity": "sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w=="
},
"wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
}
}
}

View File

@@ -1,16 +0,0 @@
{
"name": "approve-bot",
"version": "0.0.0",
"description": "",
"main": "index.js",
"private": true,
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "chris48s",
"license": "CC0",
"dependencies": {
"@actions/core": "^1.2.7",
"@actions/github": "^4.0.0"
}
}

View File

@@ -1,8 +0,0 @@
FROM node:12-buster
RUN apt-get update
RUN apt-get install -y jq
RUN apt-get install -y uuid-runtime
COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -1,5 +0,0 @@
name: 'draft-release'
description: 'Generate a changelog and propose a release PR'
runs:
using: 'docker'
image: 'Dockerfile'

View File

@@ -1,62 +0,0 @@
#!/bin/bash
set -euxo pipefail
# Set up a git user
git config user.name "release[bot]"
git config user.email "actions@users.noreply.github.com"
# Find last server-YYYY-MM-DD tag
git fetch --unshallow --tags
LAST_TAG=$(git tag | grep server | tail -n 1)
# Find the marker in CHANGELOG.md
INSERT_POINT=$(grep -n "^\-\-\-$" CHANGELOG.md | cut -f1 -d:)
INSERT_POINT=$((INSERT_POINT+1))
# Generate a release name
RELEASE_NAME="server-$(date --rfc-3339=date)"
# Assemble changelog entry
rm -f temp-changes.txt
touch temp-changes.txt
{
echo "## $RELEASE_NAME"
echo ""
git log "$LAST_TAG"..HEAD --no-merges --oneline --pretty="format:- %s" --perl-regexp --author='^((?!dependabot).*)$'
echo $'\n- Dependency updates\n'
} >> temp-changes.txt
BASE_URL="https:\/\/github.com\/badges\/shields\/issues\/"
sed -r -i "s/\((\#)([0-9]+)\)$/\[\1\2\]\($BASE_URL\2\)/g" temp-changes.txt
# Write the changelog
sed -i "${INSERT_POINT} r temp-changes.txt" CHANGELOG.md
# Cleanup
rm temp-changes.txt
# Run prettier (to ensure the markdown file doesn't fail CI)
npx prettier@$(cat package.json | jq -r .devDependencies.prettier) --write "CHANGELOG.md"
# Generate a unique branch name
BRANCH_NAME="$RELEASE_NAME"-$(uuidgen | head -c 8)
git checkout -b "$BRANCH_NAME"
# Commit + push changelog
git add CHANGELOG.md
git commit -m "Update Changelog"
git push origin "$BRANCH_NAME"
# Submit a PR
TITLE="Changelog for Release $RELEASE_NAME"
PR_RESP=$(curl https://api.github.com/repos/"$REPO_NAME"/pulls \
-X POST \
-H "Authorization: token $GITHUB_TOKEN" \
--data '{"title": "'"$TITLE"'", "body": "'"$TITLE"'", "head": "'"$BRANCH_NAME"'", "base": "master"}')
# Add the 'release' label to the PR
PR_API_URL=$(echo "$PR_RESP" | jq -r ._links.issue.href)
curl "$PR_API_URL" \
-X POST \
-H "Authorization: token $GITHUB_TOKEN" \
--data '{"labels":["release"]}'

10
.github/probot.js vendored Normal file
View File

@@ -0,0 +1,10 @@
on('pull_request.closed')
.filter(context => context.payload.pull_request.merged)
.filter(
context =>
context.payload.pull_request.head.ref.slice(0, 11) !== 'dependabot/'
)
.filter(context => context.payload.pull_request.base.ref === 'master')
.comment(`This pull request was merged to [{{ pull_request.base.ref }}]({{ repository.html_url }}/tree/{{ pull_request.base.ref }}) branch. This change is now waiting for deployment, which will usually happen within a few days. Stay tuned by joining our \`#ops\` channel on [Discord](https://discordapp.com/invite/HjJCwm5)!
After deployment, changes are copied to [gh-pages]({{ repository.html_url }}/tree/gh-pages) branch: ![](https://img.shields.io/github/commit-status/{{ repository.full_name }}/gh-pages/{{ pull_request.merge_commit_sha }}.svg?label=deploy%20status)`)

View File

@@ -1,7 +0,0 @@
<!--
Be sure to review our Contributing guidelines to help streamline the merging of your PR!
* PR title conventions - https://github.com/badges/shields/blob/master/CONTRIBUTING.md#running-service-tests-in-pull-requests
* Code formatting - https://github.com/badges/shields/blob/master/CONTRIBUTING.md#prettier
* Merge processes and reminders - https://github.com/badges/shields/blob/master/CONTRIBUTING.md#pull-requests
-->

View File

@@ -5,12 +5,6 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Install action dependencies
run: cd .github/actions/approve-bot && npm ci
- uses: ./.github/actions/approve-bot
- uses: chris48s/approve-bot@2.0.1
with:
github-token: '${{ secrets.GITHUB_TOKEN }}'

View File

@@ -1,26 +0,0 @@
name: Deploy Documentation
on:
push:
branches:
- master
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2.3.1
with:
persist-credentials: false
- name: Build
run: |
npm ci
npm run build-docs
- name: Deploy
uses: JamesIves/github-pages-deploy-action@3.7.1
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BRANCH: gh-pages
FOLDER: api-docs
CLEAN: true

View File

@@ -1,19 +0,0 @@
name: Draft Release
on:
schedule:
- cron: '0 1 1 * *'
# At 01:00 on the first day of every month
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Draft Release
uses: ./.github/actions/draft-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO_NAME: ${{ github.repository }}

View File

@@ -1,31 +0,0 @@
name: Tag Release
on:
pull_request:
types: [closed]
jobs:
tag-release:
if: |
github.event_name == 'pull_request' &&
github.event.action == 'closed' &&
github.event.pull_request.merged == true &&
contains(github.event.pull_request.labels.*.name, 'release')
runs-on: ubuntu-latest
steps:
- name: Get current date
id: date
run: echo "::set-output name=date::$(date --rfc-3339=date)"
- name: Checkout branch "master"
uses: actions/checkout@v2
with:
ref: 'master'
- name: Tag Release
uses: tvdias/github-tagger@v0.0.2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
tag: server-${{ steps.date.outputs.date }}

View File

@@ -10,7 +10,6 @@
"**/*-test-helpers.js",
"**/*-fixtures.js",
"**/mocha-*.js",
"**/*.test-d.ts",
"dangerfile.js",
"gatsby-*.js",
"core/service-test-runner",

View File

@@ -1,3 +1,7 @@
{
"recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
"recommendations": [
"esbenp.prettier-vscode",
"EditorConfig.EditorConfig",
"dbaeumer.vscode-eslint"
]
}

View File

@@ -1,51 +0,0 @@
# Changelog
Note: this changelog is for the shields.io server. The changelog for the badge-maker NPM package is at https://github.com/badges/shields/blob/master/badge-maker/CHANGELOG.md
---
## server-2021-05-01
- Add setting which allows setting a timeout on HTTP requests
This is configured with the new `REQUEST_TIMEOUT_SECONDS` setting. If a request takes longer
than this number of seconds a `408 Request Timeout` response will be returned.
- Deprecate [Bintray] service [#6423](https://github.com/badges/shields/issues/6423)
- Support git hash in [nexus] SNAPSHOT version [#6369](https://github.com/badges/shields/issues/6369)
- Replace 4183C4 with blue [#6366](https://github.com/badges/shields/issues/6366)
- [Youtube] Added channel view count and subscriber count badges [#6333](https://github.com/badges/shields/issues/6333)
- Fix Netlify badge by adding new color schema [#6340](https://github.com/badges/shields/issues/6340)
- [REUSE] Add service badges [#6330](https://github.com/badges/shields/issues/6330)
- Dependency updates
## server-2021-04-01
- Use NPM packages to provide fonts instead of Google Fonts [#6274](https://github.com/badges/shields/issues/6274)
- Prevent duplication of parameters in badge examples [#6272](https://github.com/badges/shields/issues/6272)
- Add docs for all types of releases [#6210](https://github.com/badges/shields/issues/6210)
- refresh self-hosting docs [#6273](https://github.com/badges/shields/issues/6273)
- allow missing 'goal' key in [liberapay] badges [#6258](https://github.com/badges/shields/issues/6258)
- use got to push influx metrics [#6257](https://github.com/badges/shields/issues/6257)
- Dependency updates
## server-2021-03-01
- ensure redirect target path is correctly encoded [#6229](https://github.com/badges/shields/issues/6229)
- [SecurityHeaders] Added a possibility for no follow redirects [#6212](https://github.com/badges/shields/issues/6212)
- catch URL parse error in [endpoint] badge [#6214](https://github.com/badges/shields/issues/6214)
- [Homebrew] Add homebrew downloads badge [#6209](https://github.com/badges/shields/issues/6209)
- update [pkgreview] url [#6189](https://github.com/badges/shields/issues/6189)
- Make [Twitch] a social badge [#6183](https://github.com/badges/shields/issues/6183)
- update [flathub] error handling [#6185](https://github.com/badges/shields/issues/6185)
- Add [Testspace] badges [#6162](https://github.com/badges/shields/issues/6162)
- Dependency updates
## server-2021-02-01
- Docs.rs badge (#6098)
- Fix feedz service in case the package page gets paginated (#6101)
- [Bitbucket] Server Adding Auth Tokens and Resolving Pull Request api … (#6076)
- Dependency updates
## server-2021-01-18
- Gotta start somewhere

View File

@@ -101,7 +101,7 @@ There are three places to get help:
used by developers or which are widely used by developers.
- The left-hand side of a badge should not advertise. It should be a lowercase _noun_
succinctly describing the meaning of the right-hand side.
- Except for badges using the `social` style, logos and links should be _turned off by
- Except for badges using the `social` style, logos should be _turned off by
default_.
- Badges should not obtain data from undocumented or reverse-engineered API endpoints.
- Badges should not obtain data by scraping web pages - these are likely to break frequently.
@@ -131,7 +131,13 @@ Prettier before a commit by default.
### Tests
When adding or changing a service [please write tests][service-tests], and ensure the [title of your Pull Requests follows the required conventions](#running-service-tests-in-pull-requests) to ensure your tests are executed.
When adding or changing a service [please write tests][service-tests].
When opening a pull request, include your service name in brackets in the pull
request title. That way, those service tests will run in CI.
e.g. **[Travis] Fix timeout issues**
When changing other code, please add unit tests.
To run the integration tests, you must have redis installed and in your PATH.
@@ -147,35 +153,3 @@ There is a [High-level code walkthrough](doc/code-walkthrough.md) describing the
### Logos
We have [documentation for logo usage](doc/logos.md) which includes [contribution guidance](doc/logos.md#contributing-logos)
## Pull Requests
All code changes are incorporated via pull requests, and pull requests are always squashed into a single commit on merging. Therefore there's no requirement to squash commits within your PR, but feel free to squash or restructure the commits on your PR branch if you think it will be helpful. PRs with well structured commits are always easier to review!
Because all changes are pulled into the main branch via squash merges from PRs, we do **not** support overwriting any aspects of the git history once it hits our main branch. Notably this means we do not support amending commit messages, nor adjusting commit author information once merged.
Accordingly, it is the responsibility of contributors to review this type of information and adjust as needed before marking PRs as ready for review and merging.
You can review and modify your local [git configuration][git-config] via `git config`, and also find more information about amending your commit messages [here][amending-commits].
[git-config]: https://git-scm.com/book/en/v2/Customizing-Git-Git-Configuration
[amending-commits]: https://docs.github.com/en/github/committing-changes-to-your-project/changing-a-commit-message#rewriting-the-most-recent-commit-message
### Running service tests in pull requests
The affected service names must be included in square brackets in the pull request title so that the CI engine will run those service tests. When a pull request affects multiple services, they should be separated with spaces. The test runner is case-insensitive, so they should be capitalized for readability.
For example:
- **[Travis] Fix timeout issues**
- **[Travis Sonar] Support user token authentication**
- **Add tests for [CRAN] and [CPAN]**
Note that many services are part of a "family" of related services. Depending on the changes in your PR you may need to run the tests for just a single service, or for _all_ the services within a family.
For example, a PR title of **[GitHubForks] Foo** will only run the service tests specifically for the GitHub Forks badge, whereas a title of **[GitHub] Foo** will run the service tests for all of the GitHub badges.
In the rare case when it's necessary to see the output of a full service-test
run in a PR (all 2,000+ tests), include `[*****]` in the title. Unless all the tests pass, the build
will fail, so likely it will be necessary to remove it and re-run the tests
before merging.

70
Makefile Normal file
View File

@@ -0,0 +1,70 @@
SHELL:=/bin/bash
SERVER_TMP=${TMPDIR}shields-server-deploy
FRONTEND_TMP=${TMPDIR}shields-frontend-deploy
# This branch is reserved for the deploy process and should not be used for
# development. The deploy script will clobber it. To avoid accidentally
# pushing secrets to GitHub, this branch is configured to reject pushes.
WORKING_BRANCH=server-deploy-working-branch
all: test
deploy: deploy-s0 deploy-s1 deploy-s2 clean-server-deploy deploy-gh-pages deploy-gh-pages-clean
deploy-s0: prepare-server-deploy push-s0
deploy-s1: prepare-server-deploy push-s1
deploy-s2: prepare-server-deploy push-s2
prepare-server-deploy:
# Ship a copy of the front end to each server for debugging.
# https://github.com/badges/shields/issues/1220
INCLUDE_DEV_PAGES=false \
npm run build
rm -rf ${SERVER_TMP}
git worktree prune
git worktree add -B ${WORKING_BRANCH} ${SERVER_TMP}
cp -r public ${SERVER_TMP}
git -C ${SERVER_TMP} add -f public/
git -C ${SERVER_TMP} commit --no-verify -m '[DEPLOY] Add frontend for debugging'
cp config/local-shields-io-production.yml ${SERVER_TMP}/config/
git -C ${SERVER_TMP} add -f config/local-shields-io-production.yml
git -C ${SERVER_TMP} commit --no-verify -m '[DEPLOY] MUST NOT BE ON GITHUB'
clean-server-deploy:
rm -rf ${SERVER_TMP}
git worktree prune
push-s0:
git push -f s0 ${WORKING_BRANCH}:master
push-s1:
git push -f s1 ${WORKING_BRANCH}:master
push-s2:
git push -f s2 ${WORKING_BRANCH}:master
deploy-gh-pages:
rm -rf ${FRONTEND_TMP}
git worktree prune
GATSBY_BASE_URL=https://img.shields.io \
INCLUDE_DEV_PAGES=false \
npm run build
git worktree add -B gh-pages ${FRONTEND_TMP}
git -C ${FRONTEND_TMP} ls-files | xargs git -C ${FRONTEND_TMP} rm
git -C ${FRONTEND_TMP} commit --no-verify -m '[DEPLOY] Completely clean the index'
cp -r public/* ${FRONTEND_TMP}
echo shields.io > ${FRONTEND_TMP}/CNAME
touch ${FRONTEND_TMP}/.nojekyll
git -C ${FRONTEND_TMP} add .
git -C ${FRONTEND_TMP} commit --no-verify -m '[DEPLOY] Add built site'
git push -f origin gh-pages
deploy-gh-pages-clean:
rm -rf ${FRONTEND_TMP}
git worktree prune
test:
npm test
.PHONY: all deploy prepare-server-deploy clean-server-deploy deploy-s0 deploy-s1 deploy-s2 push-s0 push-s1 push-s2 deploy-gh-pages deploy-gh-pages-clean deploy-heroku setup redis test

View File

@@ -1,5 +1,5 @@
<p align="center">
<img src="https://raw.githubusercontent.com/badges/shields/master/readme-logo.svg?sanitize=true"
<img src="https://raw.githubusercontent.com/badges/shields/master/frontend/images/logo.svg?sanitize=true"
height="130">
</p>
<p align="center">
@@ -22,6 +22,9 @@
<a href="https://lgtm.com/projects/g/badges/shields/alerts/">
<img src="https://img.shields.io/lgtm/alerts/g/badges/shields"
alt="Total alerts"/></a>
<a href="https://github.com/badges/shields/compare/gh-pages...master">
<img src="https://img.shields.io/github/commits-since/badges/shields/gh-pages?label=commits%20to%20be%20deployed"
alt="commits to be deployed"></a>
<a href="https://discord.gg/HjJCwm5">
<img src="https://img.shields.io/discord/308323056592486420?logo=discord"
alt="chat on Discord"></a>
@@ -35,13 +38,7 @@ and legible badges in SVG and raster format, which can easily be included in
GitHub readmes or any other web page. The service supports dozens of
continuous integration services, package registries, distributions, app
stores, social networks, code coverage services, and code analysis services.
Every month it serves over 770 million images and is used by some of the
world's most popular open-source projects, [VS Code][vscode], [Vue.js][vue]
and [Bootstrap][bootstrap] to name a few.
[vscode]: https://github.com/Microsoft/vscode
[vue]: https://github.com/vuejs/vue
[bootstrap]: https://github.com/twbs/bootstrap
Every month it serves over 470 million images.
This repo hosts:
@@ -89,12 +86,12 @@ and pull requests! You can peruse the [contributing guidelines][contributing].
When adding or changing a service [please add tests][service-tests].
This project has quite a backlog of suggestions! If you're new to the project,
maybe you'd like to open a pull request to address one of them.
You can read a [tutorial on how to add a badge][tutorial].
maybe you'd like to open a pull request to address one of them:
[![GitHub issues by-label](https://img.shields.io/github/issues/badges/shields/good%20first%20issue)](https://github.com/badges/shields/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22)
You can read a [tutorial on how to add a badge][tutorial].
[service-tests]: https://github.com/badges/shields/blob/master/doc/service-tests.md
[tutorial]: doc/TUTORIAL.md
[contributing]: CONTRIBUTING.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,5 @@
# Changelog
## 3.3.0
- Readability improvements: a dark font color is automatically used when the badge's background is too light. For example: ![](https://img.shields.io/badge/hello-world-white)
- Better CSS color compliance: thanks to a switch from _is-css-color_ to _[css-color-converter](https://www.npmjs.com/package/css-color-converter)_, you can use a wider range of color formats from the latest CSS specification, for example `rgb(0 255 0)`
- Less dependencies: _badge-maker_ no longer depends on _camelcase_
## 3.2.0
- Accessibility improvements: Help users of assistive technologies to read the badges when used inline

View File

@@ -14,9 +14,14 @@ function capitalize(s) {
function colorsForBackground(color) {
if (brightness(color) <= brightnessThreshold) {
return { textColor: '#fff', shadowColor: '#010101' }
} else {
return { textColor: '#333', shadowColor: '#ccc' }
return {
textColor: '#fff',
shadowColor: '#010101',
}
}
return {
textColor: '#333',
shadowColor: '#ccc',
}
}
@@ -34,12 +39,19 @@ function escapeXml(s) {
}
function roundUpToOdd(val) {
// Increase chances of pixel grid alignment.
return val % 2 === 0 ? val + 1 : val
}
function preferredWidthOf(str, options) {
// Increase chances of pixel grid alignment.
return roundUpToOdd(anafanafo(str, options) | 0)
function preferredWidthOf(str) {
return roundUpToOdd((anafanafo(str) / 10) | 0)
}
function computeWidths({ label, message }) {
return {
labelWidth: preferredWidthOf(label),
messageWidth: preferredWidthOf(message),
}
}
function createAccessibleText({ label, message }) {
@@ -77,19 +89,22 @@ function renderLogo({
logoWidth = 14,
logoPadding = 0,
}) {
if (logo) {
const logoHeight = 14
const y = (badgeHeight - logoHeight) / 2
const x = horizPadding
if (!logo) {
return {
hasLogo: true,
totalLogoWidth: logoWidth + logoPadding,
renderedLogo: `<image x="${x}" y="${y}" width="${logoWidth}" height="${logoHeight}" xlink:href="${escapeXml(
logo
)}"/>`,
hasLogo: false,
totalLogoWidth: 0,
renderedLogo: '',
}
} else {
return { hasLogo: false, totalLogoWidth: 0, renderedLogo: '' }
}
const logoHeight = 14
const y = (badgeHeight - logoHeight) / 2
const x = horizPadding
return {
hasLogo: true,
totalLogoWidth: logoWidth + logoPadding,
renderedLogo: `<image x="${x}" y="${y}" width="${logoWidth}" height="14" xlink:href="${escapeXml(
logo
)}"/>`,
}
}
@@ -124,7 +139,7 @@ function renderText({
return { renderedText: '', width: 0 }
}
const textLength = preferredWidthOf(content, { font: '11px Verdana' })
const textLength = preferredWidthOf(content)
const escapedContent = escapeXml(content)
const shadowMargin = 150 + verticalMargin
@@ -176,6 +191,10 @@ function renderBadge(
</svg>`
}
function stripXmlWhitespace(xml) {
return xml.replace(/>\s+/g, '>').replace(/<\s+/g, '<').trim()
}
class Badge {
static get fontFamily() {
throw new Error('Not implemented')
@@ -282,10 +301,6 @@ class Badge {
this.renderedMessage = renderedMessage
}
static render(params) {
return new this(params).render()
}
render() {
throw new Error('Not implemented')
}
@@ -436,6 +451,30 @@ class FlatSquare extends Badge {
}
}
function plastic(params) {
const badge = new Plastic(params)
if (params.minify) {
return stripXmlWhitespace(badge.render())
}
return badge.render()
}
function flat(params) {
const badge = new Flat(params)
if (params.minify) {
return stripXmlWhitespace(badge.render())
}
return badge.render()
}
function flatSquare(params) {
const badge = new FlatSquare(params)
if (params.minify) {
return stripXmlWhitespace(badge.render())
}
return badge.render()
}
function social({
label,
message,
@@ -445,6 +484,7 @@ function social({
logoPadding,
color = '#4c1',
labelColor = '#555',
minify,
}) {
// Social label is styled with a leading capital. Convert to caps here so
// width can be measured using the correct characters.
@@ -452,23 +492,24 @@ function social({
const externalHeight = 20
const internalHeight = 19
const labelHorizPadding = 5
const messageHorizPadding = 4
const horizGutter = 6
const horizPadding = 5
const { totalLogoWidth, renderedLogo } = renderLogo({
logo,
badgeHeight: externalHeight,
horizPadding: labelHorizPadding,
horizPadding,
logoWidth,
logoPadding,
})
const hasMessage = message.length
const font = 'bold 11px Helvetica'
const labelTextWidth = preferredWidthOf(label, { font })
const messageTextWidth = preferredWidthOf(message, { font })
const labelRectWidth = labelTextWidth + totalLogoWidth + 2 * labelHorizPadding
const messageRectWidth = messageTextWidth + 2 * messageHorizPadding
let { labelWidth, messageWidth } = computeWidths({ label, message })
labelWidth += 10 + totalLogoWidth
messageWidth += 10
messageWidth -= 4
const labelTextX = ((labelWidth + totalLogoWidth) / 2) * 10
const labelTextLength = (labelWidth - (10 + totalLogoWidth)) * 10
const escapedLabel = escapeXml(label)
let [leftLink, rightLink] = links
leftLink = escapeXml(leftLink)
@@ -478,35 +519,29 @@ function social({
const accessibleText = createAccessibleText({ label, message })
function renderMessageBubble() {
const messageBubbleMainX = labelRectWidth + horizGutter + 0.5
const messageBubbleNotchX = labelRectWidth + horizGutter
const messageBubbleMainX = labelWidth + 6.5
const messageBubbleNotchX = labelWidth + 6
return `
<rect x="${messageBubbleMainX}" y="0.5" width="${messageRectWidth}" height="${internalHeight}" rx="2" fill="#fafafa"/>
<rect x="${messageBubbleMainX}" y="0.5" width="${messageWidth}" height="${internalHeight}" rx="2" fill="#fafafa"/>
<rect x="${messageBubbleNotchX}" y="7.5" width="0.5" height="5" stroke="#fafafa"/>
<path d="M${messageBubbleMainX} 6.5 l-3 3v1 l3 3" stroke="d5d5d5" fill="#fafafa"/>
`
}
function renderLabelText() {
const labelTextX =
10 * (totalLogoWidth + labelTextWidth / 2 + labelHorizPadding)
const labelTextLength = 10 * labelTextWidth
const escapedLabel = escapeXml(label)
const shouldWrapWithLink = hasLeftLink && !shouldWrapBodyWithLink({ links })
const rect = `<rect id="llink" stroke="#d5d5d5" fill="url(#a)" x=".5" y=".5" width="${labelRectWidth}" height="${internalHeight}" rx="2" />`
const rect = `<rect id="llink" stroke="#d5d5d5" fill="url(#a)" x=".5" y=".5" width="${labelWidth}" height="${internalHeight}" rx="2" />`
const shadow = `<text aria-hidden="true" x="${labelTextX}" y="150" fill="#fff" transform="scale(.1)" textLength="${labelTextLength}">${escapedLabel}</text>`
const text = `<text x="${labelTextX}" y="140" transform="scale(.1)" textLength="${labelTextLength}">${escapedLabel}</text>`
return shouldWrapWithLink
? `
if (hasLeftLink && !shouldWrapBodyWithLink({ links })) {
return `
<a target="_blank" xlink:href="${leftLink}">
${shadow}
${text}
${rect}
</a>
`
: `
}
return `
${rect}
${shadow}
${text}
@@ -514,36 +549,34 @@ function social({
}
function renderMessageText() {
const messageTextX =
10 * (labelRectWidth + horizGutter + messageRectWidth / 2)
const messageTextLength = 10 * messageTextWidth
const messageTextX = (labelWidth + messageWidth / 2 + 6) * 10
const messageTextLength = (messageWidth - 8) * 10
const escapedMessage = escapeXml(message)
const rect = `<rect width="${messageRectWidth + 1}" x="${
labelRectWidth + horizGutter
const rect = `<rect width="${messageWidth + 1}" x="${
labelWidth + 6
}" height="${internalHeight + 1}" fill="rgba(0,0,0,0)" />`
const shadow = `<text aria-hidden="true" x="${messageTextX}" y="150" fill="#fff" transform="scale(.1)" textLength="${messageTextLength}">${escapedMessage}</text>`
const text = `<text id="rlink" x="${messageTextX}" y="140" transform="scale(.1)" textLength="${messageTextLength}">${escapedMessage}</text>`
return hasRightLink
? `
if (hasRightLink) {
return `
<a target="_blank" xlink:href="${rightLink}">
${rect}
${shadow}
${text}
</a>
`
: `
}
return `
${shadow}
${text}
`
}
return renderBadge(
const badge = renderBadge(
{
links,
leftWidth: labelRectWidth + 1,
rightWidth: hasMessage ? horizGutter + messageRectWidth : 0,
leftWidth: labelWidth + 1,
rightWidth: hasMessage ? messageWidth + 6 : 0,
accessibleText,
height: externalHeight,
},
@@ -558,7 +591,7 @@ function social({
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<g stroke="#d5d5d5">
<rect stroke="none" fill="#fcfcfc" x="0.5" y="0.5" width="${labelRectWidth}" height="${internalHeight}" rx="2"/>
<rect stroke="none" fill="#fcfcfc" x="0.5" y="0.5" width="${labelWidth}" height="${internalHeight}" rx="2"/>
${hasMessage ? renderMessageBubble() : ''}
</g>
${renderedLogo}
@@ -568,6 +601,11 @@ function social({
</g>
`
)
if (minify) {
return stripXmlWhitespace(badge)
}
return badge
}
function forTheBadge({
@@ -579,15 +617,14 @@ function forTheBadge({
logoPadding,
color = '#4c1',
labelColor,
minify,
}) {
// For the Badge is styled in all caps. Convert to caps here so widths can
// be measured using the correct characters.
label = label.toUpperCase()
message = message.toUpperCase()
let labelWidth = preferredWidthOf(label, { font: '10px Verdana' }) || 0
let messageWidth =
preferredWidthOf(message, { font: 'bold 10px Verdana' }) || 0
let { labelWidth, messageWidth } = computeWidths({ label, message })
const height = 28
const hasLabel = label.length || labelColor
if (labelColor == null) {
@@ -604,9 +641,7 @@ function forTheBadge({
labelWidth += 10 + totalLogoWidth
if (label.length) {
// Add 10 px of padding, plus approximately 1 px of letter spacing per
// character.
labelWidth += 10 + 2 * label.length
labelWidth += 10 + label.length * 1.5
} else if (hasLogo) {
if (hasLabel) {
labelWidth += 7
@@ -617,9 +652,8 @@ function forTheBadge({
labelWidth -= 11
}
// Add 20 px of padding, plus approximately 1.5 px of letter spacing per
// character.
messageWidth += 20 + 1.5 * message.length
messageWidth += 10
messageWidth += 10 + message.length * 2
const leftWidth = hasLogo && !hasLabel ? 0 : labelWidth
const rightWidth =
hasLogo && !hasLabel ? messageWidth + labelWidth : messageWidth
@@ -641,9 +675,7 @@ function forTheBadge({
const labelTextX = ((labelWidth + totalLogoWidth) / 2) * 10
const labelTextLength = (labelWidth - (24 + totalLogoWidth)) * 10
const escapedLabel = escapeXml(label)
const text = `<text fill="${textColor}" x="${labelTextX}" y="175" transform="scale(.1)" textLength="${labelTextLength}">${escapedLabel}</text>`
if (hasLeftLink && !shouldWrapBodyWithLink({ links })) {
return `
<a target="_blank" xlink:href="${leftLink}">
@@ -651,21 +683,18 @@ function forTheBadge({
${text}
</a>
`
} else {
return text
}
return text
}
function renderMessageText() {
const { textColor } = colorsForBackground(color)
const text = `<text fill="${textColor}" x="${
(labelWidth + messageWidth / 2) * 10
}" y="175" font-weight="bold" transform="scale(.1)" textLength="${
(messageWidth - 24) * 10
}">
${escapeXml(message)}</text>`
if (hasRightLink) {
return `
<a target="_blank" xlink:href="${rightLink}">
@@ -673,12 +702,11 @@ function forTheBadge({
${text}
</a>
`
} else {
return text
}
return text
}
return renderBadge(
const badge = renderBadge(
{
links,
leftWidth,
@@ -697,12 +725,17 @@ function forTheBadge({
${renderMessageText()}
</g>`
)
if (minify) {
return stripXmlWhitespace(badge)
}
return badge
}
module.exports = {
plastic: params => Plastic.render(params),
flat: params => Flat.render(params),
'flat-square': params => FlatSquare.render(params),
plastic,
flat,
social,
'flat-square': flatSquare,
'for-the-badge': forTheBadge,
}

View File

@@ -1,6 +1,6 @@
'use strict'
const { fromString } = require('css-color-converter')
const cssColorConverter = require('css-color-converter')
// When updating these, be sure also to update the list in `badge-maker/README.md`.
const namedColors = {
@@ -38,7 +38,10 @@ function isHexColor(s = '') {
}
function isCSSColor(color) {
return typeof color === 'string' && fromString(color.trim())
return (
typeof color === 'string' &&
typeof cssColorConverter(color.trim()).toRgbaArray() !== 'undefined'
)
}
function normalizeColor(color) {
@@ -70,9 +73,8 @@ function toSvgColor(color) {
function brightness(color) {
if (color) {
const cssColor = fromString(color)
if (cssColor) {
const rgb = cssColor.toRgbaArray()
const rgb = cssColorConverter(color).toRgbaArray()
if (rgb) {
return +((rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 255000).toFixed(2)
}
}

View File

@@ -37,7 +37,7 @@ test(normalizeColor, () => {
given(' blue ').expect(' blue ')
given('rgb(100%, 200%, 222%)').expect('rgb(100%, 200%, 222%)')
given('rgb(122, 200, 222)').expect('rgb(122, 200, 222)')
given('rgb(122, 200, 222, 1)').expect('rgb(122, 200, 222, 1)')
given('rgb(100%, 200, 222)').expect('rgb(100%, 200, 222)')
given('rgba(100, 20, 111, 1)').expect('rgba(100, 20, 111, 1)')
given('hsl(122, 200%, 222%)').expect('hsl(122, 200%, 222%)')
given('hsla(122, 200%, 222%, 1)').expect('hsla(122, 200%, 222%, 1)')
@@ -46,8 +46,8 @@ test(normalizeColor, () => {
given(''),
given('not-a-color'),
given('#ABCFGH'),
given('rgb(122, 200, 222, 1)'),
given('rgb(-100, 20, 111)'),
given('rgb(100%, 200, 222)'),
given('rgba(-100, 20, 111, 1.1)'),
given('hsl(122, 200, 222, 1)'),
given('hsl(122, 200, 222)'),

View File

@@ -51,8 +51,14 @@ function _clean(format) {
}
})
// Legacy.
cleaned.label = cleaned.label || ''
// convert "public" format to "internal" format
cleaned.text = [cleaned.label || '', cleaned.message]
delete cleaned.label
delete cleaned.message
if ('style' in cleaned) {
cleaned.template = cleaned.style
delete cleaned.style
}
return cleaned
}

View File

@@ -3,19 +3,14 @@
const { normalizeColor, toSvgColor } = require('./color')
const badgeRenderers = require('./badge-renderers')
function stripXmlWhitespace(xml) {
return xml.replace(/>\s+/g, '>').replace(/<\s+/g, '<').trim()
}
/*
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,
template = 'flat',
text,
color,
labelColor,
logo,
@@ -24,8 +19,9 @@ module.exports = function makeBadge({
links = ['', ''],
}) {
// String coercion and whitespace removal.
label = `${label}`.trim()
message = `${message}`.trim()
text = text.map(value => `${value}`.trim())
const [label, message] = text
// This ought to be the responsibility of the server, not `makeBadge`.
if (format === 'json') {
@@ -43,24 +39,23 @@ module.exports = function makeBadge({
})
}
const render = badgeRenderers[style]
const render = badgeRenderers[template]
if (!render) {
throw new Error(`Unknown badge style: '${style}'`)
throw new Error(`Unknown template: '${template}'`)
}
logoWidth = +logoWidth || (logo ? 14 : 0)
return stripXmlWhitespace(
render({
label,
message,
links,
logo,
logoPosition,
logoWidth,
logoPadding: logo && label.length ? 3 : 0,
color: toSvgColor(color),
labelColor: toSvgColor(labelColor),
})
)
return render({
label,
message,
links,
logo,
logoPosition,
logoWidth,
logoPadding: logo && label.length ? 3 : 0,
color: toSvgColor(color),
labelColor: toSvgColor(labelColor),
minify: true,
})
}

View File

@@ -4,18 +4,12 @@ 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',
text: ['name', 'Bob'],
[colorAttr]: color,
format: 'json',
})
@@ -40,14 +34,10 @@ describe('The badge generator', function () {
]).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.
@@ -63,6 +53,12 @@ describe('The badge generator', function () {
// invalid hex
given('#123red'), // contains letter above F
given('#red'), // contains letter above F
// invalid rgb(a)
given('rgb(220,128,255,0.5)'), // has alpha
given('rgba(0,0,255)'), // no alpha
// invalid hsl(a)
given('hsl(360,50%,50%,0.5)'), // has alpha
given('hsla(0,50%,101%)'), // no alpha
// neither a css named color nor colorscheme
given('notacolor'),
given('bluish'),
@@ -81,26 +77,23 @@ describe('The badge generator', function () {
describe('SVG', function () {
it('should produce SVG', function () {
expect(makeBadge({ label: 'cactus', message: 'grown', format: 'svg' }))
const svg = makeBadge({ text: ['cactus', 'grown'], format: 'svg' })
expect(svg)
.to.satisfy(isSvg)
.and.to.include('cactus')
.and.to.include('grown')
})
it('should match snapshot', function () {
expectBadgeToMatchSnapshot({
label: 'cactus',
message: 'grown',
format: 'svg',
})
const svg = makeBadge({ text: ['cactus', 'grown'], format: 'svg' })
snapshot(svg)
})
})
describe('JSON', function () {
it('should produce the expected JSON', function () {
const json = makeBadge({
label: 'cactus',
message: 'grown',
text: ['cactus', 'grown'],
format: 'json',
links: ['https://example.com/', 'https://other.example.com/'],
})
@@ -113,471 +106,484 @@ describe('The badge generator', function () {
})
})
it('should replace undefined svg badge style with "flat"', function () {
it('should replace undefined svg template with "flat"', function () {
const jsonBadgeWithUnknownStyle = makeBadge({
label: 'name',
message: 'Bob',
text: ['name', 'Bob'],
format: 'svg',
})
const jsonBadgeWithDefaultStyle = makeBadge({
label: 'name',
message: 'Bob',
text: ['name', 'Bob'],
format: 'svg',
style: 'flat',
template: 'flat',
})
expect(jsonBadgeWithUnknownStyle)
.to.equal(jsonBadgeWithDefaultStyle)
.and.to.satisfy(isSvg)
})
it('should fail with unknown svg badge style', function () {
it('should fail with unknown svg template', function () {
expect(() =>
makeBadge({
label: 'name',
message: 'Bob',
text: ['name', 'Bob'],
format: 'svg',
style: 'unknown_style',
template: 'unknown_style',
})
).to.throw(Error, "Unknown badge style: 'unknown_style'")
).to.throw(Error, "Unknown template: 'unknown_style'")
})
})
describe('"flat" template badge generation', function () {
it('should match snapshots: message/label, no logo', function () {
expectBadgeToMatchSnapshot({
label: 'cactus',
message: 'grown',
format: 'svg',
style: 'flat',
color: '#b3e',
labelColor: '#0f0',
})
snapshot(
makeBadge({
text: ['cactus', 'grown'],
format: 'svg',
template: 'flat',
color: '#b3e',
labelColor: '#0f0',
})
)
})
it('should match snapshots: message/label, with logo', function () {
expectBadgeToMatchSnapshot({
label: 'cactus',
message: 'grown',
format: 'svg',
style: 'flat',
color: '#b3e',
labelColor: '#0f0',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
snapshot(
makeBadge({
text: ['cactus', 'grown'],
format: 'svg',
template: 'flat',
color: '#b3e',
labelColor: '#0f0',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
)
})
it('should match snapshots: message only, no logo', function () {
expectBadgeToMatchSnapshot({
label: '',
message: 'grown',
format: 'svg',
style: 'flat',
color: '#b3e',
})
snapshot(
makeBadge({
text: ['', 'grown'],
format: 'svg',
template: 'flat',
color: '#b3e',
})
)
})
it('should match snapshots: message only, with logo', function () {
expectBadgeToMatchSnapshot({
label: '',
message: 'grown',
format: 'svg',
style: 'flat',
color: '#b3e',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
snapshot(
makeBadge({
text: ['', 'grown'],
format: 'svg',
template: 'flat',
color: '#b3e',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
)
})
it('should match snapshots: message only, with logo and labelColor', function () {
expectBadgeToMatchSnapshot({
label: '',
message: 'grown',
format: 'svg',
style: 'flat',
color: '#b3e',
labelColor: '#0f0',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
snapshot(
makeBadge({
text: ['', 'grown'],
format: 'svg',
template: 'flat',
color: '#b3e',
labelColor: '#0f0',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
)
})
it('should match snapshots: message/label, with links', function () {
expectBadgeToMatchSnapshot({
label: 'cactus',
message: 'grown',
format: 'svg',
style: 'flat',
color: '#b3e',
labelColor: '#0f0',
links: ['https://shields.io/', 'https://www.google.co.uk/'],
})
snapshot(
makeBadge({
text: ['cactus', 'grown'],
format: 'svg',
template: 'flat',
color: '#b3e',
labelColor: '#0f0',
links: ['https://shields.io/', 'https://www.google.co.uk/'],
})
)
})
})
describe('"flat-square" template badge generation', function () {
it('should match snapshots: message/label, no logo', function () {
expectBadgeToMatchSnapshot({
label: 'cactus',
message: 'grown',
format: 'svg',
style: 'flat-square',
color: '#b3e',
labelColor: '#0f0',
})
snapshot(
makeBadge({
text: ['cactus', 'grown'],
format: 'svg',
template: 'flat-square',
color: '#b3e',
labelColor: '#0f0',
})
)
})
it('should match snapshots: message/label, with logo', function () {
expectBadgeToMatchSnapshot({
label: 'cactus',
message: 'grown',
format: 'svg',
style: 'flat-square',
color: '#b3e',
labelColor: '#0f0',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
snapshot(
makeBadge({
text: ['cactus', 'grown'],
format: 'svg',
template: 'flat-square',
color: '#b3e',
labelColor: '#0f0',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
)
})
it('should match snapshots: message only, no logo', function () {
expectBadgeToMatchSnapshot({
label: '',
message: 'grown',
format: 'svg',
style: 'flat-square',
color: '#b3e',
})
snapshot(
makeBadge({
text: ['', 'grown'],
format: 'svg',
template: 'flat-square',
color: '#b3e',
})
)
})
it('should match snapshots: message only, with logo', function () {
expectBadgeToMatchSnapshot({
label: '',
message: 'grown',
format: 'svg',
style: 'flat-square',
color: '#b3e',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
snapshot(
makeBadge({
text: ['', 'grown'],
format: 'svg',
template: 'flat-square',
color: '#b3e',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
)
})
it('should match snapshots: message only, with logo and labelColor', function () {
expectBadgeToMatchSnapshot({
label: '',
message: 'grown',
format: 'svg',
style: 'flat-square',
color: '#b3e',
labelColor: '#0f0',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
snapshot(
makeBadge({
text: ['', 'grown'],
format: 'svg',
template: 'flat-square',
color: '#b3e',
labelColor: '#0f0',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
)
})
it('should match snapshots: message/label, with links', function () {
expectBadgeToMatchSnapshot({
label: 'cactus',
message: 'grown',
format: 'svg',
style: 'flat-square',
color: '#b3e',
labelColor: '#0f0',
links: ['https://shields.io/', 'https://www.google.co.uk/'],
})
snapshot(
makeBadge({
text: ['cactus', 'grown'],
format: 'svg',
template: 'flat-square',
color: '#b3e',
labelColor: '#0f0',
links: ['https://shields.io/', 'https://www.google.co.uk/'],
})
)
})
})
describe('"plastic" template badge generation', function () {
it('should match snapshots: message/label, no logo', function () {
expectBadgeToMatchSnapshot({
label: 'cactus',
message: 'grown',
format: 'svg',
style: 'plastic',
color: '#b3e',
labelColor: '#0f0',
})
snapshot(
makeBadge({
text: ['cactus', 'grown'],
format: 'svg',
template: 'plastic',
color: '#b3e',
labelColor: '#0f0',
})
)
})
it('should match snapshots: message/label, with logo', function () {
expectBadgeToMatchSnapshot({
label: 'cactus',
message: 'grown',
format: 'svg',
style: 'plastic',
color: '#b3e',
labelColor: '#0f0',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
snapshot(
makeBadge({
text: ['cactus', 'grown'],
format: 'svg',
template: 'plastic',
color: '#b3e',
labelColor: '#0f0',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
)
})
it('should match snapshots: message only, no logo', function () {
expectBadgeToMatchSnapshot({
label: '',
message: 'grown',
format: 'svg',
style: 'plastic',
color: '#b3e',
})
snapshot(
makeBadge({
text: ['', 'grown'],
format: 'svg',
template: 'plastic',
color: '#b3e',
})
)
})
it('should match snapshots: message only, with logo', function () {
expectBadgeToMatchSnapshot({
label: '',
message: 'grown',
format: 'svg',
style: 'plastic',
color: '#b3e',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
snapshot(
makeBadge({
text: ['', 'grown'],
format: 'svg',
template: 'plastic',
color: '#b3e',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
)
})
it('should match snapshots: message only, with logo and labelColor', function () {
expectBadgeToMatchSnapshot({
label: '',
message: 'grown',
format: 'svg',
style: 'plastic',
color: '#b3e',
labelColor: '#0f0',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
snapshot(
makeBadge({
text: ['', 'grown'],
format: 'svg',
template: 'plastic',
color: '#b3e',
labelColor: '#0f0',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
)
})
it('should match snapshots: message/label, with links', function () {
expectBadgeToMatchSnapshot({
label: 'cactus',
message: 'grown',
format: 'svg',
style: 'plastic',
color: '#b3e',
labelColor: '#0f0',
links: ['https://shields.io/', 'https://www.google.co.uk/'],
})
snapshot(
makeBadge({
text: ['cactus', 'grown'],
format: 'svg',
template: 'plastic',
color: '#b3e',
labelColor: '#0f0',
links: ['https://shields.io/', 'https://www.google.co.uk/'],
})
)
})
})
describe('"for-the-badge" template badge generation', function () {
// https://github.com/badges/shields/issues/1280
it('numbers should produce a string', function () {
expect(
makeBadge({
label: 1998,
message: 1999,
format: 'svg',
style: 'for-the-badge',
})
)
.to.include('1998')
.and.to.include('1999')
const svg = makeBadge({
text: [1998, 1999],
format: 'svg',
template: 'for-the-badge',
})
expect(svg).to.include('1998').and.to.include('1999')
})
it('lowercase/mixedcase string should produce uppercase string', function () {
expect(
makeBadge({
label: 'Label',
message: '1 string',
format: 'svg',
style: 'for-the-badge',
})
)
.to.include('LABEL')
.and.to.include('1 STRING')
const svg = makeBadge({
text: ['Label', '1 string'],
format: 'svg',
template: 'for-the-badge',
})
expect(svg).to.include('LABEL').and.to.include('1 STRING')
})
it('should match snapshots: message/label, no logo', function () {
expectBadgeToMatchSnapshot({
label: 'cactus',
message: 'grown',
format: 'svg',
style: 'for-the-badge',
color: '#b3e',
labelColor: '#0f0',
})
snapshot(
makeBadge({
text: ['cactus', 'grown'],
format: 'svg',
template: 'for-the-badge',
color: '#b3e',
labelColor: '#0f0',
})
)
})
it('should match snapshots: message/label, with logo', function () {
expectBadgeToMatchSnapshot({
label: 'cactus',
message: 'grown',
format: 'svg',
style: 'for-the-badge',
color: '#b3e',
labelColor: '#0f0',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
snapshot(
makeBadge({
text: ['cactus', 'grown'],
format: 'svg',
template: 'for-the-badge',
color: '#b3e',
labelColor: '#0f0',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
)
})
it('should match snapshots: message only, no logo', function () {
expectBadgeToMatchSnapshot({
label: '',
message: 'grown',
format: 'svg',
style: 'for-the-badge',
color: '#b3e',
})
snapshot(
makeBadge({
text: ['', 'grown'],
format: 'svg',
template: 'for-the-badge',
color: '#b3e',
})
)
})
it('should match snapshots: message only, with logo', function () {
expectBadgeToMatchSnapshot({
label: '',
message: 'grown',
format: 'svg',
style: 'for-the-badge',
color: '#b3e',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
snapshot(
makeBadge({
text: ['', 'grown'],
format: 'svg',
template: 'for-the-badge',
color: '#b3e',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
)
})
it('should match snapshots: message only, with logo and labelColor', function () {
expectBadgeToMatchSnapshot({
label: '',
message: 'grown',
format: 'svg',
style: 'for-the-badge',
color: '#b3e',
labelColor: '#0f0',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
snapshot(
makeBadge({
text: ['', 'grown'],
format: 'svg',
template: 'for-the-badge',
color: '#b3e',
labelColor: '#0f0',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
)
})
it('should match snapshots: message/label, with links', function () {
expectBadgeToMatchSnapshot({
label: 'cactus',
message: 'grown',
format: 'svg',
style: 'for-the-badge',
color: '#b3e',
labelColor: '#0f0',
links: ['https://shields.io/', 'https://www.google.co.uk/'],
})
snapshot(
makeBadge({
text: ['cactus', 'grown'],
format: 'svg',
template: 'for-the-badge',
color: '#b3e',
labelColor: '#0f0',
links: ['https://shields.io/', 'https://www.google.co.uk/'],
})
)
})
})
describe('"social" template badge generation', function () {
it('should produce capitalized string for badge key', function () {
expect(
makeBadge({
label: 'some-key',
message: 'some-value',
format: 'svg',
style: 'social',
})
)
.to.include('Some-key')
.and.to.include('some-value')
const svg = makeBadge({
text: ['some-key', 'some-value'],
format: 'svg',
template: 'social',
})
expect(svg).to.include('Some-key').and.to.include('some-value')
})
// https://github.com/badges/shields/issues/1606
it('should handle empty strings used as badge keys', function () {
expect(
makeBadge({
label: '',
message: 'some-value',
format: 'json',
style: 'social',
})
)
.to.include('""')
.and.to.include('some-value')
const svg = makeBadge({
text: ['', 'some-value'],
format: 'json',
template: 'social',
})
expect(svg).to.include('""').and.to.include('some-value')
})
it('should match snapshots: message/label, no logo', function () {
expectBadgeToMatchSnapshot({
label: 'cactus',
message: 'grown',
format: 'svg',
style: 'social',
color: '#b3e',
labelColor: '#0f0',
})
snapshot(
makeBadge({
text: ['cactus', 'grown'],
format: 'svg',
template: 'social',
color: '#b3e',
labelColor: '#0f0',
})
)
})
it('should match snapshots: message/label, with logo', function () {
expectBadgeToMatchSnapshot({
label: 'cactus',
message: 'grown',
format: 'svg',
style: 'social',
color: '#b3e',
labelColor: '#0f0',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
snapshot(
makeBadge({
text: ['cactus', 'grown'],
format: 'svg',
template: 'social',
color: '#b3e',
labelColor: '#0f0',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
)
})
it('should match snapshots: message only, no logo', function () {
expectBadgeToMatchSnapshot({
label: '',
message: 'grown',
format: 'svg',
style: 'social',
color: '#b3e',
})
snapshot(
makeBadge({
text: ['', 'grown'],
format: 'svg',
template: 'social',
color: '#b3e',
})
)
})
it('should match snapshots: message only, with logo', function () {
expectBadgeToMatchSnapshot({
label: '',
message: 'grown',
format: 'svg',
style: 'social',
color: '#b3e',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
snapshot(
makeBadge({
text: ['', 'grown'],
format: 'svg',
template: 'social',
color: '#b3e',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
)
})
it('should match snapshots: message only, with logo and labelColor', function () {
expectBadgeToMatchSnapshot({
label: '',
message: 'grown',
format: 'svg',
style: 'social',
color: '#b3e',
labelColor: '#0f0',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
snapshot(
makeBadge({
text: ['', 'grown'],
format: 'svg',
template: 'social',
color: '#b3e',
labelColor: '#0f0',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
)
})
it('should match snapshots: message/label, with links', function () {
expectBadgeToMatchSnapshot({
label: 'cactus',
message: 'grown',
format: 'svg',
style: 'social',
color: '#b3e',
labelColor: '#0f0',
links: ['https://shields.io/', 'https://www.google.co.uk/'],
})
snapshot(
makeBadge({
text: ['cactus', 'grown'],
format: 'svg',
template: 'social',
color: '#b3e',
labelColor: '#0f0',
links: ['https://shields.io/', 'https://www.google.co.uk/'],
})
)
})
})
describe('badges with logos should always produce the same badge', function () {
it('badge with logo', function () {
expectBadgeToMatchSnapshot({
label: 'label',
message: 'message',
const svg = makeBadge({
text: ['label', 'message'],
format: 'svg',
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
})
snapshot(svg)
})
})
describe('text colors', function () {
it('should use black text when the label color is light', function () {
expectBadgeToMatchSnapshot({
label: 'cactus',
message: 'grown',
format: 'svg',
style: 'flat',
color: '#000',
labelColor: '#f3f3f3',
})
snapshot(
makeBadge({
text: ['cactus', 'grown'],
format: 'svg',
template: 'flat',
color: '#000',
labelColor: '#f3f3f3',
})
)
})
it('should use black text when the message color is light', function () {
expectBadgeToMatchSnapshot({
label: 'cactus',
message: 'grown',
format: 'svg',
style: 'for-the-badge',
color: '#e2ffe1',
labelColor: '#000',
})
snapshot(
makeBadge({
text: ['cactus', 'grown'],
format: 'svg',
template: 'for-the-badge',
color: '#e2ffe1',
labelColor: '#000',
})
)
})
})
})

View File

@@ -1,6 +1,6 @@
{
"name": "badge-maker",
"version": "3.3.0",
"version": "3.2.0",
"description": "Shields.io badge library",
"keywords": [
"GitHub",
@@ -35,8 +35,8 @@
"logo": "https://opencollective.com/opencollective/logo.txt"
},
"dependencies": {
"anafanafo": "2.0.0",
"css-color-converter": "^2.0.0"
"anafanafo": "^1.0.0",
"css-color-converter": "^1.1.1"
},
"scripts": {
"test": "echo 'Run tests from parent dir'; false"

View File

@@ -21,7 +21,7 @@ public:
key: 'HTTPS_KEY'
cert: 'HTTPS_CRT'
redirectUrl: 'REDIRECT_URI'
redirectUri: 'REDIRECT_URI'
rasterUrl: 'RASTER_URL'
@@ -30,6 +30,9 @@ public:
__name: 'ALLOWED_ORIGIN'
__format: 'json'
persistence:
dir: 'PERSISTENCE_DIR'
services:
bitbucketServer:
authorizedOrigins: 'BITBUCKET_SERVER_ORIGINS'
@@ -61,13 +64,10 @@ public:
fetchLimit: 'FETCH_LIMIT'
requestTimeoutSeconds: 'REQUEST_TIMEOUT_SECONDS'
requestTimeoutMaxAgeSeconds: 'REQUEST_TIMEOUT_MAX_AGE_SECONDS'
requireCloudflare: 'REQUIRE_CLOUDFLARE'
private:
azure_devops_token: 'AZURE_DEVOPS_TOKEN'
bintray_user: 'BINTRAY_USER'
bintray_apikey: 'BINTRAY_API_KEY'
bitbucket_username: 'BITBUCKET_USER'
bitbucket_password: 'BITBUCKET_PASS'
bitbucket_server_username: 'BITBUCKET_SERVER_USER'

View File

@@ -16,6 +16,9 @@ public:
cors:
allowedOrigin: []
persistence:
dir: './private'
services:
github:
baseUri: 'https://api.github.com/'
@@ -33,9 +36,4 @@ public:
fetchLimit: '10MB'
requestTimeoutSeconds: 120
requestTimeoutMaxAgeSeconds: 30
requireCloudflare: false
private: {}

View File

@@ -15,4 +15,10 @@ public:
cors:
allowedOrigin: ['http://shields.io', 'https://shields.io']
redirectUrl: 'https://shields.io/'
rasterUrl: 'https://raster.shields.io'
private:
# These are not really private; they should be moved to `public`.
shields_ips: ['192.99.59.72', '51.254.114.150', '149.56.96.133']

View File

@@ -5,8 +5,11 @@
function escapeFormat(t) {
return (
t
// Single underscore.
.replace(/(^|[^_])((?:__)*)_(?!_)/g, '$1$2 ')
// Inline single underscore.
.replace(/([^_])_([^_])/g, '$1 $2')
// Leading or trailing underscore.
.replace(/([^_])_$/, '$1 ')
.replace(/^_([^_])/, ' $1')
// Double underscore and double dash.
.replace(/__/g, '_')
.replace(/--/g, '-')

View File

@@ -1,21 +0,0 @@
'use strict'
const { test, given } = require('sazerac')
const { escapeFormat } = require('./path-helpers')
describe('Badge URL helper functions', function () {
test(escapeFormat, () => {
given('_single leading underscore').expect(' single leading underscore')
given('single trailing underscore_').expect('single trailing underscore ')
given('__double leading underscores').expect('_double leading underscores')
given('double trailing underscores__').expect(
'double trailing underscores_'
)
given('treble___underscores').expect('treble_ underscores')
given('fourfold____underscores').expect('fourfold__underscores')
given('double--dashes').expect('double-dashes')
given('treble---dashes').expect('treble--dashes')
given('fourfold----dashes').expect('fourfold--dashes')
given('once_in_a_blue--moon').expect('once in a blue-moon')
})
})

View File

@@ -1,6 +1,6 @@
'use strict'
const Joi = require('joi')
const Joi = require('@hapi/joi')
const { expect } = require('chai')
const gql = require('graphql-tag')
const sinon = require('sinon')

View File

@@ -1,6 +1,6 @@
'use strict'
const Joi = require('joi')
const Joi = require('@hapi/joi')
const { expect } = require('chai')
const sinon = require('sinon')
const BaseJsonService = require('./base-json')

View File

@@ -0,0 +1,74 @@
'use strict'
const makeBadge = require('../../badge-maker/lib/make-badge')
const BaseService = require('./base')
const { MetricHelper } = require('./metric-helper')
const { setCacheHeaders } = require('./cache-headers')
const { makeSend } = require('./legacy-result-sender')
const coalesceBadge = require('./coalesce-badge')
const { prepareRoute, namedParamsForMatch } = require('./route')
// Badges are subject to two independent types of caching: in-memory and
// downstream.
//
// Services deriving from `NonMemoryCachingBaseService` are not cached in
// memory on the server. This means that each request that hits the server
// triggers another call to the handler. When using badges for server
// diagnostics, that's useful!
//
// In contrast, The `handle()` function of most other `BaseService`
// subclasses is wrapped in onboard, in-memory caching. See `lib /request-
// handler.js` and `BaseService.prototype.register()`.
//
// All services, including those extending NonMemoryCachingBaseServices, may
// be cached _downstream_. This is governed by cache headers, which are
// configured by the service, the user's request, and the server's default
// cache length.
module.exports = class NonMemoryCachingBaseService extends BaseService {
static register({ camp, metricInstance }, serviceConfig) {
const { cacheHeaders: cacheHeaderConfig } = serviceConfig
const { _cacheLength: serviceDefaultCacheLengthSeconds } = this
const { regex, captureNames } = prepareRoute(this.route)
const metricHelper = MetricHelper.create({
metricInstance,
ServiceClass: this,
})
camp.route(regex, async (queryParams, match, end, ask) => {
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
const svg = makeBadge(badgeData)
setCacheHeaders({
cacheHeaderConfig,
serviceDefaultCacheLengthSeconds,
queryParams,
res: ask.res,
})
makeSend(format, ask.res, end)(svg)
metricHandle.noteResponseSent()
})
}
}

View File

@@ -2,10 +2,14 @@
const { expect } = require('chai')
const sinon = require('sinon')
const Joi = require('joi')
const Joi = require('@hapi/joi')
const makeBadge = require('../../badge-maker/lib/make-badge')
const BaseSvgScrapingService = require('./base-svg-scraping')
function makeExampleSvg({ label, message }) {
return makeBadge({ text: ['this is the label', 'this is the result!'] })
}
const schema = Joi.object({
message: Joi.string().required(),
}).required()
@@ -25,7 +29,10 @@ class DummySvgScrapingService extends BaseSvgScrapingService {
describe('BaseSvgScrapingService', function () {
const exampleLabel = 'this is the label'
const exampleMessage = 'this is the result!'
const exampleSvg = makeBadge({ label: exampleLabel, message: exampleMessage })
const exampleSvg = makeExampleSvg({
label: exampleLabel,
message: exampleMessage,
})
describe('valueFromSvgBadge', function () {
it('should find the correct value', function () {

View File

@@ -1,6 +1,6 @@
'use strict'
const Joi = require('joi')
const Joi = require('@hapi/joi')
const { expect } = require('chai')
const sinon = require('sinon')
const BaseXmlService = require('./base-xml')

View File

@@ -57,7 +57,7 @@ class BaseYamlService extends BaseService {
})
let parsed
try {
parsed = yaml.load(buffer.toString(), encoding)
parsed = yaml.safeLoad(buffer.toString(), encoding)
} catch (err) {
logTrace(emojic.dart, 'Response YAML (unparseable)', buffer)
throw new InvalidResponse({

View File

@@ -1,6 +1,6 @@
'use strict'
const Joi = require('joi')
const Joi = require('@hapi/joi')
const { expect } = require('chai')
const sinon = require('sinon')
const BaseYamlService = require('./base-yaml')

View File

@@ -5,7 +5,7 @@
// See available emoji at http://emoji.muan.co/
const emojic = require('emojic')
const Joi = require('joi')
const Joi = require('@hapi/joi')
const log = require('../server/log')
const { AuthHelper } = require('./auth-helper')
const { MetricHelper, MetricNames } = require('./metric-helper')
@@ -213,18 +213,7 @@ class BaseService {
async _request({ url, options = {}, errorMessages = {} }) {
const logTrace = (...args) => trace.logTrace('fetch', ...args)
let logUrl = url
const logOptions = Object.assign({}, options)
if ('qs' in options) {
const params = new URLSearchParams(options.qs)
logUrl = `${url}?${params.toString()}`
delete logOptions.qs
}
logTrace(
emojic.bowAndArrow,
'Request',
`${logUrl}\n${JSON.stringify(logOptions, null, 2)}`
)
logTrace(emojic.bowAndArrow, 'Request', url, '\n', options)
const { res, buffer } = await this._requestFetcher(url, options)
await this._meterResponse(res, buffer)
logTrace(emojic.dart, 'Response status code', res.statusCode)

View File

@@ -1,6 +1,6 @@
'use strict'
const Joi = require('joi')
const Joi = require('@hapi/joi')
const chai = require('chai')
const { expect } = chai
const sinon = require('sinon')
@@ -329,7 +329,7 @@ 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)$/
const expectedRouteRegex = /^\/foo\/([^\/]+?)(|\.svg|\.json)$/
let mockCamp
let mockHandleRequest
@@ -373,10 +373,9 @@ describe('BaseService', function () {
const expectedFormat = 'svg'
expect(mockSendBadge).to.have.been.calledOnce
expect(mockSendBadge).to.have.been.calledWith(expectedFormat, {
label: 'cat',
message: 'Hello namedParamA: bar with queryParamA: ?',
text: ['cat', 'Hello namedParamA: bar with queryParamA: ?'],
color: 'lightgrey',
style: 'flat',
template: 'flat',
namedLogo: undefined,
logo: undefined,
logoWidth: undefined,
@@ -463,7 +462,9 @@ describe('BaseService', function () {
'fetch',
sinon.match.string,
'Request',
`${url}\n${JSON.stringify(options, null, 2)}`
url,
'\n',
options
)
expect(trace.logTrace).to.be.calledWithMatch(
'fetch',
@@ -517,7 +518,7 @@ describe('BaseService', function () {
await serviceInstance._request({ url })
expect(await register.getSingleMetricAsString('service_response_bytes'))
expect(register.getSingleMetricAsString('service_response_bytes'))
.to.contain(
'service_response_bytes_bucket{le="65536",category="other",family="undefined",service="dummy_service_with_service_response_size_metric_enabled"} 0\n'
)
@@ -543,7 +544,7 @@ describe('BaseService', function () {
await serviceInstance._request({ url })
expect(
await register.getSingleMetricAsString('service_response_bytes')
register.getSingleMetricAsString('service_response_bytes')
).to.not.contain('service_response_bytes_bucket')
})
})

View File

@@ -1,7 +1,7 @@
'use strict'
const assert = require('assert')
const Joi = require('joi')
const Joi = require('@hapi/joi')
const coalesce = require('./coalesce')
const serverStartTimeGMTString = new Date().toGMTString()

View File

@@ -1,6 +1,6 @@
'use strict'
const Joi = require('joi')
const Joi = require('@hapi/joi')
const categories = require('../../services/categories')
const isRealCategory = Joi.equal(...categories.map(({ id }) => id)).required()

View File

@@ -160,10 +160,12 @@ module.exports = function coalesceBadge(
}
return {
// Use `coalesce()` to support empty labels and messages, as in the static
// badge.
label: coalesce(overrideLabel, serviceLabel, defaultLabel, category),
message: coalesce(serviceMessage, 'n/a'),
text: [
// Use `coalesce()` to support empty labels and messages, as in the
// static badge.
coalesce(overrideLabel, serviceLabel, defaultLabel, category),
coalesce(serviceMessage, 'n/a'),
],
color: coalesce(
// In case of an error, disregard user's color override.
isError ? undefined : overrideColor,
@@ -177,7 +179,7 @@ module.exports = function coalesceBadge(
serviceLabelColor,
defaultLabelColor
),
style,
template: style,
namedLogo,
logo: logoSvgBase64,
logoWidth,

View File

@@ -7,61 +7,63 @@ const coalesceBadge = require('./coalesce-badge')
describe('coalesceBadge', function () {
describe('Label', function () {
it('uses the default label', function () {
expect(coalesceBadge({}, {}, { label: 'heyo' })).to.include({
label: 'heyo',
})
expect(coalesceBadge({}, {}, { label: 'heyo' }).text).to.deep.equal([
'heyo',
'n/a',
])
})
// This behavior isn't great and we might want to remove it.
it('uses the category as a default label', function () {
expect(coalesceBadge({}, {}, {}, { category: 'cat' })).to.include({
label: 'cat',
})
expect(
coalesceBadge({}, {}, {}, { category: 'cat' }).text
).to.deep.equal(['cat', 'n/a'])
})
it('preserves an empty label', function () {
expect(coalesceBadge({}, { label: '', message: '10k' }, {})).to.include({
label: '',
})
expect(
coalesceBadge({}, { label: '', message: '10k' }, {}).text
).to.deep.equal(['', '10k'])
})
it('overrides the label', function () {
expect(
coalesceBadge({ label: 'purr count' }, { label: 'purrs' }, {})
).to.include({ label: 'purr count' })
coalesceBadge({ label: 'purr count' }, { label: 'purrs' }, {}).text
).to.deep.equal(['purr count', 'n/a'])
})
})
describe('Message', function () {
it('applies the service message', function () {
expect(coalesceBadge({}, { message: '10k' }, {})).to.include({
message: '10k',
})
expect(coalesceBadge({}, { message: '10k' }, {}).text).to.deep.equal([
undefined,
'10k',
])
})
// https://github.com/badges/shields/issues/1280
it('converts a number to a string', function () {
it('applies a numeric service message', function () {
// While a number of badges use this, in the long run we may want
// `render()` to always return a string.
expect(coalesceBadge({}, { message: 10 }, {})).to.include({
message: 10,
})
expect(coalesceBadge({}, { message: 10 }, {}).text).to.deep.equal([
undefined,
10,
])
})
})
describe('Right color', function () {
it('uses the default color', function () {
expect(coalesceBadge({}, {}, {})).to.include({ color: 'lightgrey' })
expect(coalesceBadge({}, {}, {}).color).to.equal('lightgrey')
})
it('overrides the color', function () {
expect(
coalesceBadge({ color: '10ADED' }, { color: 'red' }, {})
).to.include({ color: '10ADED' })
coalesceBadge({ color: '10ADED' }, { color: 'red' }, {}).color
).to.equal('10ADED')
// also expected for legacy name
expect(
coalesceBadge({ colorB: 'B0ADED' }, { color: 'red' }, {})
).to.include({ color: 'B0ADED' })
coalesceBadge({ colorB: 'B0ADED' }, { color: 'red' }, {}).color
).to.equal('B0ADED')
})
context('In case of an error', function () {
@@ -71,23 +73,21 @@ describe('coalesceBadge', function () {
{ color: '10ADED' },
{ isError: true, color: 'lightgray' },
{}
)
).to.include({ color: 'lightgray' })
).color
).to.equal('lightgray')
// also expected for legacy name
expect(
coalesceBadge(
{ colorB: 'B0ADED' },
{ isError: true, color: 'lightgray' },
{}
)
).to.include({ color: 'lightgray' })
).color
).to.equal('lightgray')
})
})
it('applies the service color', function () {
expect(coalesceBadge({}, { color: 'red' }, {})).to.include({
color: 'red',
})
expect(coalesceBadge({}, { color: 'red' }, {}).color).to.equal('red')
})
})
@@ -97,19 +97,20 @@ describe('coalesceBadge', function () {
})
it('applies the service label color', function () {
expect(coalesceBadge({}, { labelColor: 'red' }, {})).to.include({
labelColor: 'red',
})
expect(coalesceBadge({}, { labelColor: 'red' }, {}).labelColor).to.equal(
'red'
)
})
it('overrides the label color', function () {
expect(
coalesceBadge({ labelColor: '42f483' }, { color: 'green' }, {})
).to.include({ labelColor: '42f483' })
.labelColor
).to.equal('42f483')
// also expected for legacy name
expect(
coalesceBadge({ colorA: 'B2f483' }, { color: 'green' }, {})
).to.include({ labelColor: 'B2f483' })
coalesceBadge({ colorA: 'B2f483' }, { color: 'green' }, {}).labelColor
).to.equal('B2f483')
})
it('converts a query-string numeric color to a string', function () {
@@ -119,8 +120,8 @@ describe('coalesceBadge', function () {
{ color: 123 },
{ color: 'green' },
{}
)
).to.include({ color: '123' })
).color
).to.equal('123')
// also expected for legacy name
expect(
coalesceBadge(
@@ -128,8 +129,8 @@ describe('coalesceBadge', function () {
{ colorB: 123 },
{ color: 'green' },
{}
)
).to.include({ color: '123' })
).color
).to.equal('123')
})
})
@@ -147,9 +148,9 @@ describe('coalesceBadge', function () {
})
it('applies the named logo', function () {
expect(coalesceBadge({}, { namedLogo: 'npm' }, {})).to.include({
namedLogo: 'npm',
})
expect(coalesceBadge({}, { namedLogo: 'npm' }, {}).namedLogo).to.equal(
'npm'
)
expect(coalesceBadge({}, { namedLogo: 'npm' }, {}).logo).to.equal(
getShieldsIcon({ name: 'npm' })
).and.not.to.be.empty
@@ -218,8 +219,8 @@ describe('coalesceBadge', function () {
it('overrides the logo with custom svg', function () {
const logoSvg = 'data:image/svg+xml;base64,PHN2ZyB4bWxu'
expect(
coalesceBadge({ logo: logoSvg }, { namedLogo: 'appveyor' }, {})
).to.include({ logo: logoSvg })
coalesceBadge({ logo: logoSvg }, { namedLogo: 'appveyor' }, {}).logo
).to.equal(logoSvg)
})
it('ignores the color when custom svg is provided', function () {
@@ -229,36 +230,35 @@ describe('coalesceBadge', function () {
{ logo: logoSvg, logoColor: 'brightgreen' },
{ namedLogo: 'appveyor' },
{}
)
).to.include({ logo: logoSvg })
).logo
).to.equal(logoSvg)
})
})
describe('Logo width', function () {
it('overrides the logoWidth', function () {
expect(coalesceBadge({ logoWidth: 20 }, {}, {})).to.include({
logoWidth: 20,
})
expect(coalesceBadge({ logoWidth: 20 }, {}, {}).logoWidth).to.equal(20)
})
it('applies the logo width', function () {
expect(
coalesceBadge({}, { namedLogo: 'npm', logoWidth: 275 }, {})
).to.include({ logoWidth: 275 })
coalesceBadge({}, { namedLogo: 'npm', logoWidth: 275 }, {}).logoWidth
).to.equal(275)
})
})
describe('Logo position', function () {
it('overrides the logoPosition', function () {
expect(coalesceBadge({ logoPosition: -10 }, {}, {})).to.include({
logoPosition: -10,
})
expect(
coalesceBadge({ logoPosition: -10 }, {}, {}).logoPosition
).to.equal(-10)
})
it('applies the logo position', function () {
expect(
coalesceBadge({}, { namedLogo: 'npm', logoPosition: -10 }, {})
).to.include({ logoPosition: -10 })
.logoPosition
).to.equal(-10)
})
})
@@ -279,24 +279,20 @@ describe('coalesceBadge', function () {
describe('Style', function () {
it('falls back to flat with invalid style', function () {
expect(coalesceBadge({ style: 'pill' }, {}, {})).to.include({
style: 'flat',
})
expect(coalesceBadge({ style: 7 }, {}, {})).to.include({
style: 'flat',
})
expect(coalesceBadge({ style: undefined }, {}, {})).to.include({
style: 'flat',
})
expect(coalesceBadge({ style: 'pill' }, {}, {}).template).to.equal('flat')
expect(coalesceBadge({ style: 7 }, {}, {}).template).to.equal('flat')
expect(coalesceBadge({ style: undefined }, {}, {}).template).to.equal(
'flat'
)
})
it('replaces legacy popout styles', function () {
expect(coalesceBadge({ style: 'popout' }, {}, {})).to.include({
style: 'flat',
})
expect(coalesceBadge({ style: 'popout-square' }, {}, {})).to.include({
style: 'flat-square',
})
expect(coalesceBadge({ style: 'popout' }, {}, {}).template).to.equal(
'flat'
)
expect(
coalesceBadge({ style: 'popout-square' }, {}, {}).template
).to.equal('flat-square')
})
})
@@ -304,7 +300,8 @@ describe('coalesceBadge', function () {
it('overrides the cache length', function () {
expect(
coalesceBadge({ style: 'pill' }, { cacheSeconds: 123 }, {})
).to.include({ cacheLengthSeconds: 123 })
.cacheLengthSeconds
).to.equal(123)
})
})
})

View File

@@ -1,6 +1,6 @@
'use strict'
const Joi = require('joi')
const Joi = require('@hapi/joi')
const camelcase = require('camelcase')
const BaseService = require('./base')
const { isValidCategory } = require('./categories')

View File

@@ -1,6 +1,6 @@
'use strict'
const Joi = require('joi')
const Joi = require('@hapi/joi')
const { pathToRegexp, compile } = require('path-to-regexp')
const categories = require('../../services/categories')
const coalesceBadge = require('./coalesce-badge')
@@ -124,7 +124,12 @@ function transformExample(inExample, index, ServiceClass) {
documentation,
} = validateExample(inExample, index, ServiceClass)
const { label, message, color, style, namedLogo } = coalesceBadge(
const {
text: [label, message],
color,
template: style,
namedLogo,
} = coalesceBadge(
{},
staticPreview,
ServiceClass.defaultBadgeData,

View File

@@ -3,6 +3,7 @@
const BaseService = require('./base')
const BaseJsonService = require('./base-json')
const BaseGraphqlService = require('./base-graphql')
const NonMemoryCachingBaseService = require('./base-non-memory-caching')
const BaseStaticService = require('./base-static')
const BaseSvgScrapingService = require('./base-svg-scraping')
const BaseXmlService = require('./base-xml')
@@ -21,6 +22,7 @@ module.exports = {
BaseService,
BaseJsonService,
BaseGraphqlService,
NonMemoryCachingBaseService,
BaseStaticService,
BaseSvgScrapingService,
BaseXmlService,

View File

@@ -1,6 +1,7 @@
'use strict'
const request = require('request')
const queryString = require('query-string')
const makeBadge = require('../../badge-maker/lib/make-badge')
const { setCacheHeaders } = require('./cache-headers')
const {
@@ -9,10 +10,27 @@ const {
ShieldsRuntimeError,
} = require('./errors')
const { makeSend } = require('./legacy-result-sender')
const LruCache = require('./lru-cache')
const coalesceBadge = require('./coalesce-badge')
const userAgent = 'Shields.io/2003a'
// We avoid calling the vendor's server for computation of the information in a
// number of badges.
const minAccuracy = 0.75
// The quotient of (vendor) data change frequency by badge request frequency
// must be lower than this to trigger sending the cached data *before*
// updating our data from the vendor's server.
// Indeed, the accuracy of our badges are:
// A(Δt) = 1 - min(# data change over Δt, # requests over Δt)
// / (# requests over Δt)
// = 1 - max(1, df) / rf
const freqRatioMax = 1 - minAccuracy
// Request cache size of 5MB (~5000 bytes/image).
const requestCache = new LruCache(1000)
// These query parameters are available to any badge. They are handled by
// `coalesceBadge`.
const globalQueryParams = new Set([
@@ -103,6 +121,8 @@ function handleRequest(cacheHeaderConfig, handlerOptions) {
return
}
const reqTime = new Date()
// `defaultCacheLengthSeconds` can be overridden by
// `serviceDefaultCacheLengthSeconds` (either by category or on a badge-
// by-badge basis). Then in turn that can be overridden by
@@ -131,10 +151,49 @@ function handleRequest(cacheHeaderConfig, handlerOptions) {
filteredQueryParams[key] = queryParams[key]
})
// Use sindresorhus query-string because it sorts the keys, whereas the
// builtin querystring module relies on the iteration order.
const stringified = queryString.stringify(filteredQueryParams)
const cacheIndex = `${match[0]}?${stringified}`
// Should we return the data right away?
const cached = requestCache.get(cacheIndex)
let cachedVersionSent = false
if (cached !== undefined) {
// A request was made not long ago.
const tooSoon = +reqTime - cached.time < cached.interval
if (tooSoon || cached.dataChange / cached.reqs <= freqRatioMax) {
const svg = makeBadge(cached.data.badgeData)
setCacheHeadersOnResponse(
ask.res,
cached.data.badgeData.cacheLengthSeconds
)
makeSend(cached.data.format, ask.res, end)(svg)
cachedVersionSent = true
// We do not wish to call the vendor servers.
if (tooSoon) {
return
}
}
}
// In case our vendor servers are unresponsive.
let serverUnresponsive = false
const serverResponsive = setTimeout(() => {
serverUnresponsive = true
if (cachedVersionSent) {
return
}
if (requestCache.has(cacheIndex)) {
const cached = requestCache.get(cacheIndex)
const svg = makeBadge(cached.data.badgeData)
setCacheHeadersOnResponse(
ask.res,
cached.data.badgeData.cacheLengthSeconds
)
makeSend(cached.data.format, ask.res, end)(svg)
return
}
ask.res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
const badgeData = coalesceBadge(
filteredQueryParams,
@@ -147,6 +206,8 @@ function handleRequest(cacheHeaderConfig, handlerOptions) {
makeSend(extension, ask.res, end)(svg)
}, 25000)
// Only call vendor servers when last request is older than…
let cacheInterval = 5000 // milliseconds
function cachingRequest(uri, options, callback) {
if (typeof options === 'function' && !callback) {
callback = options
@@ -162,7 +223,20 @@ function handleRequest(cacheHeaderConfig, handlerOptions) {
options.headers['User-Agent'] = userAgent
let bufferLength = 0
const r = request(options, callback)
const r = request(options, (err, res, body) => {
if (res != null && res.headers != null) {
const cacheControl = res.headers['cache-control']
if (cacheControl != null) {
const age = cacheControl.match(/max-age=([0-9]+)/)
// Would like to get some more test coverage on this before changing it.
// eslint-disable-next-line no-self-compare
if (age != null && +age[1] === +age[1]) {
cacheInterval = +age[1] * 1000
}
}
}
callback(err, res, body)
})
r.on('data', chunk => {
bufferLength += chunk.length
if (bufferLength > fetchLimitBytes) {
@@ -190,11 +264,30 @@ function handleRequest(cacheHeaderConfig, handlerOptions) {
return
}
clearTimeout(serverResponsive)
// Check for a change in the data.
let dataHasChanged = false
if (
cached !== undefined &&
cached.data.badgeData.text[1] !== badgeData.text[1]
) {
dataHasChanged = true
}
// Add format to badge data.
badgeData.format = format
const svg = makeBadge(badgeData)
setCacheHeadersOnResponse(ask.res, badgeData.cacheLengthSeconds)
makeSend(format, ask.res, end)(svg)
// Update information in the cache.
const updatedCache = {
reqs: cached ? cached.reqs + 1 : 1,
dataChange: cached ? cached.dataChange + (dataHasChanged ? 1 : 0) : 1,
time: +reqTime,
interval: cacheInterval,
data: { format, badgeData },
}
requestCache.set(cacheIndex, updatedCache)
if (!cachedVersionSent) {
const svg = makeBadge(badgeData)
setCacheHeadersOnResponse(ask.res, badgeData.cacheLengthSeconds)
makeSend(format, ask.res, end)(svg)
}
},
cachingRequest
)
@@ -206,8 +299,15 @@ function handleRequest(cacheHeaderConfig, handlerOptions) {
}
}
function clearRequestCache() {
requestCache.clear()
}
module.exports = {
handleRequest,
promisify,
clearRequestCache,
// Expose for testing.
_requestCache: requestCache,
userAgent,
}

View File

@@ -6,7 +6,11 @@ const portfinder = require('portfinder')
const Camp = require('@shields_io/camp')
const got = require('../got-test-client')
const coalesceBadge = require('./coalesce-badge')
const { handleRequest } = require('./legacy-request-handler')
const {
handleRequest,
clearRequestCache,
_requestCache,
} = require('./legacy-request-handler')
async function performTwoRequests(baseUrl, first, second) {
expect((await got(`${baseUrl}${first}`)).statusCode).to.equal(200)
@@ -79,6 +83,7 @@ describe('The request handler', function () {
camp.on('listening', () => done())
})
afterEach(function (done) {
clearRequestCache()
if (camp) {
camp.close(() => done())
camp = null
@@ -191,18 +196,57 @@ describe('The request handler', function () {
describe('caching', function () {
describe('standard query parameters', function () {
let handlerCallCount
beforeEach(function () {
handlerCallCount = 0
})
function register({ cacheHeaderConfig }) {
camp.route(
/^\/testing\/([^/]+)\.(svg|png|gif|jpg|json)$/,
handleRequest(
cacheHeaderConfig,
(queryParams, match, sendBadge, request) => {
++handlerCallCount
fakeHandler(queryParams, match, sendBadge, request)
}
)
)
}
context('With standard cache settings', function () {
beforeEach(function () {
register({ cacheHeaderConfig: standardCacheHeaders })
})
it('should cache identical requests', async function () {
await performTwoRequests(
baseUrl,
'/testing/123.svg',
'/testing/123.svg'
)
expect(handlerCallCount).to.equal(1)
})
it('should differentiate known query parameters', async function () {
await performTwoRequests(
baseUrl,
'/testing/123.svg?label=foo',
'/testing/123.svg?label=bar'
)
expect(handlerCallCount).to.equal(2)
})
it('should ignore unknown query parameters', async function () {
await performTwoRequests(
baseUrl,
'/testing/123.svg?foo=1',
'/testing/123.svg?foo=2'
)
expect(handlerCallCount).to.equal(1)
})
})
it('should set the expires header to current time + defaultCacheLengthSeconds', async function () {
register({ cacheHeaderConfig: { defaultCacheLengthSeconds: 900 } })
const { headers } = await got(`${baseUrl}/testing/123.json`)
@@ -233,6 +277,7 @@ describe('The request handler', function () {
handleRequest(
{ defaultCacheLengthSeconds: 300 },
(queryParams, match, sendBadge, request) => {
++handlerCallCount
createFakeHandlerWithCacheLength(400)(
queryParams,
match,
@@ -253,6 +298,7 @@ describe('The request handler', function () {
handleRequest(
{ defaultCacheLengthSeconds: 300 },
(queryParams, match, sendBadge, request) => {
++handlerCallCount
createFakeHandlerWithCacheLength(200)(
queryParams,
match,
@@ -299,6 +345,21 @@ describe('The request handler', function () {
'no-cache, no-store, must-revalidate'
)
})
describe('the cache key', function () {
beforeEach(function () {
register({ cacheHeaderConfig: standardCacheHeaders })
})
const expectedCacheKey = '/testing/123.json?color=123&label=foo'
it('should match expected and use canonical order - 1', async function () {
await got(`${baseUrl}/testing/123.json?color=123&label=foo`)
expect(_requestCache.cache).to.have.keys(expectedCacheKey)
})
it('should match expected and use canonical order - 2', async function () {
await got(`${baseUrl}/testing/123.json?label=foo&color=123`)
expect(_requestCache.cache).to.have.keys(expectedCacheKey)
})
})
})
describe('custom query parameters', function () {

View File

@@ -0,0 +1,136 @@
'use strict'
// In-memory KV, remove the oldest data when the capacity is reached.
const typeEnum = {
unit: 0,
heap: 1,
}
// In bytes.
let heapSize
function computeHeapSize() {
return (heapSize = process.memoryUsage().heapTotal)
}
let heapSizeTimeout
function getHeapSize() {
if (heapSizeTimeout == null) {
// Compute the heap size every 60 seconds.
heapSizeTimeout = setInterval(computeHeapSize, 60 * 1000)
return computeHeapSize()
} else {
return heapSize
}
}
function CacheSlot(key, value) {
this.key = key
this.value = value
this.older = null // Newest slot that is older than this slot.
this.newer = null // Oldest slot that is newer than this slot.
}
function Cache(capacity, type) {
type = type || 'unit'
this.capacity = capacity
this.type = typeEnum[type]
this.cache = new Map() // Maps cache keys to CacheSlots.
this.newest = null // Newest slot in the cache.
this.oldest = null
}
Cache.prototype = {
set: function addToCache(cacheKey, cached) {
let slot = this.cache.get(cacheKey)
if (slot === undefined) {
slot = new CacheSlot(cacheKey, cached)
this.cache.set(cacheKey, slot)
}
this.makeNewest(slot)
const numItemsToRemove = this.limitReached()
if (numItemsToRemove > 0) {
for (let i = 0; i < numItemsToRemove; i++) {
this.removeOldest()
}
}
},
get: function getFromCache(cacheKey) {
const slot = this.cache.get(cacheKey)
if (slot !== undefined) {
this.makeNewest(slot)
return slot.value
}
},
has: function hasInCache(cacheKey) {
return this.cache.has(cacheKey)
},
makeNewest: function makeNewestSlot(slot) {
const previousNewest = this.newest
if (previousNewest === slot) {
return
}
const older = slot.older
const newer = slot.newer
if (older !== null) {
older.newer = newer
} else if (newer !== null) {
this.oldest = newer
}
if (newer !== null) {
newer.older = older
}
this.newest = slot
if (previousNewest !== null) {
slot.older = previousNewest
slot.newer = null
previousNewest.newer = slot
} else {
// If previousNewest is null, the cache used to be empty.
this.oldest = slot
}
},
removeOldest: function removeOldest() {
const cacheKey = this.oldest.key
if (this.oldest !== null) {
this.oldest = this.oldest.newer
if (this.oldest !== null) {
this.oldest.older = null
}
}
this.cache.delete(cacheKey)
},
// Returns the number of elements to remove if we're past the limit.
limitReached: function heuristic() {
if (this.type === typeEnum.unit) {
// Remove the excess.
return Math.max(0, this.cache.size - this.capacity)
} else if (this.type === typeEnum.heap) {
if (getHeapSize() >= this.capacity) {
console.log('LRU HEURISTIC heap:', getHeapSize())
// Remove half of them.
return this.cache.size >> 1
} else {
return 0
}
} else {
console.error(`Unknown heuristic '${this.type}' for LRU cache.`)
return 1
}
},
clear: function () {
this.cache.clear()
this.newest = null
this.oldest = null
},
}
module.exports = Cache

View File

@@ -0,0 +1,134 @@
'use strict'
const { expect } = require('chai')
const LRU = require('./lru-cache')
function expectCacheSlots(cache, keys) {
expect(cache.cache.size).to.equal(keys.length)
const slots = keys.map(k => cache.cache.get(k))
const first = slots[0]
const last = slots.slice(-1)[0]
expect(cache.oldest).to.equal(first)
expect(cache.newest).to.equal(last)
expect(first.older).to.be.null
expect(last.newer).to.be.null
for (let i = 0; i + 1 < slots.length; ++i) {
const current = slots[i]
const next = slots[i + 1]
expect(current.newer).to.equal(next)
expect(next.older).to.equal(current)
}
}
describe('The LRU cache', function () {
it('should support a zero capacity', function () {
const cache = new LRU(0)
cache.set('key', 'value')
expect(cache.cache.size).to.equal(0)
})
it('should support a one capacity', function () {
const cache = new LRU(1)
cache.set('key1', 'value1')
expectCacheSlots(cache, ['key1'])
cache.set('key2', 'value2')
expectCacheSlots(cache, ['key2'])
expect(cache.get('key1')).to.be.undefined
expect(cache.get('key2')).to.equal('value2')
})
it('should remove the oldest element when reaching capacity', function () {
const cache = new LRU(2)
cache.set('key1', 'value1')
cache.set('key2', 'value2')
cache.set('key3', 'value3')
cache.cache.get('key1')
expectCacheSlots(cache, ['key2', 'key3'])
expect(cache.cache.get('key1')).to.be.undefined
expect(cache.get('key1')).to.be.undefined
expect(cache.get('key2')).to.equal('value2')
expect(cache.get('key3')).to.equal('value3')
})
it('should make sure that resetting a key in cache makes it newest', function () {
const cache = new LRU(2)
cache.set('key', 'value')
cache.set('key2', 'value2')
expectCacheSlots(cache, ['key', 'key2'])
cache.set('key', 'value')
expectCacheSlots(cache, ['key2', 'key'])
})
describe('getting a key in the cache', function () {
context('when the requested key is oldest', function () {
it('should leave the keys in the expected order', function () {
const cache = new LRU(2)
cache.set('key1', 'value1')
cache.set('key2', 'value2')
expectCacheSlots(cache, ['key1', 'key2'])
expect(cache.get('key1')).to.equal('value1')
expectCacheSlots(cache, ['key2', 'key1'])
})
})
context('when the requested key is newest', function () {
it('should leave the keys in the expected order', function () {
const cache = new LRU(2)
cache.set('key1', 'value1')
cache.set('key2', 'value2')
expect(cache.get('key2')).to.equal('value2')
expectCacheSlots(cache, ['key1', 'key2'])
})
})
context('when the requested key is in the middle', function () {
it('should leave the keys in the expected order', function () {
const cache = new LRU(3)
cache.set('key1', 'value1')
cache.set('key2', 'value2')
cache.set('key3', 'value3')
expectCacheSlots(cache, ['key1', 'key2', 'key3'])
expect(cache.get('key2')).to.equal('value2')
expectCacheSlots(cache, ['key1', 'key3', 'key2'])
})
})
})
it('should clear', function () {
// Set up.
const cache = new LRU(2)
cache.set('key1', 'value1')
cache.set('key2', 'value2')
// Confidence check.
expect(cache.get('key1')).to.equal('value1')
expect(cache.get('key2')).to.equal('value2')
// Run.
cache.clear()
// Test.
expect(cache.get('key1')).to.be.undefined
expect(cache.get('key2')).to.be.undefined
expect(cache.cache.size).to.equal(0)
})
})

View File

@@ -2,7 +2,7 @@
const camelcase = require('camelcase')
const emojic = require('emojic')
const Joi = require('joi')
const Joi = require('@hapi/joi')
const queryString = require('query-string')
const BaseService = require('./base')
const {
@@ -82,7 +82,7 @@ module.exports = function redirector(attrs) {
trace.logTrace('inbound', emojic.ticket, 'Named params', namedParams)
trace.logTrace('inbound', emojic.crayon, 'Query params', queryParams)
const targetPath = encodeURI(transformPath(namedParams))
const targetPath = transformPath(namedParams)
trace.logTrace('validate', emojic.dart, 'Target', targetPath)
let urlSuffix = ask.uri.search || ''

View File

@@ -121,20 +121,6 @@ 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,
}
)
expect(statusCode).to.equal(301)
expect(headers.location).to.equal(
'/new/service/hello%0Dworld.svg?foobar=a%0Db'
)
})
describe('transformQueryParams', function () {
const route = {
base: 'another/old/service',

View File

@@ -1,7 +1,7 @@
'use strict'
const escapeStringRegexp = require('escape-string-regexp')
const Joi = require('joi')
const Joi = require('@hapi/joi')
const { pathToRegexp } = require('path-to-regexp')
function makeFullUrl(base, partialUrl) {

View File

@@ -1,7 +1,7 @@
'use strict'
const { expect } = require('chai')
const Joi = require('joi')
const Joi = require('@hapi/joi')
const { test, given, forCases } = require('sazerac')
const {
prepareRoute,

View File

@@ -1,6 +1,6 @@
'use strict'
const Joi = require('joi')
const Joi = require('@hapi/joi')
// This should be kept in sync with the schema in
// `frontend/lib/service-definitions/index.ts`.

View File

@@ -1,7 +1,7 @@
'use strict'
const emojic = require('emojic')
const Joi = require('joi')
const Joi = require('@hapi/joi')
const trace = require('./trace')
function validate(

View File

@@ -1,6 +1,6 @@
'use strict'
const Joi = require('joi')
const Joi = require('@hapi/joi')
const { expect } = require('chai')
const sinon = require('sinon')
const trace = require('./trace')

View File

@@ -1,6 +1,8 @@
'use strict'
const os = require('os')
const got = require('got')
const { promisify } = require('util')
const { post } = require('request')
const postAsync = promisify(post)
const generateInstanceId = require('./instance-id-generator')
const { promClientJsonToInfluxV2 } = require('./metrics/format-converters')
const log = require('./log')
@@ -13,19 +15,21 @@ module.exports = class InfluxMetrics {
}
async sendMetrics() {
const auth = {
user: this._config.username,
pass: this._config.password,
}
const request = {
url: this._config.url,
uri: this._config.url,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: await this.metrics(),
body: this.metrics(),
timeout: this._config.timeoutMillseconds,
username: this._config.username,
password: this._config.password,
throwHttpErrors: false,
auth,
}
let response
try {
response = await got.post(request)
response = await postAsync(request)
} catch (error) {
log.error(
new Error(`Cannot push metrics. Cause: ${error.name}: ${error.message}`)
@@ -34,7 +38,7 @@ module.exports = class InfluxMetrics {
if (response && response.statusCode >= 300) {
log.error(
new Error(
`Cannot push metrics. ${request.url} responded with status code ${response.statusCode}`
`Cannot push metrics. ${response.request.href} responded with status code ${response.statusCode}`
)
)
}
@@ -47,8 +51,8 @@ module.exports = class InfluxMetrics {
)
}
async metrics() {
return promClientJsonToInfluxV2(await this._metricInstance.metrics(), {
metrics() {
return promClientJsonToInfluxV2(this._metricInstance.metrics(), {
env: this._config.envLabel,
application: 'shields',
instance: this._instanceId,

View File

@@ -36,7 +36,7 @@ describe('Influx metrics', function () {
instanceIdEnvVarName: 'INSTANCE_ID',
})
expect(await influxMetrics.metrics()).to.contain('instance=instance3')
expect(influxMetrics.metrics()).to.contain('instance=instance3')
})
it('should use a hostname as an instance label', async function () {
@@ -46,9 +46,7 @@ describe('Influx metrics', function () {
}
const influxMetrics = new InfluxMetrics(metricInstance, customConfig)
expect(await influxMetrics.metrics()).to.be.contain(
'instance=test-hostname'
)
expect(influxMetrics.metrics()).to.be.contain('instance=test-hostname')
})
it('should use a random string as an instance label', async function () {
@@ -57,7 +55,7 @@ describe('Influx metrics', function () {
}
const influxMetrics = new InfluxMetrics(metricInstance, customConfig)
expect(await influxMetrics.metrics()).to.be.match(/instance=\w+ /)
expect(influxMetrics.metrics()).to.be.match(/instance=\w+ /)
})
it('should use a hostname alias as an instance label', async function () {
@@ -68,7 +66,7 @@ describe('Influx metrics', function () {
}
const influxMetrics = new InfluxMetrics(metricInstance, customConfig)
expect(await influxMetrics.metrics()).to.be.contain(
expect(influxMetrics.metrics()).to.be.contain(
'instance=test-hostname-alias'
)
})
@@ -150,7 +148,7 @@ describe('Influx metrics', function () {
.and(
sinon.match.has(
'message',
'Cannot push metrics. Cause: RequestError: Nock: Disallowed net connect for "shields-metrics.io:80/metrics"'
'Cannot push metrics. Cause: NetConnectNotAllowedError: Nock: Disallowed net connect for "shields-metrics.io:80/metrics"'
)
)
)

View File

@@ -2,26 +2,26 @@
const groupBy = require('lodash.groupby')
function promClientJsonToInfluxV2(metrics, extraLabels = {}) {
return metrics
.flatMap(metric => {
const valuesByLabels = groupBy(metric.values, value =>
JSON.stringify(Object.entries(value.labels).sort())
)
return Object.values(valuesByLabels).map(metricsWithSameLabel => {
const labels = Object.entries(metricsWithSameLabel[0].labels)
.concat(Object.entries(extraLabels))
.sort((a, b) => a[0].localeCompare(b[0]))
.map(labelEntry => `${labelEntry[0]}=${labelEntry[1]}`)
.join(',')
const labelsFormatted = labels ? `,${labels}` : ''
const values = metricsWithSameLabel
.sort((a, b) => a.metricName.localeCompare(b.metricName))
.map(value => `${value.metricName || metric.name}=${value.value}`)
.join(',')
return `prometheus${labelsFormatted} ${values}`
})
}, metrics)
.join('\n')
// TODO Replace with Array.prototype.flatMap() after migrating to Node.js >= 11
const flatMap = (f, arr) => arr.reduce((acc, x) => acc.concat(f(x)), [])
return flatMap(metric => {
const valuesByLabels = groupBy(metric.values, value =>
JSON.stringify(Object.entries(value.labels).sort())
)
return Object.values(valuesByLabels).map(metricsWithSameLabel => {
const labels = Object.entries(metricsWithSameLabel[0].labels)
.concat(Object.entries(extraLabels))
.sort((a, b) => a[0].localeCompare(b[0]))
.map(labelEntry => `${labelEntry[0]}=${labelEntry[1]}`)
.join(',')
const labelsFormatted = labels ? `,${labels}` : ''
const values = metricsWithSameLabel
.sort((a, b) => a.metricName.localeCompare(b.metricName))
.map(value => `${value.metricName || metric.name}=${value.value}`)
.join(',')
return `prometheus${labelsFormatted} ${values}`
})
}, metrics).join('\n')
}
module.exports = { promClientJsonToInfluxV2 }

View File

@@ -22,7 +22,7 @@ describe('Metric format converters', function () {
expect(influx).to.be.equal('prometheus counter1=11')
})
it('converts a counter (from prometheus registry)', async function () {
it('converts a counter (from prometheus registry)', function () {
const register = new prometheus.Registry()
const counter = new prometheus.Counter({
name: 'counter1',
@@ -31,7 +31,7 @@ describe('Metric format converters', function () {
})
counter.inc(11)
const influx = promClientJsonToInfluxV2(await register.getMetricsAsJSON())
const influx = promClientJsonToInfluxV2(register.getMetricsAsJSON())
expect(influx).to.be.equal('prometheus counter1=11')
})
@@ -52,7 +52,7 @@ describe('Metric format converters', function () {
expect(influx).to.be.equal('prometheus gauge1=20')
})
it('converts a gauge (from prometheus registry)', async function () {
it('converts a gauge (from prometheus registry)', function () {
const register = new prometheus.Registry()
const gauge = new prometheus.Gauge({
name: 'gauge1',
@@ -61,7 +61,7 @@ describe('Metric format converters', function () {
})
gauge.inc(20)
const influx = promClientJsonToInfluxV2(await register.getMetricsAsJSON())
const influx = promClientJsonToInfluxV2(register.getMetricsAsJSON())
expect(influx).to.be.equal('prometheus gauge1=20')
})
@@ -101,7 +101,7 @@ prometheus histogram1_count=3,histogram1_sum=111`)
)
})
it('converts a histogram (from prometheus registry)', async function () {
it('converts a histogram (from prometheus registry)', function () {
const register = new prometheus.Registry()
const histogram = new prometheus.Histogram({
name: 'histogram1',
@@ -113,7 +113,7 @@ prometheus histogram1_count=3,histogram1_sum=111`)
histogram.observe(10)
histogram.observe(1)
const influx = promClientJsonToInfluxV2(await register.getMetricsAsJSON())
const influx = promClientJsonToInfluxV2(register.getMetricsAsJSON())
expect(sortLines(influx)).to.be.equal(
sortLines(`prometheus,le=+Inf histogram1_bucket=3
@@ -151,7 +151,7 @@ prometheus summary1_count=3,summary1_sum=111`)
)
})
it('converts a summary (from prometheus registry)', async function () {
it('converts a summary (from prometheus registry)', function () {
const register = new prometheus.Registry()
const summary = new prometheus.Summary({
name: 'summary1',
@@ -163,7 +163,7 @@ prometheus summary1_count=3,summary1_sum=111`)
summary.observe(10)
summary.observe(1)
const influx = promClientJsonToInfluxV2(await register.getMetricsAsJSON())
const influx = promClientJsonToInfluxV2(register.getMetricsAsJSON())
expect(sortLines(influx)).to.be.equal(
sortLines(`prometheus,quantile=0.99 summary1=100

View File

@@ -1,6 +1,20 @@
'use strict'
const config = require('config').util.toObject()
const secretIsValid = require('./secret-is-valid')
const RateLimit = require('./rate-limit')
const log = require('./log')
function secretInvalid(req, res) {
if (!secretIsValid(req.password)) {
// An unknown entity tries to connect. Let the connection linger for a minute.
setTimeout(() => {
res.json({ errors: [{ code: 'invalid_secrets' }] })
}, 10000)
return true
}
return false
}
function setRoutes({ rateLimit }, { server, metricInstance }) {
const ipRateLimit = new RateLimit({
@@ -15,6 +29,12 @@ function setRoutes({ rateLimit }, { server, metricInstance }) {
})
server.handle((req, res, next) => {
if (req.url.startsWith('/sys/')) {
if (secretInvalid(req, res)) {
return
}
}
if (rateLimit) {
const ip =
(req.headers['x-forwarded-for'] || '').split(', ')[0] ||
@@ -39,6 +59,27 @@ function setRoutes({ rateLimit }, { server, metricInstance }) {
next()
})
server.get('/sys/network', (req, res) => {
res.json({ ips: config.public.shields_ips })
})
server.ws('/sys/logs', socket => {
const listener = (...msg) => socket.send(msg.join(' '))
socket.on('close', () => log.removeListener(listener))
socket.on('message', msg => {
let req
try {
req = JSON.parse(msg)
} catch (e) {
return
}
if (!secretIsValid(req.secret)) {
return socket.close()
}
log.addListener(listener)
})
})
server.get('/sys/rate-limit', (req, res) => {
res.json({
ip: ipRateLimit.toJSON(),
@@ -54,4 +95,6 @@ function setRoutes({ rateLimit }, { server, metricInstance }) {
}
}
module.exports = { setRoutes }
module.exports = {
setRoutes,
}

View File

@@ -76,9 +76,9 @@ module.exports = class PrometheusMetrics {
async registerMetricsEndpoint(server) {
const { register } = this
server.route(/^\/metrics$/, async (data, match, end, ask) => {
server.route(/^\/metrics$/, (data, match, end, ask) => {
ask.res.setHeader('Content-Type', register.contentType)
ask.res.end(await register.metrics())
ask.res.end(register.metrics())
})
}
@@ -90,8 +90,8 @@ module.exports = class PrometheusMetrics {
}
}
async metrics() {
return await this.register.getMetricsAsJSON()
metrics() {
return this.register.getMetricsAsJSON()
}
/**

View File

@@ -1,5 +1,7 @@
'use strict'
const serverSecrets = require('../../lib/server-secrets')
function constEq(a, b) {
if (a.length !== b.length) {
return false
@@ -11,10 +13,9 @@ function constEq(a, b) {
return zero === 0
}
function makeSecretIsValid(shieldsSecret) {
return function secretIsValid(secret = '') {
return shieldsSecret && constEq(secret, shieldsSecret)
}
module.exports = function secretIsValid(secret = '') {
return (
serverSecrets.shields_secret &&
constEq(secret, serverSecrets.shields_secret)
)
}
module.exports = { makeSecretIsValid }

View File

@@ -6,19 +6,20 @@
const path = require('path')
const url = require('url')
const { URL } = url
const cloudflareMiddleware = require('cloudflare-middleware')
const bytes = require('bytes')
const Camp = require('@shields_io/camp')
const originalJoi = require('joi')
const originalJoi = require('@hapi/joi')
const makeBadge = require('../../badge-maker/lib/make-badge')
const GithubConstellation = require('../../services/github/github-constellation')
const suggest = require('../../services/suggest')
const { loadServiceClasses } = require('../base-service/loader')
const { makeSend } = require('../base-service/legacy-result-sender')
const { handleRequest } = require('../base-service/legacy-request-handler')
const {
handleRequest,
clearRequestCache,
} = require('../base-service/legacy-request-handler')
const { clearRegularUpdateCache } = require('../legacy/regular-update')
const { rasterRedirectUrl } = require('../badge-urls/make-badge-url')
const { nonNegativeInteger } = require('../../services/validators')
const log = require('./log')
const sysMonitor = require('./monitor')
const PrometheusMetrics = require('./prometheus-metrics')
@@ -88,10 +89,10 @@ const publicConfigSchema = Joi.object({
.integer()
.min(1)
.when('enabled', { is: true, then: Joi.required() }),
intervalSeconds: Joi.number().integer().min(1).when('enabled', {
is: true,
then: Joi.required(),
}),
intervalSeconds: Joi.number()
.integer()
.min(1)
.when('enabled', { is: true, then: Joi.required() }),
instanceIdFrom: Joi.string()
.equal('hostname', 'env-var', 'random')
.when('enabled', { is: true, then: Joi.required() }),
@@ -116,6 +117,9 @@ const publicConfigSchema = Joi.object({
cors: {
allowedOrigin: Joi.array().items(optionalUrl).required(),
},
persistence: {
dir: Joi.string().required(),
},
services: Joi.object({
bitbucketServer: defaultService,
drone: defaultService,
@@ -138,20 +142,18 @@ const publicConfigSchema = Joi.object({
teamcity: defaultService,
trace: Joi.boolean().required(),
}).required(),
cacheHeaders: { defaultCacheLengthSeconds: nonNegativeInteger },
cacheHeaders: {
defaultCacheLengthSeconds: Joi.number().integer().required(),
},
rateLimit: Joi.boolean().required(),
handleInternalErrors: Joi.boolean().required(),
fetchLimit: Joi.string().regex(/^[0-9]+(b|kb|mb|gb|tb)$/i),
requestTimeoutSeconds: nonNegativeInteger,
requestTimeoutMaxAgeSeconds: nonNegativeInteger,
documentRoot: Joi.string().default(
path.resolve(__dirname, '..', '..', 'public')
),
requireCloudflare: Joi.boolean().required(),
}).required()
const privateConfigSchema = Joi.object({
azure_devops_token: Joi.string(),
bintray_user: Joi.string(),
bintray_apikey: Joi.string(),
discord_bot_token: Joi.string(),
drone_token: Joi.string(),
gh_client_id: Joi.string(),
@@ -161,13 +163,12 @@ const privateConfigSchema = Joi.object({
jenkins_pass: Joi.string(),
jira_user: Joi.string(),
jira_pass: Joi.string(),
bitbucket_server_username: Joi.string(),
bitbucket_server_password: Joi.string(),
nexus_user: Joi.string(),
nexus_pass: Joi.string(),
npm_token: Joi.string(),
redis_url: Joi.string().uri({ scheme: ['redis', 'rediss'] }),
sentry_dsn: Joi.string(),
shields_ips: Joi.array().items(Joi.string().ip()),
shields_secret: Joi.string(),
sl_insight_userUuid: Joi.string(),
sl_insight_apiToken: Joi.string(),
@@ -185,11 +186,6 @@ const privateMetricsInfluxConfigSchema = privateConfigSchema.append({
influx_username: Joi.string().required(),
influx_password: Joi.string().required(),
})
function addHandlerAtIndex(camp, index, handlerFn) {
camp.stack.splice(index, 0, handlerFn)
}
/**
* The Server is based on the web framework Scoutcamp. It creates
* an http server, sets up helpers for token persistence and monitoring.
@@ -228,6 +224,7 @@ class Server {
}
this.githubConstellation = new GithubConstellation({
persistence: publicConfig.persistence,
service: publicConfig.services.github,
private: privateConfig,
})
@@ -281,23 +278,6 @@ 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) {
// 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 = process.env.DYNO
? req.headers['x-forwarded-for'].split(', ').pop()
: req.socket.remoteAddress
next()
})
addHandlerAtIndex(this.camp, 1, cloudflareMiddleware())
}
/**
* Set up Scoutcamp routes for 404/not found responses
*/
@@ -315,8 +295,7 @@ class Server {
end
)(
makeBadge({
label: '410',
message: `${format} no longer available`,
text: ['410', `${format} no longer available`],
color: 'lightgray',
format: 'svg',
})
@@ -331,8 +310,7 @@ class Server {
end
)(
makeBadge({
label: '404',
message: 'raster badges not available',
text: ['404', 'raster badges not available'],
color: 'lightgray',
format: 'svg',
})
@@ -350,8 +328,7 @@ class Server {
end
)(
makeBadge({
label: '404',
message: 'badge not found',
text: ['404', 'badge not found'],
color: 'red',
format,
})
@@ -432,25 +409,19 @@ class Server {
ssl: { isSecure: secure, cert, key },
cors: { allowedOrigin },
rateLimit,
requireCloudflare,
} = this.config.public
log(`Server is starting up: ${this.baseUrl}`)
const camp = (this.camp = Camp.create({
documentRoot: this.config.public.documentRoot,
documentRoot: path.resolve(__dirname, '..', '..', 'public'),
port,
hostname,
secure,
staticMaxAge: 300,
cert,
key,
}))
if (requireCloudflare) {
this.requireCloudflare()
}
const { metricInstance } = this
this.cleanupMonitor = sysMonitor.setRoutes(
{ rateLimit },
@@ -475,19 +446,6 @@ class Server {
this.registerRedirects()
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()
})
}
camp.listenAsConfigured()
await new Promise(resolve => camp.on('listening', () => resolve()))
@@ -496,6 +454,7 @@ class Server {
static resetGlobalState() {
// This state should be migrated to instance state. When possible, do not add new
// global state.
clearRequestCache()
clearRegularUpdateCache()
}

View File

@@ -1,11 +1,8 @@
'use strict'
const path = require('path')
const { expect } = require('chai')
const isSvg = require('is-svg')
const config = require('config')
const nock = require('nock')
const sinon = require('sinon')
const got = require('../got-test-client')
const Server = require('./server')
const { createTestServer } = require('./in-process-server-test-helpers')
@@ -16,11 +13,7 @@ describe('The server', function () {
before('Start the server', async function () {
// Fixes https://github.com/badges/shields/issues/2611
this.timeout(10000)
server = await createTestServer({
public: {
documentRoot: path.resolve(__dirname, 'test-public'),
},
})
server = await createTestServer()
baseUrl = server.baseUrl
await server.start()
})
@@ -52,16 +45,6 @@ describe('The server', function () {
.and.to.include('apple')
})
it('should serve front-end with default maxAge', async function () {
const { headers } = await got(`${baseUrl}/`)
expect(headers['cache-control']).to.equal('max-age=300, s-maxage=300')
})
it('should serve badges with custom maxAge', async function () {
const { headers } = await got(`${baseUrl}npm/l/express`)
expect(headers['cache-control']).to.equal('max-age=3600, s-maxage=3600')
})
it('should redirect colorscheme PNG badges as configured', async function () {
const { statusCode, headers } = await got(
`${baseUrl}:fruit-apple-green.png`,
@@ -185,79 +168,6 @@ describe('The server', function () {
})
})
context('`requireCloudflare` is enabled', function () {
let server
afterEach(async function () {
if (server) {
server.stop()
}
})
it('should reject requests from localhost with an empty 200 response', async function () {
this.timeout(10000)
server = await createTestServer({ public: { requireCloudflare: true } })
await server.start()
const { statusCode, body } = await got(
`${server.baseUrl}badge/foo-bar-blue.svg`
)
expect(statusCode).to.be.equal(200)
expect(body).to.equal('')
})
})
describe('`requestTimeoutSeconds` setting', function () {
let server
beforeEach(async function () {
this.timeout(10000)
// configure server to time out requests that take >2 seconds
server = await createTestServer({ public: { requestTimeoutSeconds: 2 } })
await server.start()
// /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)
})
})
afterEach(async function () {
if (server) {
server.stop()
}
server = undefined
})
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')
})
it('should not time out fast requests', async function () {
this.timeout(10000)
const { statusCode, body } = await got(`${server.baseUrl}fast`)
expect(statusCode).to.be.equal(200)
expect(body).to.equal('')
})
})
describe('configuration', function () {
let server
afterEach(async function () {
@@ -419,67 +329,4 @@ describe('The server', function () {
})
})
})
describe('running with metrics enabled', function () {
let server, baseUrl, scope, clock
const metricsPushIntervalSeconds = 1
before('Start the server', async function () {
// Fixes https://github.com/badges/shields/issues/2611
this.timeout(10000)
process.env.INSTANCE_ID = 'test-instance'
server = await createTestServer({
public: {
metrics: {
prometheus: { enabled: true },
influx: {
enabled: true,
url: 'http://localhost:1112/metrics',
instanceIdFrom: 'env-var',
instanceIdEnvVarName: 'INSTANCE_ID',
envLabel: 'localhost-env',
intervalSeconds: metricsPushIntervalSeconds,
},
},
},
private: {
influx_username: 'influx-username',
influx_password: 'influx-password',
},
})
clock = sinon.useFakeTimers()
baseUrl = server.baseUrl
await server.start()
})
after('Shut down the server', async function () {
if (server) {
await server.stop()
}
server = undefined
nock.cleanAll()
delete process.env.INSTANCE_ID
clock.restore()
})
it('should push custom metrics', async function () {
scope = nock('http://localhost:1112', {
reqheaders: {
'Content-Type': 'application/x-www-form-urlencoded',
},
})
.post(
'/metrics',
/prometheus,application=shields,category=static,env=localhost-env,family=static-badge,instance=test-instance,service=static_badge service_requests_total=1\n/
)
.basicAuth({ user: 'influx-username', pass: 'influx-password' })
.reply(200)
await got(`${baseUrl}badge/fruit-apple-green.svg`)
await clock.tickAsync(1000 * metricsPushIntervalSeconds + 500)
expect(scope.isDone()).to.be.equal(
true,
`pending mocks: ${scope.pendingMocks()}`
)
})
})
})

View File

@@ -1,10 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>shields.io</title>
</head>
<body>
concise, consistent, legible
</body>
</html>

View File

@@ -3,7 +3,7 @@
* @module
*/
const Joi = require('joi')
const Joi = require('@hapi/joi')
const { expect } = require('chai')
/**

View File

@@ -0,0 +1,51 @@
'use strict'
const fsos = require('fsos')
const TokenPersistence = require('./token-persistence')
class FsTokenPersistence extends TokenPersistence {
constructor({ path }) {
super()
this.path = path
}
async initialize() {
let contents
try {
contents = await fsos.get(this.path)
} catch (e) {
if (e.code === 'ENOENT') {
contents = '[]'
} else {
throw e
}
}
const tokens = JSON.parse(contents)
this._tokens = new Set(tokens)
return tokens
}
async save() {
const tokens = Array.from(this._tokens)
await fsos.set(this.path, JSON.stringify(tokens))
}
async onTokenAdded(token) {
if (!this._tokens) {
throw Error('initialize() has not been called')
}
this._tokens.add(token)
await this.save()
}
async onTokenRemoved(token) {
if (!this._tokens) {
throw Error('initialize() has not been called')
}
this._tokens.delete(token)
await this.save()
}
}
module.exports = FsTokenPersistence

View File

@@ -0,0 +1,72 @@
'use strict'
const fs = require('fs')
const tmp = require('tmp')
const readFile = require('fs-readfile-promise')
const { expect } = require('chai')
const FsTokenPersistence = require('./fs-token-persistence')
describe('File system token persistence', function () {
let path, persistence
beforeEach(function () {
path = tmp.tmpNameSync()
persistence = new FsTokenPersistence({ path })
})
context('when the file does not exist', function () {
it('does nothing', async function () {
const tokens = await persistence.initialize()
expect(tokens).to.deep.equal([])
})
it('saving creates an empty file', async function () {
await persistence.initialize()
await persistence.save()
const json = JSON.parse(await readFile(path))
expect(json).to.deep.deep.equal([])
})
})
context('when the file exists', function () {
const initialTokens = ['a', 'b', 'c'].map(char => char.repeat(40))
beforeEach(async function () {
fs.writeFileSync(path, JSON.stringify(initialTokens))
})
it('loads the contents', async function () {
const tokens = await persistence.initialize()
expect(tokens).to.deep.equal(initialTokens)
})
context('when tokens are added', function () {
it('saves the change', async function () {
const newToken = 'e'.repeat(40)
const expected = Array.from(initialTokens)
expected.push(newToken)
await persistence.initialize()
await persistence.noteTokenAdded(newToken)
const savedTokens = JSON.parse(await readFile(path))
expect(savedTokens).to.deep.equal(expected)
})
})
context('when tokens are removed', function () {
it('saves the change', async function () {
const expected = Array.from(initialTokens)
const toRemove = expected.pop()
await persistence.initialize()
await persistence.noteTokenRemoved(toRemove)
const savedTokens = JSON.parse(await readFile(path))
expect(savedTokens).to.deep.equal(expected)
})
})
})
})

View File

@@ -3,13 +3,13 @@
const { URL } = require('url')
const Redis = require('ioredis')
const log = require('../server/log')
const TokenPersistence = require('./token-persistence')
module.exports = class RedisTokenPersistence {
module.exports = class RedisTokenPersistence extends TokenPersistence {
constructor({ url, key }) {
super()
this.url = url
this.key = key
this.noteTokenAdded = this.noteTokenAdded.bind(this)
this.noteTokenRemoved = this.noteTokenRemoved.bind(this)
}
async initialize() {
@@ -40,20 +40,4 @@ module.exports = class RedisTokenPersistence {
async onTokenRemoved(token) {
await this.redis.srem(this.key, token)
}
async noteTokenAdded(token) {
try {
await this.onTokenAdded(token)
} catch (e) {
log.error(e)
}
}
async noteTokenRemoved(token) {
try {
await this.onTokenRemoved(token)
} catch (e) {
log.error(e)
}
}
}

View File

@@ -0,0 +1,44 @@
'use strict'
const log = require('../server/log')
// This is currently bound to the legacy github auth code. That will be
// replaced with a dependency-injected token provider.
class TokenPersistence {
constructor() {
this.noteTokenAdded = this.noteTokenAdded.bind(this)
this.noteTokenRemoved = this.noteTokenRemoved.bind(this)
}
async initialize() {
throw Error('initialize() is not implemented')
}
async stop() {}
async onTokenAdded(token) {
throw Error('onTokenAdded() is not implemented')
}
async noteTokenAdded(token) {
try {
await this.onTokenAdded(token)
} catch (e) {
log.error(e)
}
}
async onTokenRemoved(token) {
throw Error('onTokenRemoved() is not implemented')
}
async noteTokenRemoved(token) {
try {
await this.onTokenRemoved(token)
} catch (e) {
log.error(e)
}
}
}
module.exports = TokenPersistence

View File

@@ -62,13 +62,4 @@ describe('Main page', function () {
cy.get(`img[src='${backendUrl}/github/issues/badges/shields?color=orange']`)
})
it('Do not duplicate example parameters', function () {
cy.visit('/category/social')
cy.contains('GitHub Sponsors').click()
cy.get('[name="style"]').should($style => {
expect($style).to.have.length(1)
})
})
})

View File

@@ -111,7 +111,10 @@ if (allFiles.length > 100) {
// eslint-disable-next-line promise/prefer-await-to-then
danger.git.diffForFile(file).then(({ diff }) => {
if (diff.includes('authHelper') && !secretsDocs.modified) {
if (
(diff.includes('authHelper') || diff.includes('serverSecrets')) &&
!secretsDocs.modified
) {
warn(
[
`:books: Remember to ensure any changes to \`config.private\` `,
@@ -131,11 +134,11 @@ if (allFiles.length > 100) {
)
}
if (diff.includes("require('@hapi/joi')")) {
if (diff.includes("require('joi')")) {
fail(
[
`Found import of '@hapi/joi' in \`${file}\`. <br>`,
"Joi must be imported as 'joi'.",
`Found import of 'joi' in \`${file}\`. <br>`,
"Joi must be imported as '@hapi/joi'.",
].join('')
)
}

View File

@@ -25,7 +25,7 @@ and learn about the [Github workflow](http://try.github.io/).
#### Node, NPM
Node >=12 and NPM >=7 is required. If you don't already have them,
Node 12 or later is required. If you don't already have them,
install node and npm: https://nodejs.org/en/download/
### Setup a dev install
@@ -179,7 +179,7 @@ const { renderVersionBadge } = require('..//version')
const { BaseJsonService } = require('..')
// (4)
const Joi = require('joi')
const Joi = require('@hapi/joi')
const schema = Joi.object({
version: Joi.string().required(),
}).required()
@@ -226,7 +226,7 @@ Description of the code:
- [text-formatters.js](https://github.com/badges/shields/blob/master/services/text-formatters.js)
- [version.js](https://github.com/badges/shields/blob/master/services/version.js)
3. Our badge will query a JSON API so we will extend `BaseJsonService` instead of `BaseService`. This contains some helpers to reduce the need for boilerplate when calling a JSON API.
4. We perform input validation by defining a schema which we expect the JSON we receive to conform to. This is done using [Joi](https://github.com/hapijs/joi). Defining a schema means we can ensure the JSON we receive meets our expectations and throw an error if we receive unexpected input without having to explicitly code validation checks. The schema also acts as a filter on the JSON object. Any properties we're going to reference need to be validated, otherwise they will be filtered out. In this case our schema declares that we expect to receive an object which must have a property called 'version', which is a string. There is further documentation on [input validation](input-validation.md).
4. We perform input validation by defining a schema which we expect the JSON we receive to conform to. This is done using [Joi](https://github.com/hapijs/joi). Defining a schema means we can ensure the JSON we receive meets our expectations and throw an error if we receive unexpected input without having to explicitly code validation checks. The schema also acts as a filter on the JSON object. Any properties we're going to reference need to be validated, otherwise they will be filtered out. In this case our schema declares that we expect to receive an object which must have a property called 'version', which is a string.
5. Our module exports a class which extends `BaseJsonService`
6. Returns the name of the category to sort this badge into (eg. "build"). Used to sort the examples on the main [shields.io](https://shields.io) website. [Here](https://github.com/badges/shields/blob/master/services/categories.js) is the list of the valid categories. See [section 4.4](#44-adding-an-example-to-the-front-page) for more details on examples.
7. As with our previous badge, we need to declare a route. This time we will capture a variable called `gem`.
@@ -311,7 +311,7 @@ Save, run `npm start`, and you can see it [locally](http://127.0.0.1:3000/).
If you update `examples`, you don't have to restart the server. Run `npm run defs` in another terminal window and the frontend will update.
### (4.5) Write Tests<!-- Change the link below when you change the heading -->
### (4.5) Write Tests <!-- Change the link below when you change the heading -->
[write tests]: #45-write-tests

View File

@@ -125,9 +125,10 @@ test this kind of logic through unit tests (e.g. of `render()` and
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.
[`legacy-request-handler`][legacy-request-handler]. If the badge result
is found in a relatively small in-memory cache, the response is sent
immediately. Otherwise 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, request )`. Its job is to extract data
from the regex `match` and `queryParams`, invoke `request` to fetch
@@ -161,8 +162,8 @@ test this kind of logic through unit tests (e.g. of `render()` and
services 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
timeout. Then it renders the badge to svg or raster and pushes out the
result over the HTTPS connection.
timeout and caches the result. Then it renders the badge to svg or raster
and pushes out the result over the HTTPS connection.
[error reporting]: https://github.com/badges/shields/blob/master/doc/production-hosting.md#error-reporting
[coalescebadge]: https://github.com/badges/shields/blob/master/core/base-service/coalesce-badge.js

View File

@@ -1,50 +0,0 @@
# Input Data Validation
When we receive input data from an upstream API, we perform input validation to:
- Ensure we won't throw a runtime error trying to render a badge
- Ensure we won't render badges with spurious or unexpected output e.g: ![](https://img.shields.io/badge/version-null-blue) ![](https://img.shields.io/badge/coverage-NaN%25-red) ![](https://img.shields.io/badge/build-undefined-red) ![](https://img.shields.io/badge/coverage---10%25-critical) etc
- Express and document our understanding of the input data
## Writing schemas and validation
- The default validation mechanism should be to use [Joi](https://github.com/sideway/joi) to define a schema for the input data. Validation against Joi schemas is implemented in the base classes and inherited by every service class that extends them. Sometimes additional manual validation is needed which can't be covered by Joi and plugins in which case we implement it by hand.
- If validation is implemented manually (because we need to enforce a constraint that can't be expressed with Joi), invalid data should throw an [InvalidResponse](https://contributing.shields.io/module-core_base-service_errors-InvalidResponse.html) exception.
- Our definition of "valid" should not be stricter than the upstream API's definition of "valid".
- The schema/validation we choose is informed by the assumptions we're making about the data. e.g:
- If we're going to use a value, make sure it exists.
- If we need to multiply it by something, we check it's a number.
- If we're going to call `.split()` on it, we make sure it's a string.
- If we're going to address `foo[0]`, `foo` must be an array.
- If we're going to sort a version on the assumption it is a semver, check it's a semver
- We don't need to validate characteristics we don't rely on. For example, if we're just going to render a version on a badge with the same exact value from the API response and do not need to sort or transform the value, then it doesn't matter what format the version number is in. We can use a very relaxed schema to validate in this case, e.g. `Joi.string().required()`
- If theory (docs) and practice (real-world API responses) conflict, real-world outputs take precedence over documented behaviour. e.g: if the docs say version is a semver but we learn that there are real-world packages where the version number is `0.3b` or `1.2.1.27` then we should accept those values in preference to enforcing the documented API behaviour.
- Shields is descriptive rather than prescriptive. We reflect the established norms of the communities we serve.
- It is fine to define a single schema which is applied to multiple badges. For example, we could define a schema that says:
```js
const schema = Joi.object({
license: Joi.string().required(),
version: Joi.string().required(),
}).required()
```
and have both the license and version badges validate the response against that schema.
- For build status badges there is a shared [isBuildStatus](https://github.com/badges/shields/blob/master/services/build-status.js) validator. In most cases build status badges should use `isBuildStatus` or input validation and `renderBuildStatusBadge` should be used for rendering. Any additional status values can be added to the relevant color arrays.
## Identifying problems
- If we know of a real-world example of a package/repo/etc that causes us to render an invalid value on a badge (e.g: ![](https://img.shields.io/badge/version-null-blue) ![](https://img.shields.io/badge/coverage-NaN%25-red) ![](https://img.shields.io/badge/build-undefined-red) ) our input validation is broken and we should fix it.
- If we know of a real-world example of a package/repo/etc that causes us to throw an unhandled runtime exception, our input validation is broken and we should fix it.
- We should not fail to render a badge because of a validation failure on a field that isn't necessary to render the badge. In the above example of a shared license/version schema: If we become aware of a real-world example of a package/repo/etc that has a `version` key but not a `license` key then we should split the schema (or make `version` optional and handle the error in code).

View File

@@ -14,43 +14,55 @@ Production hosting is managed by the Shields ops team:
[operations issues]: https://github.com/badges/shields/issues?q=is%3Aissue+is%3Aopen+label%3Aoperations
[ops discord]: https://discordapp.com/channels/308323056592486420/480747695879749633
| Component | Subcomponent | People with access |
| ----------------------------- | ------------------------------- | --------------------------------------------------------------- |
| shields-production-us | Account owner | @paulmelnikow |
| shields-production-us | Full access | @calebcartwright, @chris48s, @paulmelnikow, @pyvesb |
| shields-production-us | Access management | @calebcartwright, @chris48s, @paulmelnikow, @pyvesb |
| Compose.io Redis | Account owner | @paulmelnikow |
| Compose.io Redis | Account access | @paulmelnikow |
| Compose.io Redis | Database connection credentials | @calebcartwright, @chris48s, @paulmelnikow, @pyvesb |
| Zeit Now | Team owner | @paulmelnikow |
| Zeit Now | Team members | @paulmelnikow, @chris48s, @calebcartwright, @platan |
| Raster server | Full access as team members | @paulmelnikow, @chris48s, @calebcartwright, @platan |
| shields-server.com redirector | Full access as team members | @paulmelnikow, @chris48s, @calebcartwright, @platan |
| Cloudflare (CDN) | Account owner | @espadrine |
| Cloudflare (CDN) | Access management | @espadrine |
| Cloudflare (CDN) | Admin access | @calebcartwright, @chris48s, @espadrine, @paulmelnikow, @PyvesB |
| Twitch | OAuth app | @PyvesB |
| Discord | OAuth app | @PyvesB |
| YouTube | Account owner | @PyvesB |
| OpenStreetMap (for Wheelmap) | Account owner | @paulmelnikow |
| DNS | Account owner | @olivierlacan |
| DNS | Read-only account access | @espadrine, @paulmelnikow, @chris48s |
| Sentry | Error reports | @espadrine, @paulmelnikow |
| Metrics server | Owner | @platan |
| UptimeRobot | Account owner | @paulmelnikow |
| More metrics | Owner | @RedSparr0w |
| Component | Subcomponent | People with access |
| ----------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------ |
| shields-production-us | Account owner | @paulmelnikow |
| shields-production-us | Full access | @calebcartwright, @chris48s, @paulmelnikow, @pyvesb |
| shields-production-us | Access management | @calebcartwright, @chris48s, @paulmelnikow, @pyvesb |
| Compose.io Redis | Account owner | @paulmelnikow |
| Compose.io Redis | Account access | @paulmelnikow |
| Compose.io Redis | Database connection credentials | @calebcartwright, @chris48s, @paulmelnikow, @pyvesb |
| Zeit Now | Team owner | @paulmelnikow |
| Zeit Now | Team members | @paulmelnikow, @chris48s, @calebcartwright, @platan |
| Raster server | Full access as team members | @paulmelnikow, @chris48s, @calebcartwright, @platan |
| shields-server.com redirector | Full access as team members | @paulmelnikow, @chris48s, @calebcartwright, @platan |
| Legacy badge servers | Account owner | @espadrine |
| Legacy badge servers | ssh, logs | @espadrine |
| Legacy badge servers | Deployment | @espadrine, @paulmelnikow |
| Legacy badge servers | Admin endpoints | @espadrine, @paulmelnikow |
| Cloudflare (CDN) | Account owner | @espadrine |
| Cloudflare (CDN) | Access management | @espadrine |
| Cloudflare (CDN) | Admin access | @calebcartwright, @chris48s, @espadrine, @paulmelnikow, @PyvesB |
| Twitch | OAuth app | @PyvesB |
| Discord | OAuth app | @PyvesB |
| YouTube | Account owner | @PyvesB |
| OpenStreetMap (for Wheelmap) | Account owner | @paulmelnikow |
| DNS | Account owner | @olivierlacan |
| DNS | Read-only account access | @espadrine, @paulmelnikow, @chris48s |
| Sentry | Error reports | @espadrine, @paulmelnikow |
| Frontend | Deployment | Technically anyone with push access but in practice must be deployed with the badge server |
| Metrics server | Owner | @platan |
| UptimeRobot | Account owner | @paulmelnikow |
| More metrics | Owner | @RedSparr0w |
| Netlify (documentation site) | Owner | @chris48s |
There are [too many bottlenecks][issue 2577]!
[issue 2577]: https://github.com/badges/shields/issues/2577
## Attached state
Shields has mercifully little persistent state:
1. The GitHub tokens we collect are saved on each server in a cloud Redis
database. They can also be fetched from the [GitHub auth admin endpoint][]
for debugging.
2. The server keeps the [regular-update cache][] in memory. It is neither
persisted nor inspectable.
1. The GitHub tokens we collect are saved on each server in a cloud Redis database.
They can also be fetched from the [GitHub auth admin endpoint][] for debugging.
2. The server keeps a few caches in memory. These are neither persisted nor
inspectable.
- The [request cache][]
- The [regular-update cache][]
[github auth admin endpoint]: https://github.com/badges/shields/blob/master/services/github/auth/admin.js
[request cache]: https://github.com/badges/shields/blob/master/core/base-service/legacy-request-handler.js#L29-L30
[regular-update cache]: https://github.com/badges/shields/blob/master/core/legacy/regular-update.js
## Configuration
@@ -78,17 +90,32 @@ files:
[shields-io-production.yml]: ../config/shields-io-production.yml
[default.yml]: ../config/default.yml
The project ships with `dotenv`, however there is no `.env` in production.
## Badge CDN
Sitting in front of the three servers is a Cloudflare Free account which
provides several services:
- Global CDN, caching, and SSL gateway for `img.shields.io` and `shields.io`
- Global CDN, caching, and SSL gateway for `img.shields.io`
- Analytics through the Cloudflare dashboard
- DNS resolution for `shields.io` (and subdomains)
- DNS hosting for `shields.io`
Cloudflare is configured to respect the servers' cache headers.
## Frontend
The frontend is served by [GitHub Pages][] via the [gh-pages branch][gh-pages]. SSL is enforced.
`shields.io` resolves to the GitHub Pages hosts. It is not proxied through
Cloudflare.
Technically any maintainer can push to `gh-pages`, but in practice the frontend must be deployed
with the badge server via the deployment process described below.
[github pages]: https://pages.github.com/
[gh-pages]: https://github.com/badges/shields/tree/gh-pages
## Raster server
The raster server `raster.shields.io` (a.k.a. the rasterizing proxy) is
@@ -98,14 +125,28 @@ hosted on [Zeit Now][]. It's managed in the
[zeit now]: https://zeit.co/now
[svg-to-image-proxy]: https://github.com/badges/svg-to-image-proxy
### Heroku Deployment
## Deployment
Both the badge server and frontend are served from Heroku.
The deployment is done in two stages: the badge server (heroku) and the front-end (gh-pages).
### Heroku
After merging a commit to master, heroku should create a staging deploy. Check this has deployed correctly in the `shields-staging` pipeline and review http://shields-staging.herokuapp.com/
If we're happy with it, "promote to production". This will deploy what's on staging to the `shields-production-eu` and `shields-production-us` pieplines.
### Frontend
To deploy the front-end to GH pages, use a clean clone of the shields repo.
```sh
$ git pull # update the working copy
$ npm ci # install dependencies (devDependencies are needed to build the frontend)
$ make deploy-gh-pages # build the frontend and push it to the gh-pages branch
```
No secrets are required to build or deploy the frontend.
## DNS
DNS is registered with [DNSimple][].
@@ -128,13 +169,6 @@ the server. It's generously donated by [Sentry][sentry home]. We bundle
[sentry home]: https://sentry.io/shields/
[sentry configuration]: https://github.com/badges/shields/blob/master/doc/self-hosting.md#sentry
## URLs
The canonical and only recommended domain for badge URLs is `img.shields.io`. Currently it is possible to request badges on both `img.shields.io` and `shields.io` i.e: https://img.shields.io/badge/build-passing-brightgreen and https://shields.io/badge/build-passing-brightgreen will both work. However:
- We never show or generate the `img.`-less URL format on https://shields.io/
- We make no guarantees about the `img.`-less URL format. At some future point we may remove the ability to serve badges on `shields.io` (without `img.`) without any warning. `img.shields.io` should always be used for badge urls.
## Monitoring
Overall server performance and requests by service are monitored using
@@ -154,3 +188,19 @@ Request performance is monitored in two places:
[monitor]: https://shields.redsparr0w.com/1568/
[notifications]: http://shields.redsparr0w.com/discord_notification
[monitor discord]: https://discordapp.com/channels/308323056592486420/470700909182320646
## Legacy servers
There are three legacy servers on OVH VPSs which are currently used for proxying.
| Cname | Hostname | Type | IP | Location |
| --------------------------- | -------------------- | ---- | -------------- | ------------------ |
| [s0.servers.shields.io][s0] | vps71670.vps.ovh.ca | VPS | 192.99.59.72 | Quebec, Canada |
| [s1.servers.shields.io][s1] | vps244529.ovh.net | VPS | 51.254.114.150 | Gravelines, France |
| [s2.servers.shields.io][s2] | vps117870.vps.ovh.ca | VPS | 149.56.96.133 | Quebec, Canada |
[s0]: https://s0.servers.shields.io/index.html
[s1]: https://s1.servers.shields.io/index.html
[s2]: https://s2.servers.shields.io/index.html
The only way to inspect the commit on the server is with `git ls-remote`.

View File

@@ -1,57 +0,0 @@
# Releases
Shields is a community project that is stewarded by a handful of core maintainers who contribute on a volunteer basis. We do our best to maintain the availability and reliability of the service, and enhance and improve the project overall. However, if you've spotted something wrong or would like to see a specific feature implemented, please consider helping us resolve it by submitting a pull request. All community contributions, even documentation improvements, are welcome!
https://github.com/badges/shields is a monorepo and hosts the Shields frontend and server code as well as the [badge-maker][npm package] NPM library (and the [badge design specification](https://github.com/badges/shields/tree/master/spec)). The packaging and release processes for these items is described in the respective sections below.
## badge-maker package
We follow [Semantic Versioning](https://semver.org/) for the badge-maker package, and publish git Tags using the form `X.Y.Z`, and all such tags of this form are badge-maker releases (e.g. [3.3.0](https://github.com/badges/shields/releases/tag/3.3.0))
The [badge-maker][npm package] is also published to the npm.js registry.
Releases of badge-maker are done as and when needed, and not on any predetermined interval.
## Shields.io service
The [Shields.io Service][shields.io] consists of the frontend [https://shields.io][shields.io] website which allows users to browse and discover available badges, as well as the badge server backend that serves up requested badges (e.g. https://img.shields.io/badge/badges-rock-blue).
This is the core, free, anonymous service available for anyone to use and which serves up hundreds of millions of badges every month!
We do not have a fixed schedule for deploying updates to the Shields.io production environment, but we typically deploy the latest version at least once per week.
We do not have any guaranteed SLA for the Shields.io service, but we do have monitoring and observability capabilities in place and our [Operations team](https://github.com/badges/shields#project-leaders) will review and address any availability, performance, etc. issues on a best-effort basis.
More information about the production environment can be found [here][production hosting]
## Shields server
Some users may wish to host their own instance of Shields so we also make it possible for users to do so. This is particularly useful if you want to serve badges for resources that require authentication or are not exposed to the internet (e.g: inside a corporate network). A variety of options are available either by installing from source or using a docker image.
### Important information
This Shields server is the exact same codebase that powers the main Shields.io service; we do not have a separate self-hosted version of Shields. This means that the server codebase is geared towards the development, maintenance, and operation of the Shields.io service so there are a few things to note:
- We often have to reject or de-prioritize feature requests that are only applicable for self-hosting and/or which may be problematic for the core Shields.io offering (e.g. requiring authentication on the badge server)
- We do not accept new badges that cannot be utilized with Shields.io (e.g. those that would always require user-specific authorization)
- We do not do any additional testing nor validation of self-hosted instances (e.g. we test Shields.io with Jira Cloud badges, but we don't test a self-hosted Shields server against a self-hosted Jira server instance with auth)
- We're happy to try to offer some tips and guidance to self-hosters when and where we can, but we don't have the ability to troubleshoot/debug/support/etc. self-hosted Shields servers
- Note that Paul, a core team member, offers some paid support options for those interested in self-hosting support. Contact him at ![](https://img.shields.io/badge/paul-%40m6ize.com-blue) for more information.
We are happy to document and collate any self-hosting patterns/approaches that others have found to be helpful to be able to share with the broader community. If you have any self-hosting material you'd like to share, please feel free to get in contact with us (or even open a PR) and we'll work with you to get that incorporated for the benefit of other self-hosters!
### Server Releases
We try to make it as easy as possible for users to self-host a Shields server so we publish a few releases of the server. Please be sure to refer to the [self hosting guide][self hosting] for a detailed walk through on how to spin up a server.
- The server uses [Calendar Versioning](https://calver.org/). Tags of the form `server-YYYY-MM-DD` are server releases (these are the tags that are relevant to self-hosting users, e.g. [server-2021-02-01](https://github.com/badges/shields/releases/tag/server-2021-02-01)).
- As well as [tags on GitHub](https://github.com/badges/shields/tags), server releases are also pushed to [DockerHub](https://registry.hub.docker.com/r/shieldsio/shields/tags). See the self-hosting section on [Docker](https://github.com/badges/shields/blob/master/doc/self-hosting.md#Docker) for more details.
- We publish release notes for server releases in the [CHANGELOG](https://github.com/badges/shields/blob/master/CHANGELOG.md). There may occasionally be non-backwards compatible changes to be aware of.
- We will normally put out one release per month. If there is a security patch or major bugfix affecting self-hosting users, we may put out an out-of-sequence release.
- Releases are just a snapshot in time. We advise always tracking the latest release to ensure you are up-to-date with the latest bug fixes and security updates. There are no 'patch' releases - we don't backport fixes to old releases. Tagged versions just provide a convenient way to apply upgrades in a controlled way or roll back to an older version if necessary and communicate about versions.
- You can stay on the bleeding edge by tracking the `master` branch for source installs or the `next` tag on DockerHub.
[shields.io]: https://shields.io
[npm package]: https://www.npmjs.com/package/badge-maker
[production hosting]: https://github.com/badges/shields/blob/master/doc/production-hosting.md
[self hosting]: https://github.com/badges/shields/blob/master/doc/self-hosting.md

283
doc/rewriting-services.md Normal file
View File

@@ -0,0 +1,283 @@
**_WARNING: all legacy services have been rewritten, this document may contain outdated information._**
# Tips for rewriting legacy services
## Background
The services are in the process of being rewritten to use our new service
framework ([#1358](https://github.com/badges/shields/issues/1358)).
Meanwhile, the legacy services extend from an abstract
adapter called [LegacyService][] which provides a place to put the
`camp.route()` invocation. The wrapper extends from [BaseService][], so it
supports badge examples via `category`, `examples`, and `route`. Setting `route`
also enables `createServiceTester()` to infer a service's base path, reducing
boilerplate for [creating the tester][creating a tester].
Legacy services look like:
```js
module.exports = class ExampleService extends LegacyService {
static category = 'build'
static registerLegacyRouteHandler({ camp, cache }) {
camp.route(
/^\/example\/([^\/]+)\/([^\/]+)\.(svg|png|gif|jpg|json)$/,
cache(function (data, match, sendBadge, request) {
var first = match[1]
var second = match[2]
var format = match[3]
var badgeData = getBadgeData('X' + first + 'X', data)
badgeData.text[1] = second
badgeData.colorscheme = 'blue'
badgeData.colorB = '#008bb8'
sendBadge(format, badgeData)
})
)
}
}
```
References:
- Current documentation
- [Defining a route][]
- [Defining examples][]
- [Creating a tester][]
- [BaseService, the new service framework][baseservice]
- [LegacyService, the adapter][legacyservice]
- [Obsolete tutorial on legacy services][old tutorial], possibly useful as a reference
[old tutorial]: https://github.com/badges/shields/blob/e25e748a03d4cbb50c60b69d2b2404fc08e7cead/doc/TUTORIAL.md
[defining a route]: https://github.com/badges/shields/blob/master/doc/TUTORIAL.md#42-our-first-badge
[defining examples]: https://github.com/badges/shields/blob/master/doc/TUTORIAL.md#44-adding-an-example-to-the-front-page
[creating a tester]: https://github.com/badges/shields/blob/master/doc/service-tests.md#1-boilerplate
[baseservice]: https://github.com/badges/shields/blob/master/services/base.js
[legacyservice]: https://github.com/badges/shields/blob/master/services/legacy-service.js
## First, write some tests
If service tests dont exist for the legacy service, stop and write them first.
Its recommended to PR these separately. If theres some test coverage, its
probably fine to move right ahead and add more in the process. Make sure the
tests are passing, though.
## Organization
1. When theres a single legacy service that handles lots of different things
(e.g. version, license, and downloads), it should be split into three separate
service classes and placed in three separate files, e.g.:
- `example-version.service.js`
- `example-license.service.js`
- `example-downloads.service.js`
2. When a badge offers different variants of basically the same thing, its okay
to put them in the same service class. For example, daily/weekly/monthly/total
downloads can go in one badge, and star rating vs point rating vs rating count
can go in one badge, and same with various kinds of detail about a pull request.
The hard limit (as of now anyway) is _one category per service class_.
3. If the tests havent been split up, split them up too and make sure they
still pass.
## Get the route working
1. Disable the legacy service by adding a `return` at the top of
`registerLegacyRouteHandler()`.
2. Set up the route for one of the badges. First determine if you can express
the route using a `pattern`. A `pattern` (e.g. `pattern: ':param1/:param2'`) is
the simplest way to declare the route, also the most readable, and will be
useful for displaying a badge builder with fields in the front end and
generating badge URLs programmatically.
3. When creating the initial route, you can stub out the service. A minimal
service extends BaseJsonService (or BaseService, or one of the others), and
defines `route()` and `handle()`. `defaultBadgeData` is optional but suggested:
```js
const BaseJsonService = require('../base-json')
class ExampleDownloads extends BaseJsonService {
static route = { base: 'example/d', pattern: ':param1/:param2' }
static defaultBadgeData() {
return { label: 'downloads' } // or whatever
}
async handle({ param1, param2 }) {
return { message: 'hello' }
}
}
```
4. We dont have really good tools for debugging matches, so the best you can do
is run a subset of your tests. To run a single service test, add `.only()`
somewhere in the chain, and run `npm run test:services:trace -- --only=example`.
```js
t.create('build status')
.get('/pip.json')
.only() // Prevent this ServiceTester from running its other tests.
.expectBadge(
label: 'docs',
message: Joi.alternatives().try(isBuildStatus, Joi.equal('unknown')),
)
```
5. Presumably the test will fail, though by examining the copious output, you
can confirm the route was matched and the named parameters mapped successfully.
Since you'll have just run the tests on the old code (right?) you'll know you
haven't inadvertently changed the route (an easy mistake to make).
6. If the legacy service had a base URL and you've changed it, youll need to
update the tests _and_ the examples. Take care to do that.
## Implement `render()` and `handle()`
Once the route is working, fill out `render()` and `handle()`.
1. If theres a single service, you can implement fetch as a method or a
function at the top of the file. If there are more than one service which share
fetching code, you can put the fetch function in `example-common.js` like this
one for github:
<details>
```js
const Joi = require('@hapi/joi')
const { errorMessagesFor } = require('./github-helpers')
const issueSchema = Joi.object({
head: Joi.object({
sha: Joi.string().required(),
}).required(),
}).required()
async function fetchIssue(serviceInstance, { user, repo, number }) {
return serviceInstance._requestJson({
schema: issueSchema,
url: `/repos/${user}/${repo}/pulls/${number}`,
errorMessages: errorMessagesFor('pull request or repo not found'),
})
}
module.exports = {
fetchIssue,
}
```
</details>
or create an abstract superclass like **PypiBase**:
<details>
```js
const Joi = require('@hapi/joi')
const BaseJsonService = require('../base-json')
const schema = Joi.object({
info: Joi.object({
...
}).required()
}).required()
module.exports = class PypiBase extends BaseJsonService {
static buildRoute(base) {
return {
base,
pattern: ':egg*',
}
}
async fetch({ egg }) {
return this._requestJson({
schema,
url: `https://pypi.org/pypi/${egg}/json`,
errorMessages: { 404: 'package or version not found' },
})
}
}
```
</details>
2. Validation should be handled using Joi. Save this for last. While you're
getting things working, you can use `const schema = Joi.any()`, which matches
anything.
3. Substitution of default values should also be handled by Joi, using
`.default()`.
4. To keep with the design pattern of `render()`, formatting concerns, including
concatenation and color computation, should be dealt with inside `render()`.
This helps avoid static examples falling out of sync with the implementation.
## Error handling
BaseService includes built-in runtime error handling. Error classes are defined
in `services/errors.js`. Request code and validation code will throw a runtime
error, which will then bubble up to BaseService, which then renders an error
badge. The cases covered by built-in error handling need not be tested in each
service, and existing tests should be removed.
1. If an external server can't be reached or returns a 5xx status code,
`_requestJson()` along with code in `lib/error-helper.js` will bubble up an
**Inaccessible** error.
2. If a response does not match the schema, `validate()` will bubble up an
**InvalidResponse** error which will display **invalid response data**.
Error handling can also be customized by the service. Alternate messages
corresponding to HTTP status codes can be specified in the `errorMessages`
parameter to `_requestJson()` etc.
For the not found case, a service test should establish that the API is doing
what we expect. If the API returns a 404 error, code in `lib/error-helper.js`
will automatically throw a **NotFound** error. The error message can, and
generally should be customized to display something more specific like
**package not found** or **room not found**.
Not all services return a 404 response in the not found case. Sometimes a
different status code is returned.
Sometimes a 200 response must be examined to distinguish the not found case from a success case. This can be handled in either of two ways:
- Write a schema which accommodates both the success and error cases.
- Write the schema for the success case. Pass `schema: Joi.any()` to
`_requestJson()`. Manually check for the error case, then invoke
`_validate()` with the success-case schema.
In either case, the service should throw e.g
`new NotFound({ prettyMessage: 'package not found' })`.
## Convert the examples
1. Convert all the examples to `pattern`, `namedParams`, and `staticExample`. In some cases you can use the `pattern` inherited from `route`, though in other cases you may need to specify a pattern in the example. For example, when showing download badges for several periods, you may want to render the example with an explicit `dt` instead of `:which`. You will also need to specify a pattern for badges that use a `format` regex in the route.
2. Open the frontend and check that the static preview badges look good.
Remember, none of them are live.
3. Open up the prepared example URLs in their own tabs, and make sure they work correctly.
## Validation
When it's time to add the schema, refer to the Joi API docs:
https://github.com/hapijs/joi/blob/master/API.md
## Housekeeping
Switch to `createServiceTester`:
```js
const t = (module.exports = require('../tester').createServiceTester())
```
This may require updating the URLs, which will be relative to the service's base
URL. When using `createServiceTester`, services need to be specified using
the non-case-sensitive service class name, or a leading substring (e.g.
`AppveyorTests` or `appveyor`).
Do this last. Since it involves changing test URLs, and you don't want to
accidentally change them.

View File

@@ -1,8 +1,6 @@
# Hosting your own Shields server
This document describes how to host your own shields server either from source or using a docker image. See the docs on [releases](https://github.com/badges/shields/blob/master/doc/releases.md#shields-server) for info on how we version the server and how to choose a release.
## Installing from Source
## Installation
You will need Node 12 or later, which you can install using a
[package manager][].
@@ -16,19 +14,18 @@ curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash -; sudo apt-get in
```sh
git clone https://github.com/badges/shields.git
cd shields
git checkout $(git tag | grep server | tail -n 1) # checkout the latest tag
npm ci # You may need sudo for this.
```
[package manager]: https://nodejs.org/en/download/package-manager/
### Build the frontend
## Build the frontend
```sh
npm run build
```
### Start the server
## Start the server
```sh
sudo node server
@@ -47,7 +44,7 @@ The root gets redirected to https://shields.io.
For testing purposes, you can go to `http://localhost/`.
### Deploying to Heroku
## Heroku
Once you have installed the [Heroku CLI][]
@@ -60,32 +57,9 @@ heroku open
[heroku cli]: https://devcenter.heroku.com/articles/heroku-cli
### Deploying to Zeit Vercel
To deploy using Zeit Vercel:
```console
npm run build # Not sure why, but this needs to be run before deploying.
vercel
```
## Docker
### DockerHub
We publish images to DockerHub at https://registry.hub.docker.com/r/shieldsio/shields
The `next` tag is the latest build from `master`, or tagged releases are available
https://registry.hub.docker.com/r/shieldsio/shields/tags
```console
$ docker pull shieldsio/shields:next
$ docker run shieldsio/shields:next
```
### Building Docker Image Locally
Alternatively, you can build and run the server locally using Docker. First build an image:
You can build and run the server locally using Docker. First build an image:
```console
$ docker build -t shields .
@@ -140,6 +114,15 @@ preconfigured raster server.
[raster server]: https://github.com/badges/svg-to-image-proxy
[micro]: https://github.com/zeit/micro
## Zeit Now
To deploy using Zeit Now:
```console
npm run build # Not sure why, but this needs to be run before deploying.
now
```
## Persistence
To enable Redis-backed GitHub token persistence, point `REDIS_URL` to your
@@ -201,30 +184,19 @@ Start the server using the Sentry DSN. You can set it:
sudo SENTRY_DSN=https://xxx:yyy@sentry.io/zzz node server
```
Or via config as you would do with [server secrets](server-secrets.md):
- or by `sentry_dsn` secret property defined in `private/secret.json`
```yml
private:
sentry_dsn: ...
```
```sh
sudo node server
```
## Prometheus
### Prometheus
Shields uses [prom-client](https://github.com/siimon/prom-client) to provide [default metrics](https://prometheus.io/docs/instrumenting/writing_clientlibs/#standard-and-runtime-collectors). These metrics are disabled by default.
You can enable them by `METRICS_PROMETHEUS_ENABLED` and `METRICS_PROMETHEUS_ENDPOINT_ENABLED` environment variables.
You can enable them by `METRICS_PROMETHEUS_ENABLED` environment variable.
```bash
METRICS_PROMETHEUS_ENABLED=true METRICS_PROMETHEUS_ENDPOINT_ENABLED=true npm start
METRICS_PROMETHEUS_ENABLED=true npm start
```
Metrics are available at `/metrics` resource.
## Cloudflare
Shields.io uses Cloudflare as a downstream CDN. If your installation does the same,
you can configure your server to only accept requests coming from Cloudflare's IPs.
Set `public.requireCloudflare: true`.

View File

@@ -80,6 +80,14 @@ An Azure DevOps Token (PAT) is required for accessing [private Azure DevOps proj
[ado personal access tokens]: https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=vsts#create-personal-access-tokens-to-authenticate-access
[ado token scopes]: https://docs.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/oauth?view=vsts#scopes
### Bintray
- `BINTRAY_USER` (yml: `private.bintray_user`)
- `BINTRAY_API_KEY` (yml: `private.bintray_apikey`)
The bintray API [requires authentication](https://bintray.com/docs/api/#_authentication)
Create an account and obtain a token from the user profile page.
### Bitbucket (Cloud)
- `BITBUCKET_USER` (yml: `private.bitbucket_username`)

View File

@@ -66,7 +66,7 @@ t.create('Build status')
- Note that when we call our badge, we are allowing it to communicate with an external service without mocking the response. We write tests which interact with external services, which is unusual practice in unit testing. We do this because one of the purposes of service tests is to notify us if a badge has broken due to an upstream API change. For this reason it is important for at least one test to call the live API without mocking the interaction.
- All badges on shields can be requested in a number of formats. As well as calling https://img.shields.io/wercker/build/wercker/go-wercker-api.svg to generate ![](https://img.shields.io/wercker/build/wercker/go-wercker-api.svg) we can also call https://img.shields.io/wercker/build/wercker/go-wercker-api.json to request the same content as JSON. When writing service tests, we request the badge in JSON format so it is easier to make assertions about the content.
- We don't need to explicitly call `/wercker/build/wercker/go-wercker-api.json` here, only `/build/wercker/go-wercker-api.json`. When we create a tester object with `createServiceTester()` the URL base defined in our service class (in this case `/wercker`) is used as the base URL for any requests made by the tester object.
3. `expectBadge()` is a helper function which accepts either a string literal, a [RegExp][] or a [Joi][] schema for the different fields.
3. `expectBadge()` is a helper function which accepts either a string literal or a [Joi][] schema for the different fields.
Joi is a validation library that is build into IcedFrisby which you can use to
match based on a set of allowed strings, regexes, or specific values. You can
refer to their [API reference][joi api].
@@ -82,7 +82,6 @@ harness will call it for you.
[icedfrisby api]: https://github.com/MarkHerhold/IcedFrisby/blob/master/API.md
[joi]: https://github.com/hapijs/joi
[joi api]: https://github.com/hapijs/joi/blob/master/API.md
[regexp]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp
### (3) Running the Tests
@@ -260,9 +259,21 @@ npm run coverage:report:open
## Pull requests
Pull requests must follow the [documented conventions][pr-conventions] in order to execute the correct set of service tests.
The affected service ids should be included in square brackets in the pull request
title. That way, Circle CI will run those service tests. When a pull request
affects multiple services, they should be separated with spaces. The test
runner is case-insensitive, so they should be capitalized for readability.
[pr-conventions]: https://github.com/badges/shields/blob/master/CONTRIBUTING.md#running-service-tests-in-pull-requests
For example:
- [Travis] Fix timeout issues
- [Travis Sonar] Support user token authentication
- Add tests for [CRAN] and [CPAN]
In the rare case when it's necessary to see the output of a full service-test
run in a PR, include `[*****]` in the title. Unless all the tests pass, the build
will fail, so likely it will be necessary to remove it and re-run the tests
before merging.
## Getting help

50
doc/users.md Normal file
View File

@@ -0,0 +1,50 @@
# Notable Projects Using Shields
- https://github.com/AFNetworking/AFNetworking
- https://github.com/angular/angular.js
- https://github.com/ansible/ansible
- https://github.com/apple/swift
- https://github.com/atom/atom
- https://github.com/babel/babel
- https://github.com/bevacqua/dragula
- https://github.com/bower/bower
- https://github.com/chartjs/Chart.js
- https://github.com/creationix/nvm
- https://github.com/discourse/discourse
- https://github.com/docker/docker
- https://github.com/electron/electron
- https://github.com/elm-lang/core
- https://github.com/emberjs/ember.js
- https://github.com/expressjs/express
- https://github.com/facebook/react
- https://github.com/FortAwesome/Font-Awesome
- https://github.com/gitlabhq/gitlabhq
- https://github.com/gulpjs/gulp
- https://github.com/h5bp/html5-boilerplate
- https://github.com/jakubroztocil/httpie
- https://github.com/jekyll/jekyll
- https://github.com/kennethreitz/requests
- https://github.com/kubernetes/kubernetes
- https://github.com/laravel/laravel
- https://github.com/less/less.js
- https://github.com/Microsoft/TypeScript
- https://github.com/Microsoft/vscode
- https://github.com/mitchellh/vagrant
- https://github.com/Modernizr/Modernizr
- https://github.com/moment/moment
- https://github.com/mrdoob/three.js
- https://github.com/necolas/normalize.css
- https://github.com/nodejs/node
- https://github.com/plataformatec/devise
- https://github.com/postcss/postcss
- https://github.com/rails/rails
- https://github.com/reactjs/redux
- https://github.com/socketio/socket.io
- https://github.com/tensorflow/tensorflow
- https://github.com/TryGhost/Ghost
- https://github.com/twbs/bootstrap
- https://github.com/videojs/video.js
- https://github.com/vuejs/vue
- https://github.com/webpack/webpack
- https://github.com/yarnpkg/yarn
- https://github.com/zurb/foundation-sites

View File

@@ -11,6 +11,13 @@ import {
CopiedContentIndicatorHandle,
} from './copied-content-indicator'
function getBaseUrlFromWindowLocation(): string {
// Default to the current hostname for when there is no `BASE_URL` set
// at build time (as in most PaaS deploys).
const { protocol, hostname } = window.location
return `${protocol}//${hostname}`
}
export default function Customizer({
baseUrl,
title,
@@ -32,7 +39,9 @@ export default function Customizer({
}): JSX.Element {
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/35572
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/28884#issuecomment-471341041
const indicatorRef = useRef<CopiedContentIndicatorHandle>() as React.MutableRefObject<CopiedContentIndicatorHandle>
const indicatorRef = useRef<
CopiedContentIndicatorHandle
>() as React.MutableRefObject<CopiedContentIndicatorHandle>
const [path, setPath] = useState('')
const [queryString, setQueryString] = useState<string>()
const [pathIsComplete, setPathIsComplete] = useState<boolean>()
@@ -41,7 +50,7 @@ export default function Customizer({
function generateBuiltBadgeUrl(): string {
const suffix = queryString ? `?${queryString}` : ''
return `${baseUrl}${path}${suffix}`
return `${baseUrl || getBaseUrlFromWindowLocation()}${path}${suffix}`
}
function renderLivePreview(): JSX.Element {

View File

@@ -91,15 +91,15 @@ export function constructPath({
if (typeof token === 'string') {
return token.trim()
} else {
const { prefix, name, modifier } = token
const { delimiter, name, optional } = token
const value = namedParams[name]
if (value) {
return `${prefix}${value.trim()}`
} else if (modifier === '?' || modifier === '*') {
return `${delimiter}${value.trim()}`
} else if (optional) {
return ''
} else {
isComplete = false
return `${prefix}:${name}`
return `${delimiter}:${name}`
}
}
})
@@ -221,15 +221,14 @@ export default function PathBuilder({
tokenIndex: number,
namedParamIndex: number
): JSX.Element {
const { prefix, modifier } = token
const optional = modifier === '?' || modifier === '*'
const { delimiter, optional } = token
const name = `${token.name}`
const exampleValue = exampleParams[name] || '(not set)'
return (
<React.Fragment key={token.name}>
{renderLiteral(prefix, tokenIndex, false)}
{renderLiteral(delimiter, tokenIndex, false)}
<PathBuilderColumn pathContainsOnlyLiterals={false} withHorizPadding>
<NamedParamLabelContainer>
<BuilderLabel htmlFor={name}>{humanizeString(name)}</BuilderLabel>

View File

@@ -241,21 +241,15 @@ export default function QueryStringBuilder({
const [queryParams, setQueryParams] = useState(() =>
// For each of the custom query params defined in `exampleParams`,
// create empty values in `queryParams`.
Object.entries(exampleParams)
.filter(
// If the example defines a value for one of the standard supported
// options, do not duplicate the corresponding parameter.
([name]) => !supportedBadgeOptions.some(option => name === option.name)
)
.reduce((accum, [name, value]) => {
// Custom query params are either string or boolean. Inspect the example
// value to infer which one, and set empty values accordingly.
// Throughout the component, these two types are supported in the same
// manner: by inspecting this value type.
const isStringParam = typeof value === 'string'
accum[name] = isStringParam ? '' : true
return accum
}, {} as { [k: string]: string | boolean })
Object.entries(exampleParams).reduce((accum, [name, value]) => {
// Custom query params are either string or boolean. Inspect the example
// value to infer which one, and set empty values accordingly.
// Throughout the component, these two types are supported in the same
// manner: by inspecting this value type.
const isStringParam = typeof value === 'string'
accum[name] = isStringParam ? '' : true
return accum
}, {} as { [k: string]: string | boolean })
)
// For each of the standard badge options, create empty values in
// `badgeOptions`. When `initialStyle` has been provided, use it.

View File

@@ -1,7 +1,7 @@
import React from 'react'
import styled from 'styled-components'
import { staticBadgeUrl } from '../../../core/badge-urls/make-badge-url'
import { getBaseUrl } from '../../constants'
import { baseUrl } from '../../constants'
import { shieldsLogos, simpleIcons } from '../../lib/supported-features'
import Meta from '../meta'
import Header from '../header'
@@ -19,7 +19,6 @@ const StyledTable = styled.table`
`
function NamedLogoTable({ logoNames }: { logoNames: string[] }): JSX.Element {
const baseUrl = getBaseUrl()
return (
<StyledTable>
<thead>

View File

@@ -1,11 +1,10 @@
import React, { Fragment } from 'react'
import styled from 'styled-components'
// FIXME: is this needed?
// @ts-ingnore
import { staticBadgeUrl } from '../../../core/badge-urls/make-badge-url'
import { getBaseUrl } from '../../constants'
import { baseUrl } from '../../constants'
import Meta from '../meta'
// ts-expect-error: because reasons?
// @ts-ignore
import Header from '../header'
import { H3, Badge } from '../common'
@@ -63,6 +62,11 @@ function Badges({
)
}
interface StyleExamples {
title: string
badges: BadgeData[]
}
const examples = [
{
title: 'Basic examples',
@@ -119,14 +123,13 @@ const examples = [
]
function StyleTable({ style }: { style: string }): JSX.Element {
const baseUrl = getBaseUrl()
return (
<StyledTable>
<thead>
<tr>
<td>Description</td>
<td>Badges (new)</td>
<td>Badges (img.shields.io)</td>
<td>Badges (old)</td>
</tr>
</thead>
<tbody>

View File

@@ -11,7 +11,7 @@ import {
RenderableExample,
} from '../lib/service-definitions'
import ServiceDefinitionSetHelper from '../lib/service-definitions/service-definition-set-helper'
import { getBaseUrl } from '../constants'
import { baseUrl } from '../constants'
import Meta from './meta'
import Header from './header'
import SuggestionAndSearch from './suggestion-and-search'
@@ -54,7 +54,6 @@ export default function Main({
setSelectedExampleIsSuggestion,
] = useState(false)
const searchTimeout = useRef(0)
const baseUrl = getBaseUrl()
function performSearch(query: string): void {
setSearchIsInProgress(false)

View File

@@ -1,10 +1,7 @@
import React from 'react'
import { Helmet } from 'react-helmet'
// eslint-disable-next-line
// @ts-ignore
import favicon from '../images/favicon.png'
import '@fontsource/lato'
import '@fontsource/lekton'
const description = `We serve fast and scalable informational images as badges
for GitHub, Travis CI, Jenkins, WordPress and many more services. Use them to
@@ -20,6 +17,10 @@ export default function Meta(): JSX.Element {
<meta content="width=device-width,initial-scale=1" name="viewport" />
<meta content={description} name="description" />
<link href={favicon} rel="icon" type="image/png" />
<link
href="https://fonts.googleapis.com/css?family=Lato|Lekton"
rel="stylesheet"
/>
</Helmet>
)
}

Some files were not shown because too many files have changed in this diff Show More