diff --git a/.nycrc.json b/.c8rc.json similarity index 87% rename from .nycrc.json rename to .c8rc.json index 5e00c6d1746e1..9413073fbf999 100644 --- a/.nycrc.json +++ b/.c8rc.json @@ -9,10 +9,8 @@ "**/test-helpers.js", "**/*-test-helpers.js", "**/*-fixtures.js", - "**/mocha-*.js", "**/*.test-d.ts", "dangerfile.js", - "gatsby-*.js", "core/service-test-runner", "core/got-test-client.js", "services/**/*.tester.js", @@ -21,8 +19,10 @@ "core/base-service/loader-test-fixtures", "scripts", "coverage", - "build", ".github", - "**/public/" + "**/public/", + "cypress", + "frontend", + "migrations" ] } diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 0c4a2f6f8683b..0000000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,359 +0,0 @@ -version: 2 - -main_steps: &main_steps - steps: - - checkout - - - run: - name: Install dependencies - command: npm ci - environment: - # https://docs.cypress.io/guides/getting-started/installing-cypress.html#Skipping-installation - # We don't need to install the Cypress binary in jobs that aren't actually running Cypress. - CYPRESS_INSTALL_BINARY: 0 - - - run: - name: Linter - when: always - command: npm run lint - - - run: - name: Core tests - when: always - environment: - mocha_reporter: mocha-junit-reporter - MOCHA_FILE: junit/core/results.xml - command: npm run test:core - - - run: - name: Entrypoint tests - when: always - environment: - mocha_reporter: mocha-junit-reporter - MOCHA_FILE: junit/entrypoint/results.xml - command: npm run test:entrypoint - - - store_test_results: - path: junit - - - run: - name: 'Prettier check (quick fix: `npm run prettier`)' - when: always - command: npm run prettier:check - -integration_steps: &integration_steps - steps: - - checkout - - - run: - name: Install dependencies - command: npm ci - environment: - CYPRESS_INSTALL_BINARY: 0 - - - run: - name: Integration tests - when: always - environment: - mocha_reporter: mocha-junit-reporter - MOCHA_FILE: junit/integration/results.xml - command: npm run test:integration - - - store_test_results: - path: junit - -services_steps: &services_steps - steps: - - checkout - - - run: - name: Install dependencies - command: npm ci - environment: - CYPRESS_INSTALL_BINARY: 0 - - - run: - name: Identify services tagged in the PR title - command: npm run test:services:pr:prepare - - - run: - name: Run tests for tagged services - environment: - mocha_reporter: mocha-junit-reporter - MOCHA_FILE: junit/services/results.xml - command: RETRY_COUNT=3 npm run test:services:pr:run - - - store_test_results: - path: junit - -package_steps: &package_steps - steps: - - checkout - - - run: - name: Install node and npm - command: | - set +e - export NVM_DIR="/opt/circleci/.nvm" - [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" - nvm install v14 - nvm use v14 - npm install -g npm - - # Run the package tests on each currently supported node version. See: - # https://github.com/badges/shields/blob/master/badge-maker/README.md#node-version-support - # https://nodejs.org/en/about/releases/ - - - run: - 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: - 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 - - - run: - environment: - mocha_reporter: mocha-junit-reporter - MOCHA_FILE: junit/badge-maker/v16/results.xml - NODE_VERSION: v16 - CYPRESS_INSTALL_BINARY: 0 - name: Run package tests on Node 16 - command: scripts/run_package_tests.sh - - - store_test_results: - path: junit - -jobs: - main: - docker: - - image: circleci/node:14 - - <<: *main_steps - - main@node-16: - docker: - - image: circleci/node:16 - - <<: *main_steps - - integration: - docker: - - image: circleci/node:14 - - image: redis - - <<: *integration_steps - - integration@node-16: - docker: - - image: circleci/node:16 - - image: redis - - <<: *integration_steps - - danger: - docker: - - image: circleci/node:14 - steps: - - checkout - - - run: - name: Install dependencies - command: npm ci - environment: - CYPRESS_INSTALL_BINARY: 0 - - - run: - name: Danger - when: always - environment: - # https://github.com/gatsbyjs/gatsby/pull/11555 - NODE_ENV: test - command: npm run danger ci - - frontend: - docker: - - image: circleci/node:14 - steps: - - checkout - - - run: - name: Install dependencies - command: npm ci - environment: - CYPRESS_INSTALL_BINARY: 0 - - - run: - name: Prepare frontend tests - command: npm run defs && npm run features - - - run: - name: Check types - command: npm run check-types:frontend - - - run: - name: Frontend unit tests - environment: - mocha_reporter: mocha-junit-reporter - MOCHA_FILE: junit/frontend/results.xml - when: always - command: npm run test:frontend - - - store_test_results: - path: junit - - - run: - name: Frontend build completes successfully - when: always - command: npm run build - - package: - machine: true - - <<: *package_steps - - services: - docker: - - image: circleci/node:14 - - <<: *services_steps - - services@node-16: - docker: - - image: circleci/node:16 - - <<: *services_steps - - e2e: - docker: - - image: cypress/base:14.16.0 - steps: - - checkout - - - restore_cache: - name: Restore Cypress binary - keys: - - v2-cypress-dependencies-{{ checksum "package-lock.json" }} - - - run: - name: Install dependencies - command: npm ci - - - run: - name: Frontend build - command: GATSBY_BASE_URL=http://localhost:8080 npm run build - - - run: - name: Run tests - environment: - CYPRESS_REPORTER: junit - MOCHA_FILE: junit/e2e/results.xml - command: npm run e2e-on-build - - - store_test_results: - path: junit - - - store_artifacts: - path: cypress/videos - - - store_artifacts: - path: cypress/screenshots - - - save_cache: - name: Cache Cypress binary - paths: - # https://docs.cypress.io/guides/getting-started/installing-cypress.html#Binary-cache - - ~/.cache/Cypress - key: v2-cypress-dependencies-{{ checksum "package-lock.json" }} - -workflows: - version: 2 - - on-commit: - jobs: - - main: - filters: - branches: - ignore: gh-pages - - main@node-16: - filters: - branches: - ignore: gh-pages - - integration@node-16: - filters: - branches: - ignore: gh-pages - - frontend: - filters: - branches: - ignore: gh-pages - - package: - filters: - branches: - ignore: gh-pages - - services: - filters: - branches: - ignore: - - master - - gh-pages - - services@node-16: - filters: - branches: - ignore: - - master - - gh-pages - - danger: - filters: - branches: - ignore: - - master - - gh-pages - - /dependabot\/.*/ - - e2e: - filters: - branches: - ignore: gh-pages - # on-commit-with-cache: - # jobs: - # - npm-install: - # filters: - # branches: - # ignore: gh-pages - # - main: - # requires: - # - npm-install - # - main@node-latest: - # requires: - # - npm-install - # - frontend: - # requires: - # - npm-install - # - services: - # requires: - # - npm-install - # filters: - # branches: - # ignore: master - # - services@node-latest: - # requires: - # - npm-install - # filters: - # branches: - # ignore: master - # - danger: - # requires: - # - npm-install - # filters: - # branches: - # ignore: /dependabot\/.*/ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000000..1df72d64c0b4e --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,14 @@ +{ + "name": "Node.js", + // "Officially" maintained node devcontainer image from Microsoft + // https://github.com/devcontainers/templates/tree/main/src/javascript-node + "image": "mcr.microsoft.com/devcontainers/javascript-node:1-22-bookworm", + + // Features to add to the dev container. More info: https://containers.dev/features. + "features": { + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "npm ci" +} diff --git a/.dockerignore b/.dockerignore index 17a2a01a4ba70..02e23cdd067a6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,12 @@ node_modules/ -shields.env .git/ .gitignore +.github .vscode/ +fly.toml + +*.md +doc/ # Improve layer cacheability. Dockerfile diff --git a/.editorconfig b/.editorconfig index 27a65a3586c47..4a8c4be5758ae 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,3 +1,5 @@ +# The formatting for most files is handled by Prettier nowadays. +# See https://github.com/badges/shields/blob/master/CONTRIBUTING.md#prettier for more information. root = true @@ -6,7 +8,3 @@ end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true indent_style = space - -[*.{js,json,html,css}] -charset = utf-8 -indent_size = 2 diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 170a29b892244..0000000000000 --- a/.eslintignore +++ /dev/null @@ -1,7 +0,0 @@ -/api-docs/ -/build -/coverage -/__snapshots__ -public -badge-maker/node_modules/ -!.github/ diff --git a/.eslintrc.yml b/.eslintrc.yml deleted file mode 100644 index 0614f9a0134af..0000000000000 --- a/.eslintrc.yml +++ /dev/null @@ -1,198 +0,0 @@ -extends: - - standard - - standard-jsx - - standard-react - - plugin:@typescript-eslint/recommended - - prettier - - 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. - sourceType: 'script' - -settings: - react: - version: '16.8' - jsdoc: - mode: jsdoc - -plugins: - - chai-friendly - - jsdoc - - mocha - - no-extension-in-require - - sort-class-members - - import - - react-hooks - - promise - -overrides: - # For simplicity's sake, when possible prefer to add rules to the top-level - # list of rules, even if they only apply to certain files. That way the - # rules listed here are only ones which conflict. - - - files: - - '**/*.js' - - '!frontend/**/*.js' - env: - node: true - es6: true - rules: - no-console: 'off' - '@typescript-eslint/explicit-module-boundary-types': 'off' - - - files: - - '**/*.@(ts|tsx)' - parserOptions: - sourceType: 'module' - parser: '@typescript-eslint/parser' - rules: - # Argh. - '@typescript-eslint/explicit-function-return-type': - ['error', { 'allowExpressions': true }] - '@typescript-eslint/no-empty-function': 'error' - '@typescript-eslint/no-var-requires': 'error' - '@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 - parserOptions: - sourceType: 'module' - parser: '@typescript-eslint/parser' - - files: - - gatsby-browser.js - - 'frontend/**/*.@(js|ts|tsx)' - parserOptions: - sourceType: 'module' - env: - browser: true - rules: - import/extensions: - ['error', 'never', { 'json': 'always', 'yml': 'always' }] - - - files: - - 'core/base-service/**/*.js' - - 'services/**/*.js' - rules: - sort-class-members/sort-class-members: - [ - 'error', - { - order: - [ - 'name', - 'category', - 'isDeprecated', - 'route', - 'auth', - 'examples', - '_cacheLength', - 'defaultBadgeData', - 'render', - 'constructor', - 'fetch', - 'transform', - 'handle', - ], - }, - ] - - - files: - - '**/*.spec.@(js|ts|tsx)' - - '**/*.integration.js' - - '**/test-helpers.js' - - 'core/service-test-runner/**/*.js' - env: - mocha: true - rules: - mocha/no-exclusive-tests: 'error' - mocha/no-mocha-arrows: 'error' - mocha/prefer-arrow-callback: 'error' - -rules: - # Disable some rules from eslint:recommended. - no-empty: ['error', { 'allowEmptyCatch': true }] - - # Allow unused parameters. In callbacks, removing them seems to obscure - # what the functions are doing. - '@typescript-eslint/no-unused-vars': ['error', { 'args': 'none' }] - no-unused-vars: 'off' - - '@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' - - # Shields additions. - no-var: 'error' - prefer-const: 'error' - arrow-body-style: ['error', 'as-needed'] - no-extension-in-require/main: 'error' - object-shorthand: ['error', 'properties'] - prefer-template: 'error' - promise/prefer-await-to-then: 'error' - func-style: ['error', 'declaration', { 'allowArrowFunctions': true }] - 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' - - # jsdoc plugin: - # don't require every class/function to have a docblock - jsdoc/require-jsdoc: 'off' - - # allow Joi as an undefined type - jsdoc/no-undefined-types: ['error', { definedTypes: ['Joi'] }] - - # all the other recommended rules as errors (not warnings) - jsdoc/check-alignment: 'error' - jsdoc/check-param-names: 'error' - jsdoc/check-tag-names: 'error' - jsdoc/check-types: 'error' - jsdoc/implements-on-classes: 'error' - jsdoc/newline-after-description: 'error' - jsdoc/require-param: 'error' - jsdoc/require-param-description: 'error' - jsdoc/require-param-name: 'error' - jsdoc/require-param-type: 'error' - jsdoc/require-returns: 'error' - jsdoc/require-returns-check: 'error' - jsdoc/require-returns-description: 'error' - jsdoc/require-returns-type: 'error' - jsdoc/valid-types: 'error' - - # Disable some from TypeScript. - '@typescript-eslint/camelcase': off - '@typescript-eslint/explicit-function-return-type': 'off' - '@typescript-eslint/no-empty-function': 'off' - - react/jsx-sort-props: 'error' - react-hooks/rules-of-hooks: 'error' - react-hooks/exhaustive-deps: 'error' - jsx-quotes: ['error', 'prefer-double'] diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000..6313b56c57848 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/ISSUE_TEMPLATE/1_Bug_report.yml b/.github/ISSUE_TEMPLATE/1_Bug_report.yml index b2f126dd9a1fe..417bc782df20b 100644 --- a/.github/ISSUE_TEMPLATE/1_Bug_report.yml +++ b/.github/ISSUE_TEMPLATE/1_Bug_report.yml @@ -41,4 +41,4 @@ body: attributes: value: | ## :heart: Love Shields? - Please consider donating $10 to sustain our activities: [https://opencollective.com/shields](https://opencollective.com/shields) + Please consider donating to sustain our activities: [https://opencollective.com/shields](https://opencollective.com/shields) diff --git a/.github/ISSUE_TEMPLATE/2_Failing_service_test.md b/.github/ISSUE_TEMPLATE/2_Failing_service_test.md index 4de1d140813c6..57919ee852193 100644 --- a/.github/ISSUE_TEMPLATE/2_Failing_service_test.md +++ b/.github/ISSUE_TEMPLATE/2_Failing_service_test.md @@ -18,19 +18,15 @@ labels: 'keep-service-tests-green' -:link: **CircleCI link** - - - -:beetle: **Stack trace** +:lady_beetle: **Stack trace** ``` - + ``` :bulb: **Possible solution** - diff --git a/.github/ISSUE_TEMPLATE/3_Badge_request.md b/.github/ISSUE_TEMPLATE/3_Badge_request.md deleted file mode 100644 index 37936abaa6921..0000000000000 --- a/.github/ISSUE_TEMPLATE/3_Badge_request.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -name: 💡 Badge Request -about: Ideas for new badges -labels: 'service-badge' ---- - -:clipboard: **Description** - - - -:link: **Data** - - - -:microphone: **Motivation** - - - - diff --git a/.github/ISSUE_TEMPLATE/3_Badge_request.yml b/.github/ISSUE_TEMPLATE/3_Badge_request.yml new file mode 100644 index 0000000000000..1f6cc81bc0924 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/3_Badge_request.yml @@ -0,0 +1,58 @@ +name: '💡 Badge Request' +description: Ideas for new badges +labels: ['service-badge'] +body: + - type: markdown + attributes: + value: > + ## Ideas for new badges + + + This issue template is for suggesting new badges which **fetch and display data from an upstream service**. If your suggestion is for a static badge (which shows the same information every time it is requested), it is [already possible to make these](https://shields.io/docs/static-badges). We don't add specific routes for badges which only show static information. + + + - type: textarea + id: description + attributes: + label: '📋 Description' + description: | + A clear and concise description of the new badge. + + - Which service is this badge for e.g: GitHub, Travis CI + - What sort of information should this badge show? + Provide an example in plain text e.g: "version | v1.01" or as a static badge + (static badge generator can be found at https://shields.io/badges/static-badge ) + validations: + required: true + + - type: textarea + id: data + attributes: + label: '🔗 Data' + description: | + Where can we get the data from? + + Please consider and cover details like: + - Is there a public API? + - Does the API require authentication or an API key? + If so, please review our documentation on [Badges Requiring Authentication](https://github.com/badges/shields/blob/master/doc/authentication.md) + - Link to the API documentation. + validations: + required: true + + - type: textarea + id: motivation + attributes: + label: '🎤 Motivation' + description: | + Please explain why this feature should be implemented and how it would be used. + + - What is the specific use case? + validations: + required: true + + - type: markdown + attributes: + value: | + ## :heart: Love Shields? + Please consider donating to sustain our activities: [https://opencollective.com/shields](https://opencollective.com/shields) diff --git a/.github/ISSUE_TEMPLATE/4_Feature_request.md b/.github/ISSUE_TEMPLATE/4_Feature_request.md index 64f5f734b59a6..d285358ebf665 100644 --- a/.github/ISSUE_TEMPLATE/4_Feature_request.md +++ b/.github/ISSUE_TEMPLATE/4_Feature_request.md @@ -7,5 +7,5 @@ about: Ideas for other new features or improvements - diff --git a/.github/actions/close-bot/action.yml b/.github/actions/close-bot/action.yml deleted file mode 100644 index 0c74e5eaa750b..0000000000000 --- a/.github/actions/close-bot/action.yml +++ /dev/null @@ -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' diff --git a/.github/actions/close-bot/helpers.js b/.github/actions/close-bot/helpers.js deleted file mode 100644 index 13daf44ccb406..0000000000000 --- a/.github/actions/close-bot/helpers.js +++ /dev/null @@ -1,62 +0,0 @@ -'use strict' - -function findChangelogStart(lines) { - for (let i = 0; i < lines.length; i++) { - const line = lines[i] - if ( - line === 'Changelog' && - lines[i + 2] === '
' - ) { - return i + 3 - } - } - return null -} - -function findChangelogEnd(lines, start) { - for (let i = start; i < lines.length; i++) { - const line = lines[i] - if (line === '
') { - 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 isPointlessVersionBump(body) { - const pointlessBumpLinks = [ - 'https://github.com/gatsbyjs/gatsby', - 'https://github.com/typescript-eslint/typescript-eslint', - ] - - const lines = body.split(/\r?\n/) - if (!pointlessBumpLinks.some(link => lines[0].includes(link))) { - 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(' !line.startsWith('

All notable changes')) - .filter( - line => !line.startsWith('See ') - ) - .filter(line => !line.startsWith(' x [#9496](https://github.com/badges/shields/issues/9496) +- [bundlejs] add badge for the npm package size [#9055](https://github.com/badges/shields/issues/9055) +- Switch [OpenCollective] badges to use GraphQL and auth [#9387](https://github.com/badges/shields/issues/9387) +- [Pulsar] Add Pulsar Badges for Stargazers & Downloads [#8767](https://github.com/badges/shields/issues/8767) +- Add [CurseForge] badges [#9252](https://github.com/badges/shields/issues/9252) +- deploy on node 18 [#9385](https://github.com/badges/shields/issues/9385) +- allow calling [github] without auth [#9427](https://github.com/badges/shields/issues/9427) +- Dependency updates + +## server-2023-08-01 + +- Convert `examples` arrays to `openApi` objects (part 1) [#9320](https://github.com/badges/shields/issues/9320) +- Migrate from docs.rs' builds API to status API [#9422](https://github.com/badges/shields/issues/9422) +- [OpenVSX] Fix OpenVSX API call for unversioned package URLs [#9408](https://github.com/badges/shields/issues/9408) +- Add support for [Lemmy] [#9368](https://github.com/badges/shields/issues/9368) +- upgrade to npm 9 [#9323](https://github.com/badges/shields/issues/9323) +- Go back to default YouTube cache [#9372](https://github.com/badges/shields/issues/9372) +- Add [GitHubDiscussionsSearch] and GitHubRepoDiscussionsSearch service [#9340](https://github.com/badges/shields/issues/9340) +- Allow user to filter github tags and releases [#9193](https://github.com/badges/shields/issues/9193) +- don't URL encode slash in [githubactionsworkflow] badge [#9322](https://github.com/badges/shields/issues/9322) +- add a bit of border to select boxes [#9348](https://github.com/badges/shields/issues/9348) +- deprecate [snyk] badges [#9349](https://github.com/badges/shields/issues/9349) +- increase max-age on [docker] badges, again [#9350](https://github.com/badges/shields/issues/9350) [#9369](https://github.com/badges/shields/issues/9369) +- Dependency updates + +## server-2023-07-02 + +By far the most significant change in this release is the long-awaited launch of the re-designed frontend: + +- migrate frontend to docusaurus [#9014](https://github.com/badges/shields/issues/9014) +- fix a load of spacing issues in frontend content [#9281](https://github.com/badges/shields/issues/9281) +- set a sensible meta description [#9283](https://github.com/badges/shields/issues/9283) +- chore(frontend): open homepage feature links in new tab [#9300](https://github.com/badges/shields/issues/9300) +- adapt opencollective images to theme background [#9298](https://github.com/badges/shields/issues/9298) +- temp fix: wrap code examples tabs in narrow browser windows [#9302](https://github.com/badges/shields/issues/9302) +- add a bit of border to text boxes [#9324](https://github.com/badges/shields/issues/9324) + +Other changes in this release: + +- cache [dockerpulls] badges for an hour [#9343](https://github.com/badges/shields/issues/9343) +- Mention YouTube API services and link to Google Privacy Policy [#9339](https://github.com/badges/shields/issues/9339) +- allow negative timestamps in relative [date] badge [#9321](https://github.com/badges/shields/issues/9321) +- upgrade to graphql 16 [#9290](https://github.com/badges/shields/issues/9290) +- remove obsolete travis .org examples [#9284](https://github.com/badges/shields/issues/9284) +- increase max age on reddit badges [#9282](https://github.com/badges/shields/issues/9282) +- feat: Add author filter option for [GithubCommitActivity] [#9251](https://github.com/badges/shields/issues/9251) +- Fix: [GithubCommitActivity] invalid branch error handling [#9258](https://github.com/badges/shields/issues/9258) +- Implement a pattern for dealing with upstream APIs which are slow on the first hit; affects [endpoint] [#9233](https://github.com/badges/shields/issues/9233) +- Delete old deprecated services [#9254](https://github.com/badges/shields/issues/9254) +- feat: add 'canceled' status to netlify deploy badge [#9240](https://github.com/badges/shields/issues/9240) +- increase default cache on youtube badges [#9238](https://github.com/badges/shields/issues/9238) +- embiggen youtube cache, again [#9250](https://github.com/badges/shields/issues/9250) +- Dependency updates + +## server-2023-06-01 + +- feat: Add total commits to [GitHubCommitActivity] [#9196](https://github.com/badges/shields/issues/9196) +- set a custom error on 429 [#9159](https://github.com/badges/shields/issues/9159) +- deprecate [travis].org badges [#9171](https://github.com/badges/shields/issues/9171) +- count private sponsors on [GithubSponsors] badge [#9170](https://github.com/badges/shields/issues/9170) +- Dependency updates + +## server-2023-05-01 + +**Removal:** For users who need to maintain a Github Token pool, storage has been provided via the `RedisTokenPersistence` and `REDIS_URL` settings. This feature was deprecated in `server-2023-03-01`. As of this release, the `RedisTokenPersistence` backend is now removed. If you are using this feature, you will need to migrate to using the `SQLTokenPersistence` backend for storage and provide a postgres connection string via the `POSTGRES_URL` setting. [#8922](https://github.com/badges/shields/issues/8922) + +- fail to start server if there are duplicate service names [#9099](https://github.com/badges/shields/issues/9099) +- [SourceForge] Added badges for SourceForge [#9078](https://github.com/badges/shields/issues/9078) [#9102](https://github.com/badges/shields/issues/9102) +- crates: Use `?include=` to reduce crates.io backend load [#9081](https://github.com/badges/shields/issues/9081) +- Dependency updates + +## server-2023-04-02 + +- [JenkinsCoverage] Update Jenkins Code Coverage API for new plugin version [#9010](https://github.com/badges/shields/issues/9010) +- [CTAN] fallback to date if version is empty [#9036](https://github.com/badges/shields/issues/9036) +- Update to [CTAN] API version 2.0 [#9016](https://github.com/badges/shields/issues/9016) +- handle missing statistics array in [VisualStudioMarketplace] badges [#8985](https://github.com/badges/shields/issues/8985) +- [Netlify] upgrade colors for SVG parsing [#8971](https://github.com/badges/shields/issues/8971) +- Fix [Vcpkg] version service for different version fields [#8945](https://github.com/badges/shields/issues/8945) +- only try to close pool if one exists [#8947](https://github.com/badges/shields/issues/8947) +- misc minor fixes to [githubsize node pypi] [#8946](https://github.com/badges/shields/issues/8946) +- Dependency updates + +## server-2023-03-01 + +**Deprecation:** For users who need to maintain a Github Token pool, storage has been provided via the `RedisTokenPersistence` and `REDIS_URL` settings. As of this release, the `RedisTokenPersistence` backend is now deprecated and will be removed in a future release. If you are using this feature, you will need to migrate to using the `SQLTokenPersistence` backend for storage and provide a postgres connection string via the `POSTGRES_URL` setting. [#8922](https://github.com/badges/shields/issues/8922) + +- fix: for crates.io versions, use max_stable_version if it exists [#8687](https://github.com/badges/shields/issues/8687) +- don't autofocus search [#8927](https://github.com/badges/shields/issues/8927) +- Add [Vcpkg] version service [#8923](https://github.com/badges/shields/issues/8923) +- fix: Set uid/gid in docker image to 0 [#8908](https://github.com/badges/shields/issues/8908) +- expose port 443 in Dockerfile [#8889](https://github.com/badges/shields/issues/8889) +- Dependency updates + +## server-2023-02-01 + +- replace [twitter] badge with static fallback [#8842](https://github.com/badges/shields/issues/8842) +- Add various [Polymart] badges [#8811](https://github.com/badges/shields/issues/8811) +- update [githubpipenv] tests/examples [#8797](https://github.com/badges/shields/issues/8797) +- deprecate [apm] service [#8773](https://github.com/badges/shields/issues/8773) +- deprecate lgtm [#8771](https://github.com/badges/shields/issues/8771) +- Dependency updates + +## server-2023-01-01 + +- Breaking change: Routes for GitHub workflows badge have changed. See https://github.com/badges/shields/issues/8671 for more details +- Behaviour change: In this release we fixed a long standing bug. GitHub badges were previously not reading the base URL from the `config.service.baseUri`. This release fixes that bug, bringing the code into line with the documented behaviour. This should not cause a behaviour change for most users, but users who had previously set a value in `config.service.baseUri` which was previously ignored could see this now have an effect. Users who configure their instance using env vars rather than yaml should see no change. +- Send `X-GitHub-Api-Version` when calling [GitHub] v3 API [#8669](https://github.com/badges/shields/issues/8669) +- add [VpmVersion] badge [#8755](https://github.com/badges/shields/issues/8755) +- Add [modrinth] game versions [#8673](https://github.com/badges/shields/issues/8673) +- fix debug logging of undefined query params [#8540](https://github.com/badges/shields/issues/8540), [#8757](https://github.com/badges/shields/issues/8757) +- fall back to classifiers if [pypi] license text is really long [#8690](https://github.com/badges/shields/issues/8690) +- allow passing key to [stackexchange] [#8539](https://github.com/badges/shields/issues/8539) +- Dependency updates + +## server-2022-12-01 + +- fix: support logoColor to shield icons. [#8263](https://github.com/badges/shields/issues/8263) +- handle missing properties array in [VisualStudioMarketplaceVersion] [#8603](https://github.com/badges/shields/issues/8603) +- deprecate [wercker] service [#8642](https://github.com/badges/shields/issues/8642) +- Add [Coincap] Cryptocurrency badges [#8623](https://github.com/badges/shields/issues/8623) +- Add [modrinth] version [#8604](https://github.com/badges/shields/issues/8604) +- [factorio-mod-portal] services [#8625](https://github.com/badges/shields/issues/8625) +- [Coveralls] for GitLab [#8584](https://github.com/badges/shields/issues/8584), [#8644](https://github.com/badges/shields/issues/8644) +- Remove 'suggest badges' feature [#8311](https://github.com/badges/shields/issues/8311) +- Add [modrinth] followers [#8601](https://github.com/badges/shields/issues/8601) +- Update the [modrinth] API to v2 [#8600](https://github.com/badges/shields/issues/8600) +- tidy up [GitHubGist] routes [#8510](https://github.com/badges/shields/issues/8510) +- fix [flathub] version error handling [#8500](https://github.com/badges/shields/issues/8500) +- Dependency updates + +## server-2022-11-01 + +- [Ansible] Add collection badge [#8578](https://github.com/badges/shields/issues/8578) +- [VisualStudioMarketplace] Add support to prerelease extensions version (Issue #8207) [#8561](https://github.com/badges/shields/issues/8561) +- feat: add [GitlabLastCommit] service [#8508](https://github.com/badges/shields/issues/8508) +- fix [swagger] service tests (allow 0 items in array) [#8564](https://github.com/badges/shields/issues/8564) +- fix codecov badge for non-default branch [#8565](https://github.com/badges/shields/issues/8565) +- Add [GitHubLastCommit] by committer badge [#8537](https://github.com/badges/shields/issues/8537) +- [GitHubReleaseDate] - published_at field [#8543](https://github.com/badges/shields/issues/8543) +- Fix [Testspace] with new "untested" value in case_counts array [#8544](https://github.com/badges/shields/issues/8544) +- fix: Support WAITING status for GitHub deployments [#8521](https://github.com/badges/shields/issues/8521) +- [Whatpulse] badge for a user and for a team [#8466](https://github.com/badges/shields/issues/8466) +- deprecate [pkgreview] service [#8499](https://github.com/badges/shields/issues/8499) +- Dependency updates + +## server-2022-10-08 + +- deprecate [criterion] service [#8501](https://github.com/badges/shields/issues/8501) +- fix formatRelativeDate error handling; run [date] [#8497](https://github.com/badges/shields/issues/8497) +- allow/validate bitbucket_username / bitbucket_password in private config schema [#8472](https://github.com/badges/shields/issues/8472) +- fix [pub] points badge test and example [#8498](https://github.com/badges/shields/issues/8498) +- feat: add [GitlabLanguageCount] service [#8377](https://github.com/badges/shields/issues/8377) +- [GitHubGistStars] add GitHub Gist Stars [#8471](https://github.com/badges/shields/issues/8471) +- fix display/search of CII badge examples [#8473](https://github.com/badges/shields/issues/8473) +- feat: add 2022 support to GitHub Hacktoberfest [#8468](https://github.com/badges/shields/issues/8468) +- fix [GitLabCoverage] subgroup bug [#8401](https://github.com/badges/shields/issues/8401) +- implement ruby gems-specific version sort/color functions [#8434](https://github.com/badges/shields/issues/8434) +- Add `rc` to pre-release identifiers [#8435](https://github.com/badges/shields/issues/8435) +- add [GitHub] Number of commits between branches/tags/commits [#8394](https://github.com/badges/shields/issues/8394) +- add [Packagist] dependency version [#8371](https://github.com/badges/shields/issues/8371) +- fix Docker build status invalid response data bug [#8392](https://github.com/badges/shields/issues/8392) +- Dependency updates + +## server-2022-09-04 + +- fix frontend compile for users running on Windows [#8350](https://github.com/badges/shields/issues/8350) +- [DockerSize] Docker image size multi arch [#8290](https://github.com/badges/shields/issues/8290) +- upgrade gatsby [#8334](https://github.com/badges/shields/issues/8334) +- Custom domains for [JitPack] artifacts [#8333](https://github.com/badges/shields/issues/8333) +- fix [dockerstars] service [#8316](https://github.com/badges/shields/issues/8316) +- [BountySource] Fix: Broken Badge generation for decimal activity values [#8315](https://github.com/badges/shields/issues/8315) +- feat: add [gitlabmergerequests] service [#8166](https://github.com/badges/shields/issues/8166) +- Fix terminology for [ROS] version service [#8292](https://github.com/badges/shields/issues/8292) +- feat: add [GitlabStars] service [#8209](https://github.com/badges/shields/issues/8209) +- Fix invalid `rst` format when `alt` or `target` is present [#8275](https://github.com/badges/shields/issues/8275) +- [GithubGistLastCommit] GitHub gist last commit [#8272](https://github.com/badges/shields/issues/8272) +- [GitHub] GitHub file size for a specific branch [#8262](https://github.com/badges/shields/issues/8262) +- Dependency updates + +## server-2022-08-01 + +- [pypi] Add Framework Version Badges support [#8261](https://github.com/badges/shields/issues/8261) +- feat: add [GitlabForks] server [#8208](https://github.com/badges/shields/issues/8208) +- Update PyPI api according to https://warehouse.pypa.io/api-reference/json.html [#8251](https://github.com/badges/shields/issues/8251) +- Add [galaxytoolshed] Activity [#8164](https://github.com/badges/shields/issues/8164) +- [greasyfork] Add Greasy Fork rating badges [#8087](https://github.com/badges/shields/issues/8087) +- refactor(deps): Replace moment with dayjs [#8192](https://github.com/badges/shields/issues/8192) +- add spaces round pipe in [conda] badge [#8189](https://github.com/badges/shields/issues/8189) +- Add [ROS] version service [#8169](https://github.com/badges/shields/issues/8169) +- feat: add [gitlabissues] service [#8108](https://github.com/badges/shields/issues/8108) +- Dependency updates + +## server-2022-07-03 + +- Add [galaxytoolshed] services [#8114](https://github.com/badges/shields/issues/8114) +- fix [gitlab] auth [#8145](https://github.com/badges/shields/issues/8145) [#8162](https://github.com/badges/shields/issues/8162) +- increase cache length on AUR version badge, run [AUR] [#8110](https://github.com/badges/shields/issues/8110) +- Use GraphQL to fix GitHub file count badges [github] [#8112](https://github.com/badges/shields/issues/8112) +- feat: add [gitlab] contributors service [#8084](https://github.com/badges/shields/issues/8084) +- [greasyfork] Add Greasy Fork service badges [#8080](https://github.com/badges/shields/issues/8080) +- Add [gitlablicense] services [#8024](https://github.com/badges/shields/issues/8024) +- [Spack] Package Manager: Update Domain [#8046](https://github.com/badges/shields/issues/8046) +- switch [jitpack] to use latestOk endpoint [#8041](https://github.com/badges/shields/issues/8041) +- Dependency updates + +## server-2022-06-01 + +- Update GitLab logo (2022) [#7984](https://github.com/badges/shields/issues/7984) +- [GitHub] Added milestone property to GitHub issue details service [#7864](https://github.com/badges/shields/issues/7864) +- [Spack] Package Manager: Update Endpoint [#7957](https://github.com/badges/shields/issues/7957) +- Update Chocolatey API endpoint URL [#7952](https://github.com/badges/shields/issues/7952) +- [Flathub]Add downloads badge [#7724](https://github.com/badges/shields/issues/7724) +- replace the outdated Telegram logo with the newest [#7831](https://github.com/badges/shields/issues/7831) +- add [PUB] points badge [#7918](https://github.com/badges/shields/issues/7918) +- add [PUB] popularity badge [#7920](https://github.com/badges/shields/issues/7920) +- add [PUB] likes badge [#7916](https://github.com/badges/shields/issues/7916) +- Dependency updates + +## server-2022-05-03 + +- [OSSFScorecard] Create scorecard badge service [#7687](https://github.com/badges/shields/issues/7687) +- Stringify [githublanguagecount] message [#7881](https://github.com/badges/shields/issues/7881) +- Stringify and trim whitespace from a few services [#7880](https://github.com/badges/shields/issues/7880) +- add labels to Dockerfile [#7862](https://github.com/badges/shields/issues/7862) +- handle missing 'fly-client-ip' [#7814](https://github.com/badges/shields/issues/7814) +- Dependency updates + +## server-2022-04-03 + +- Breaking change: This release updates ioredis from v4 to v5. If you are using redis for GitHub token pooling, redis connection strings of the form `redis://junkusername:authpassword@example.com:1234` will need to be updated to `redis://:authpassword@example.com:1234`. See the [ioredis upgrade guide](https://github.com/luin/ioredis/wiki/Upgrading-from-v4-to-v5) for further details. +- fix installation issue on npm >= 8.5.5 [#7809](https://github.com/badges/shields/issues/7809) +- two fixes for [packagist] schemas [#7782](https://github.com/badges/shields/issues/7782) +- allow requireCloudflare setting to work when hosted on fly.io [#7781](https://github.com/badges/shields/issues/7781) +- fix [pypi] badges when package has null license [#7761](https://github.com/badges/shields/issues/7761) +- Add a [pub] publisher badge [#7715](https://github.com/badges/shields/issues/7715) +- Switch Steam file size badge to informational color [#7722](https://github.com/badges/shields/issues/7722) +- Make W3C and Youtube documentation links clickable [#7721](https://github.com/badges/shields/issues/7721) +- Improve Wercker examples [#7720](https://github.com/badges/shields/issues/7720) +- Improve Cirrus CI examples [#7719](https://github.com/badges/shields/issues/7719) +- Support [CodeClimate] responses with multiple data items [#7716](https://github.com/badges/shields/issues/7716) +- Delete [TeamCityCoverage] and [BowerVersion] redirectors [#7718](https://github.com/badges/shields/issues/7718) +- Deprecate [Shippable] service [#7717](https://github.com/badges/shields/issues/7717) +- fix: restore version comparison updates from #4173 [#4254](https://github.com/badges/shields/issues/4254) +- [piwheels], filter out versions with no files [#7696](https://github.com/badges/shields/issues/7696) +- set a longer cacheLength on [librariesio] badges [#7692](https://github.com/badges/shields/issues/7692) +- improve python version formatting [#7682](https://github.com/badges/shields/issues/7682) +- Clarify GitHub All Contributors badge [#7690](https://github.com/badges/shields/issues/7690) +- Support [HexPM] packages with no stable release [#7685](https://github.com/badges/shields/issues/7685) +- Add Test at Scale Badge [#7612](https://github.com/badges/shields/issues/7612) +- [packagist] api v2 support [#7681](https://github.com/badges/shields/issues/7681) +- Add [piwheels] version badge [#7656](https://github.com/badges/shields/issues/7656) +- Dependency updates + +## server-2022-03-01 + +- Add [Conan] version service (#7460) +- remove suspended [github] tokens from the pool [#7654](https://github.com/badges/shields/issues/7654) +- generate links without trailing : if port not set [#7655](https://github.com/badges/shields/issues/7655) +- Use the latest build status when checking docs.rs [#7613](https://github.com/badges/shields/issues/7613) +- Remove no download handling and add API warning to [Wordpress] badges [#7606](https://github.com/badges/shields/issues/7606) +- set a higher default cacheLength on rating/star category [#7587](https://github.com/badges/shields/issues/7587) +- Update [amo] to use v4 API, set custom `cacheLength`s [#7586](https://github.com/badges/shields/issues/7586) +- fix(amo): include trailing slash in API call [#7585](https://github.com/badges/shields/issues/7585) +- fix docker image user agent [#7582](https://github.com/badges/shields/issues/7582) +- Delete deprecated Codetally and continuousphp services [#7572](https://github.com/badges/shields/issues/7572) +- Deprecate [Requires] service [#7571](https://github.com/badges/shields/issues/7571) +- [AUR] Fix RPC URL [#7570](https://github.com/badges/shields/issues/7570) +- Dependency updates + +## server-2022-02-01 + +- [Depfu] Add support for Gitlab [#7475](https://github.com/badges/shields/issues/7475) +- replace label in hn-user-karma with U/ [#7500](https://github.com/badges/shields/issues/7500) +- Support [Feedz] response with multiple pages without items [#7476](https://github.com/badges/shields/issues/7476) +- revert decamelize and humanize-string to old versions [#7449](https://github.com/badges/shields/issues/7449) +- Dependency updates + +## server-2022-01-01 + +- minor [reddit] improvements [#7436](https://github.com/badges/shields/issues/7436) +- [HackerNews] Show User Karma [#7411](https://github.com/badges/shields/issues/7411) +- [YouTube] Drop support for removed dislikes [#7410](https://github.com/badges/shields/issues/7410) +- change closed GitHub issue color to purple [#7374](https://github.com/badges/shields/issues/7374) +- restore cors header injection from #4171 [#4255](https://github.com/badges/shields/issues/4255) +- [GithubPackageJson] Get version from monorepo subfolder package.json [#7350](https://github.com/badges/shields/issues/7350) +- Dependency updates + +## server-2021-12-01 + +- Send better user-agent values [#7309](https://github.com/badges/shields/issues/7309) Self-hosting users now send a user agent which indicates the server version and starts `shields (self-hosted)/` by default. This can be configured using the env var `USER_AGENT_BASE` +- upgrade to node 16 [#7271](https://github.com/badges/shields/issues/7271) +- feat: deprecate dependabot badges [#7274](https://github.com/badges/shields/issues/7274) +- fix: npmversion tagged service test [#7269](https://github.com/badges/shields/issues/7269) +- feat: create new Test Results category [#7218](https://github.com/badges/shields/issues/7218) +- Migration from Request to Got for all HTTP requests is completed in this release +- Dependency updates + +## server-2021-11-04 + +- migrate regularUpdate() from request-->got [#7215](https://github.com/badges/shields/issues/7215) +- migrate github badges to use got instead of request; affects [github librariesio] [#7212](https://github.com/badges/shields/issues/7212) +- deprecate David badges [#7197](https://github.com/badges/shields/issues/7197) +- fix: ensure libraries.io header values are processed numerically [#7196](https://github.com/badges/shields/issues/7196) +- Add authentication for Libraries.io-based badges, run [Libraries Bower] [#7080](https://github.com/badges/shields/issues/7080) +- fixes and tests for pipenv helpers [#7194](https://github.com/badges/shields/issues/7194) +- add GitLab Release badge, run all [GitLab] [#7021](https://github.com/badges/shields/issues/7021) +- set content-length header on badge responses [#7179](https://github.com/badges/shields/issues/7179) +- fix [github] release/tag/download schema [#7170](https://github.com/badges/shields/issues/7170) +- Supported nested groups on [GitLabPipeline] badge [#7159](https://github.com/badges/shields/issues/7159) +- Support nested groups on [GitLabTag] badge [#7158](https://github.com/badges/shields/issues/7158) +- Fixing incorrect JetBrains Plugin rating values for [JetBrainsRating] [#7140](https://github.com/badges/shields/issues/7140) +- support using release or tag name in [GitHub] Release version badge [#7075](https://github.com/badges/shields/issues/7075) +- feat: support branches in sonar badges [#7065](https://github.com/badges/shields/issues/7065) +- Add [Modrinth] total downloads badge [#7132](https://github.com/badges/shields/issues/7132) +- remove [github] admin routes [#7105](https://github.com/badges/shields/issues/7105) +- Dependency updates + +## server-2021-10-04 + +- feat: add 2021 support to GitHub Hacktoberfest [#7086](https://github.com/badges/shields/issues/7086) +- Add [ClearlyDefined] service [#6944](https://github.com/badges/shields/issues/6944) +- handle null licenses in crates.io response schema, run [crates] [#7074](https://github.com/badges/shields/issues/7074) +- [OBS] add Open Build Service service-badge [#6993](https://github.com/badges/shields/issues/6993) +- Correction of badges url in self-hosting configuration with a custom port. Issue 7025 [#7036](https://github.com/badges/shields/issues/7036) +- fix: support gitlab token via env var [#7023](https://github.com/badges/shields/issues/7023) +- Add API-based support for [GitLab] badges, add new GitLab Tag badge [#6988](https://github.com/badges/shields/issues/6988) +- [freecodecamp]: allow + symbol in username [#7016](https://github.com/badges/shields/issues/7016) +- Rename Riot to Element in Matrix badge help [#6996](https://github.com/badges/shields/issues/6996) +- Fixed Reddit Negative Karma Issue [#6992](https://github.com/badges/shields/issues/6992) +- Dependency updates + ## server-2021-09-01 - use multi-stage build to reduce size of docker images [#6938](https://github.com/badges/shields/issues/6938) @@ -59,9 +865,7 @@ Note: this changelog is for the shields.io server. The changelog for the badge-m ## 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. +- 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) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 0d618ae3eb98f..0a6cd13cb5537 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -2,128 +2,80 @@ ## Our Pledge -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, religion, or sexual identity -and orientation. +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards -Examples of behavior that contributes to a positive environment for our -community include: +Examples of behavior that contributes to a positive environment for our community include: - Demonstrating empathy and kindness toward other people - Being respectful of differing opinions, viewpoints, and experiences - Giving and gracefully accepting constructive feedback -- Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -- Focusing on what is best not just for us as individuals, but for the - overall community +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: -- The use of sexualized language or imagery, and sexual attention or - advances of any kind +- The use of sexualized language or imagery, and sexual attention or advances of any kind - Trolling, insulting or derogatory comments, and personal or political attacks - Public or private harassment -- Publishing others' private information, such as a physical or email - address, without their explicit permission -- Other conduct which could reasonably be considered inappropriate in a - professional setting +- Publishing others' private information, such as a physical or email address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official e-mail address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -![](https://img.shields.io/badge/conduct-%40shields.io-blue) or directly to [@calebcartwright](https://github.com/calebcartwright) ![](https://img.shields.io/badge/caleb-%40shields.io-blue) or [@paulmelnikow](https://github.com/paulmelnikow) ![](https://img.shields.io/badge/paul-%40shields.io-blue) +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at ![](https://img.shields.io/badge/conduct-%40shields.io-blue) or directly to [@calebcartwright](https://github.com/calebcartwright) ![](https://img.shields.io/badge/caleb-%40shields.io-blue) or [@paulmelnikow](https://github.com/paulmelnikow) ![](https://img.shields.io/badge/paul-%40shields.io-blue) All complaints will be reviewed and investigated promptly and fairly. -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. +All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning -**Community Impact**: A violation through a single incident or series -of actions. +**Community Impact**: A violation through a single incident or series of actions. -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or -permanent ban. +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. -**Consequence**: A permanent ban from any sort of public interaction within -the community. +**Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.0, available at -https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. -Community Impact Guidelines were inspired by [Mozilla's code of conduct -enforcement ladder](https://github.com/mozilla/diversity). +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org -For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at -https://www.contributor-covenant.org/translations. +For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d949a8d12fb63..5819289807547 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,22 +1,16 @@ # Contributing to Shields -Shields is a community project. We invite your participation through -financial contributions, issues, and pull requests! +Shields is a community project. We invite your participation through financial contributions, issues, and pull requests! ## Ways you can help ### Financial contributions -We welcome financial contributions in full transparency on our -[open collective](https://opencollective.com/shields). Anyone can file an -expense. If the expense makes sense for the development of the community, it -will be "merged" into the ledger of our open collective by the core -contributors and the person who filed the expense will be reimbursed. +We welcome financial contributions in full transparency on our [open collective](https://opencollective.com/shields). ### Contributing code -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: +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: [![GitHub issues by-label](https://img.shields.io/github/issues/badges/shields/good%20first%20issue.svg)](https://github.com/badges/shields/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) @@ -27,34 +21,22 @@ You can help by improving the project's usage and developer instructions. Tutorials are in [/doc](https://github.com/badges/shields/tree/master/doc): - When you read the documentation, you can fix mistakes and add your own thoughts. -- When your pull request follows the documentation but the practice changed, - consider pointing this out and change the documentation for the next person. +- When your pull request follows the documentation but the practice changed, consider pointing this out and change the documentation for the next person. API documentation is at [contributing.shields.io](https://contributing.shields.io/): -- This documentation is generated by annotating the code with - [JSDoc](https://jsdoc.app/about-getting-started.html) comments. - [Example](https://github.com/badges/shields/blob/b3be4d94d5ef570b8daccfd088c343a958988843/core/base-service/base-json.js#L26-L41) -- Adding a JSDoc comment to some existing code is a great first contribution - and a good way to familiarize yourself with the codebase +- This documentation is generated by annotating the code with [JSDoc](https://jsdoc.app/about-getting-started.html) comments. [Example](https://github.com/badges/shields/blob/b3be4d94d5ef570b8daccfd088c343a958988843/core/base-service/base-json.js#L26-L41) +- Adding a JSDoc comment to some existing code is a great first contribution and a good way to familiarize yourself with the codebase ### Helping others -You can help with code review, which reduces bugs, and over time has a -wonderful side effect of making the code more readable and therefore more -approachable. It's also a great way to teach and learn. Feel free to jump in! -Be welcoming, appreciative, and helpful. You can perform first reviews of -simple changes, like badge additions. These are usually tagged with -[service badge][service badge pr tag]. +You can help with code review, which reduces bugs, and over time has a wonderful side effect of making the code more readable and therefore more approachable. It's also a great way to teach and learn. Feel free to jump in! Be welcoming, appreciative, and helpful. You can perform first reviews of simple changes, like badge additions. These are usually tagged with [service badge][service badge pr tag]. Please review [these impeccable guidelines][code review guidelines]. -You can monitor [issues][], [discussions][] and the [chat room][], and help -other people who have questions about contributing to Shields, or using it -for their projects. +You can monitor [issues][], [discussions][] and the [chat room][], and help other people who have questions about contributing to Shields, or using it for their projects. -Feel free to reach out to one of the [maintainers][] -if you need help getting started. +Feel free to reach out to one of the [maintainers][] if you need help getting started. [service badge pr tag]: https://github.com/badges/shields/pulls?q=is%3Apr+is%3Aopen+label%3Aservice-badge [code review guidelines]: https://kickstarter.engineering/a-guide-to-mindful-communication-in-code-reviews-48aab5282e5e @@ -65,51 +47,45 @@ if you need help getting started. ### Suggesting improvements -There are _a lot_ of suggestions on file. You can help by weighing in on these -suggestions, which helps convey community need to other contributors who might -pick them up. +There are _a lot_ of suggestions on file. You can help by weighing in on these suggestions, which helps convey community need to other contributors who might pick them up. -There is no need to post a new comment. Just add a :thumbsup: or :heart: to -the top post. +There is no need to post a new comment. Just add a :thumbsup: or :heart: to the top post. -If you have a suggestion of your own, [search the open issues][issues]. If you -don't see it, feel free to [open a new issue][open an issue]. +If you have a suggestion of your own, [search the open issues][issues]. If you don't see it, feel free to [open a new issue][open an issue]. [open an issue]: https://github.com/badges/shields/issues/new/choose +### Requesting new logos + +We consume logos via [the SimpleIcons project][simple-icons github], and encourage you to contribute logos there. Please review their [guidance][simple-icons contributing] before doing so. + +[simple-icons github]: https://github.com/simple-icons/simple-icons +[simple-icons contributing]: https://github.com/simple-icons/simple-icons/blob/develop/CONTRIBUTING.md + ### Spreading the word Feel free to star the repository. This will help increase the visibility of the project, therefore attracting more users and contributors to Shields! -We're also asking for [one-time \$10 donations](https://opencollective.com/shields) from developers who use and love Shields, please spread the word! +We're also asking for [donations](https://opencollective.com/shields) from developers who use and love Shields, please spread the word! ## Getting help There are three places to get help: 1. If you're new to the project, a good place to start is the [tutorial][]. -2. If you need help getting started or implementing a change, [start a discussion][discussions] - with your question. We promise it's okay to do that. If there is already an - issue open for the feature you're working on, you can post there directly. +2. If you need help getting started or implementing a change, [start a discussion][discussions] with your question. We promise it's okay to do that. If there is already an issue open for the feature you're working on, you can post there directly. 3. You can also join the [chat room][] and ask your question there. [tutorial]: doc/TUTORIAL.md ## Badge guidelines -- Shields.io hosts integrations for services which are primarily - 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 - default_. +- Shields.io hosts integrations for services which are primarily 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 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. - Whereas API publishers are incentivised to maintain a stable platform for their users, - authors of web pages have no such incentive. -- Badges may require users to specify a token in the badge URL as long it is scoped only to - fetching information and doesn't expose any sensitive information. Generating a token with the - correct scope must be clearly documented. +- Badges should not obtain data by scraping web pages - these are likely to break frequently. Whereas API publishers are incentivised to maintain a stable platform for their users, authors of web pages have no such incentive. +- Badges may require users to specify a token in the badge URL as long it is scoped only to fetching information and doesn't expose any sensitive information. Generating a token with the correct scope must be clearly documented. ## Badge URLs @@ -120,23 +96,33 @@ There are three places to get help: ### Prettier -This project formats its source code using Prettier. The most enjoyable way to -use Prettier is to let it format code for you when you save. You can [integrate -it into your editor][integrate prettier]. +This project formats its source code using Prettier. The most enjoyable way to use Prettier is to let it format code for you when you save. You can [integrate it into your editor][integrate prettier]. -Whether you integrate it into your editor or not, a pre-commit hook will run -Prettier before a commit by default. +Whether you integrate it into your editor or not, a pre-commit hook will run Prettier before a commit by default. [integrate prettier]: https://prettier.io/docs/en/editors.html +### Coding style + +This project uses lower camelCase for variable names and function names and client facing parameters. We use PascalCase for class names and service names. + ### 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 changing other code, please add unit 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 changing other code, please add unit tests. -To run the integration tests, you must have redis installed and in your PATH. -Use `brew install redis`, `yum install redis`, etc. The test runner will -start the server automatically. +The integration tests are not run by default. For most contributions it is OK to skip these unless you're working directly on the code for storing the GitHub token pool in postgres. + +To run the integration tests: + +- You must have PostgreSQL installed. Use `brew install postgresql`, `apt-get install postgresql`, etc. +- Set a connection string either with an env var `POSTGRES_URL=postgresql://user:pass@127.0.0.1:5432/db_name` or by using + ```yaml + private: + postgres_url: 'postgresql://user:pass@127.0.0.1:5432/db_name' + ``` + in a yaml config file. +- Run `npm run migrate up` to apply DB migrations +- Run `npm run test:integration` to run the tests [service-tests]: https://github.com/badges/shields/blob/master/doc/service-tests.md @@ -144,10 +130,6 @@ start the server automatically. There is a [High-level code walkthrough](doc/code-walkthrough.md) describing the layout of the project. -### 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! @@ -175,7 +157,4 @@ Note that many services are part of a "family" of related services. Depending on 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. +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. diff --git a/Dockerfile b/Dockerfile index dec19674affde..01ba8b8a94f24 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,6 @@ -FROM node:14-alpine AS Builder +FROM node:22-alpine AS builder + +RUN npm install -g "npm@^10" RUN mkdir -p /usr/src/app RUN mkdir /usr/src/app/private @@ -8,23 +10,31 @@ COPY package.json package-lock.json /usr/src/app/ # Without the badge-maker package.json and CLI script in place, `npm ci` will fail. COPY badge-maker /usr/src/app/badge-maker/ -RUN npm install -g "npm@>=7" # We need dev deps to build the front end. We don't need Cypress, though. RUN NODE_ENV=development CYPRESS_INSTALL_BINARY=0 npm ci COPY . /usr/src/app -RUN npm run build -RUN npm prune --production -RUN npm cache clean --force + +RUN npm run build \ + && npm prune --omit=dev --force \ + && rm -rf node_modules/.cache \ + && rm -rf frontend package-lock.json + # Use multi-stage build to reduce size -FROM node:14-alpine +FROM node:22-alpine + +ARG version=dev +ENV DOCKER_SHIELDS_VERSION=$version +LABEL version=$version +LABEL fly.version=$version + # Run the server using production configs. -ENV NODE_ENV production +ENV NODE_ENV=production WORKDIR /usr/src/app -COPY --from=Builder /usr/src/app /usr/src/app +COPY --from=builder --chown=0:0 /usr/src/app /usr/src/app -CMD node server +CMD ["node", "server"] -EXPOSE 80 +EXPOSE 80 443 diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 670154e353886..0000000000000 --- a/LICENSE +++ /dev/null @@ -1,116 +0,0 @@ -CC0 1.0 Universal - -Statement of Purpose - -The laws of most jurisdictions throughout the world automatically confer -exclusive Copyright and Related Rights (defined below) upon the creator and -subsequent owner(s) (each and all, an "owner") of an original work of -authorship and/or a database (each, a "Work"). - -Certain owners wish to permanently relinquish those rights to a Work for the -purpose of contributing to a commons of creative, cultural and scientific -works ("Commons") that the public can reliably and without fear of later -claims of infringement build upon, modify, incorporate in other works, reuse -and redistribute as freely as possible in any form whatsoever and for any -purposes, including without limitation commercial purposes. These owners may -contribute to the Commons to promote the ideal of a free culture and the -further production of creative, cultural and scientific works, or to gain -reputation or greater distribution for their Work in part through the use and -efforts of others. - -For these and/or other purposes and motivations, and without any expectation -of additional consideration or compensation, the person associating CC0 with a -Work (the "Affirmer"), to the extent that he or she is an owner of Copyright -and Related Rights in the Work, voluntarily elects to apply CC0 to the Work -and publicly distribute the Work under its terms, with knowledge of his or her -Copyright and Related Rights in the Work and the meaning and intended legal -effect of CC0 on those rights. - -1. Copyright and Related Rights. A Work made available under CC0 may be -protected by copyright and related or neighboring rights ("Copyright and -Related Rights"). Copyright and Related Rights include, but are not limited -to, the following: - - i. the right to reproduce, adapt, distribute, perform, display, communicate, - and translate a Work; - - ii. moral rights retained by the original author(s) and/or performer(s); - - iii. publicity and privacy rights pertaining to a person's image or likeness - depicted in a Work; - - iv. rights protecting against unfair competition in regards to a Work, - subject to the limitations in paragraph 4(a), below; - - v. rights protecting the extraction, dissemination, use and reuse of data in - a Work; - - vi. database rights (such as those arising under Directive 96/9/EC of the - European Parliament and of the Council of 11 March 1996 on the legal - protection of databases, and under any national implementation thereof, - including any amended or successor version of such directive); and - - vii. other similar, equivalent or corresponding rights throughout the world - based on applicable law or treaty, and any national implementations thereof. - -2. Waiver. To the greatest extent permitted by, but not in contravention of, -applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and -unconditionally waives, abandons, and surrenders all of Affirmer's Copyright -and Related Rights and associated claims and causes of action, whether now -known or unknown (including existing as well as future claims and causes of -action), in the Work (i) in all territories worldwide, (ii) for the maximum -duration provided by applicable law or treaty (including future time -extensions), (iii) in any current or future medium and for any number of -copies, and (iv) for any purpose whatsoever, including without limitation -commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes -the Waiver for the benefit of each member of the public at large and to the -detriment of Affirmer's heirs and successors, fully intending that such Waiver -shall not be subject to revocation, rescission, cancellation, termination, or -any other legal or equitable action to disrupt the quiet enjoyment of the Work -by the public as contemplated by Affirmer's express Statement of Purpose. - -3. Public License Fallback. Should any part of the Waiver for any reason be -judged legally invalid or ineffective under applicable law, then the Waiver -shall be preserved to the maximum extent permitted taking into account -Affirmer's express Statement of Purpose. In addition, to the extent the Waiver -is so judged Affirmer hereby grants to each affected person a royalty-free, -non transferable, non sublicensable, non exclusive, irrevocable and -unconditional license to exercise Affirmer's Copyright and Related Rights in -the Work (i) in all territories worldwide, (ii) for the maximum duration -provided by applicable law or treaty (including future time extensions), (iii) -in any current or future medium and for any number of copies, and (iv) for any -purpose whatsoever, including without limitation commercial, advertising or -promotional purposes (the "License"). The License shall be deemed effective as -of the date CC0 was applied by Affirmer to the Work. Should any part of the -License for any reason be judged legally invalid or ineffective under -applicable law, such partial invalidity or ineffectiveness shall not -invalidate the remainder of the License, and in such case Affirmer hereby -affirms that he or she will not (i) exercise any of his or her remaining -Copyright and Related Rights in the Work or (ii) assert any associated claims -and causes of action with respect to the Work, in either case contrary to -Affirmer's express Statement of Purpose. - -4. Limitations and Disclaimers. - - a. No trademark or patent rights held by Affirmer are waived, abandoned, - surrendered, licensed or otherwise affected by this document. - - b. Affirmer offers the Work as-is and makes no representations or warranties - of any kind concerning the Work, express, implied, statutory or otherwise, - including without limitation warranties of title, merchantability, fitness - for a particular purpose, non infringement, or the absence of latent or - other defects, accuracy, or the present or absence of errors, whether or not - discoverable, all to the greatest extent permissible under applicable law. - - c. Affirmer disclaims responsibility for clearing rights of other persons - that may apply to the Work or any use thereof, including without limitation - any person's Copyright and Related Rights in the Work. Further, Affirmer - disclaims responsibility for obtaining any necessary consents, permissions - or other rights required for any use of the Work. - - d. Affirmer understands and acknowledges that Creative Commons is not a - party to this document and has no duty or obligation with respect to this - CC0 or use of the Work. - -For more information, please see - diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000000000..4c2e465d106a3 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright Thaddée Tyl, Paul Melnikow, Pierre-Yves Bigourdan and contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000000000..bcb82f0bc732d --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Thaddée Tyl, Paul Melnikow, Pierre-Yves Bigourdan and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 6e9e8fb28f072..9bf3b552b202e 100644 --- a/README.md +++ b/README.md @@ -3,41 +3,26 @@ height="130">

- - - + - + - - build status - - service-test status + + + + Daily Tests Status coverage - - Total alerts + alt="Code Coverage"> - chat on Discord - - follow on Twitter + Chat on Discord

-This is home to [Shields.io][shields.io], a service for concise, consistent, -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. +This is home to [Shields.io][shields.io], a service for concise, consistent, 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 1.6 billion 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 @@ -68,12 +53,11 @@ This repo hosts: - amount of [Liberapay](https://liberapay.com/) donations per week: ![receives](https://img.shields.io/badge/receives-2.00%20USD%2Fweek-yellow) - Python package downloads: ![downloads](https://img.shields.io/badge/downloads-13k%2Fmonth-brightgreen) - Chrome Web Store extension rating: ![rating](https://img.shields.io/badge/rating-★★★★☆-brightgreen) -- [Uptime Robot](https://uptimerobot.com) percentage: ![uptime](https://img.shields.io/badge/uptime-100%25-brightgreen) +- Uptime Robot uptime percentage: ![uptime](https://img.shields.io/badge/uptime-100%25-brightgreen) -[Make your own badges!][custom badges] -(Quick example: `https://img.shields.io/badge/left-right-f39f37`) +[Make your own badges!][custom badges] (Quick example: `https://img.shields.io/badge/left-right-f39f37`) -[custom badges]: https://shields.io/#your-badge +[custom badges]: https://img.shields.io/badges/static-badge ### Quickstart @@ -83,72 +67,56 @@ Use the button at the bottom to copy your badge url or snippet, which can then b ## Contributing -Shields is a community project. We invite your participation through issues -and pull requests! You can peruse the [contributing guidelines][contributing]. +Shields is a community project. We invite your participation through issues 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. +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]. [![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) +If you intend on reporting or contributing a fix related to security vulnerabilities, please first refer to our [security policy][security]. + [service-tests]: https://github.com/badges/shields/blob/master/doc/service-tests.md [tutorial]: https://github.com/badges/shields/blob/master/doc/TUTORIAL.md [contributing]: https://github.com/badges/shields/blob/master/CONTRIBUTING.md +[security]: https://github.com/badges/shields/blob/master/SECURITY.md ## Development -1. Install Node 14 or later. You can use the [package manager][] of your choice. - Tests need to pass in Node 14 and 16. +[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/badges/shields?quickstart=1) + +1. Install Node 22. You can use the [package manager][] of your choice. 2. Clone this repository. 3. Run `npm ci` to install the dependencies. 4. Run `npm start` to start the badge server and the frontend dev server. 5. Open `http://localhost:3000/` to view the frontend. -When server source files change, the badge server should automatically restart -itself (using [nodemon][]). When the frontend files change, the frontend dev -server (`gatsby dev`) should also automatically reload. However the badge -definitions are built only before the server first starts. To regenerate those, -either run `npm run defs` or manually restart the server. - -To debug a badge from the command line, run `npm run badge -- /npm/v/nock`. -It also works with full URLs like -`npm run badge -- https://img.shields.io/npm/v/nock`. +When server source files change, the badge server should automatically restart itself (using [nodemon][]). When the frontend files change, the frontend dev server (`docusaurus start`) should also automatically reload. However the badge definitions are built only before the server first starts. To regenerate those, either run `npm run prestart` or manually restart the server. -Use `npm run debug:server` to start server in debug mode. -[This recipe][nodemon debug] shows how to debug Node.js application in [VS Code][]. +To debug a badge from the command line, run `npm run badge -- /npm/v/nock`. It also works with full URLs like `npm run badge -- https://img.shields.io/npm/v/nock`. -Shields has experimental support for [Gitpod][gitpod], a pre-configured development -environment that runs in your browser. To use Gitpod, click the button below and -sign in with GitHub. Gitpod also offers a browser add-on, though it is not required. -Please report any Gitpod bugs, questions, or suggestions in issue -[#2772](https://github.com/badges/shields/issues/2772). +Use `npm run debug:server` to start server in debug mode. [This recipe][nodemon debug] shows how to debug Node.js application in [VS Code][]. -[![Edit with Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/badges/shields) - -[Snapshot tests][] ensure we don't inadvertently make changes that affect the -SVG or JSON output. When deliberately changing the output, run -`SNAPSHOT_DRY=1 npm run test:package` to preview changes to the saved -snapshots, and `SNAPSHOT_UPDATE=1 npm run test:package` to update them. +[Snapshot tests][] ensure we don't inadvertently make changes that affect the SVG or JSON output. When deliberately changing the output, run `SNAPSHOT_DRY=1 npm run test:package` to preview changes to the saved snapshots, and `SNAPSHOT_UPDATE=1 npm run test:package` to update them. The server can be configured to use [Sentry][] ([configuration][sentry configuration]) and [Prometheus][] ([configuration][prometheus configuration]). -Daily tests, including a full run of the service tests and overall code coverage, are run via [badges/daily-tests][daily-tests]. +Our [full test suite][full test suite] as well as [code coverage][code coverage] are run on a daily basis. [package manager]: https://nodejs.org/en/download/package-manager/ -[gitpod]: https://www.gitpod.io/ [snapshot tests]: https://glebbahmutov.com/blog/snapshot-testing/ [prometheus]: https://prometheus.io/ [prometheus configuration]: https://github.com/badges/shields/blob/master/doc/self-hosting.md#prometheus [sentry]: https://sentry.io/ [sentry configuration]: https://github.com/badges/shields/blob/master/doc/self-hosting.md#sentry -[daily-tests]: https://github.com/badges/daily-tests [nodemon]: https://nodemon.io/ [nodemon debug]: https://github.com/Microsoft/vscode-recipes/tree/master/nodemon [vs code]: https://code.visualstudio.com/ +[full test suite]: https://github.com/badges/shields/actions/workflows/daily-tests.yml +[code coverage]: https://coveralls.io/github/badges/shields ## Hosting your own server @@ -160,36 +128,18 @@ There is documentation about [hosting your own server][self-hosting]. [![Awesome](https://awesome.re/badge.svg)](https://awesome.re) -Status badges are used widely across open-source and private software projects. -Academics have studied the "signal" badges provide about software project -quality. There are many existing libraries for rendering these badges, and -alternatives to the hosted Shields badge service. [awesome-badges][] is a -curated collection of such resources. -[Contributions][contributing to awesome-badges] may be considered there. -(The presence of a project in that collection should not be interpreted as an endorsement nor promotion from the Shields project) +Status badges are used widely across open-source and private software projects. Academics have studied the "signal" badges provide about software project quality. There are many existing libraries for rendering these badges, and alternatives to the hosted Shields badge service. [awesome-badges][] is a curated collection of such resources. [Contributions][contributing to awesome-badges] may be considered there. (The presence of a project in that collection should not be interpreted as an endorsement nor promotion from the Shields project) [awesome-badges]: https://github.com/badges/awesome-badges [contributing to awesome-badges]: https://github.com/badges/awesome-badges/blob/main/CONTRIBUTING.md ## History -b.adge.me was the original website for this service. Heroku back then had a -thing which made it hard to use a toplevel domain with it, hence the odd -domain. It used code developed in 2013 from a library called -[gh-badges][old-gh-badges], both developed by [Thaddée Tyl][espadrine]. -The project merged with shields.io by making it use the b.adge.me code -and closed b.adge.me. +b.adge.me was the original website for this service. Heroku back then had a thing which made it hard to use a toplevel domain with it, hence the odd domain. It used code developed in 2013 from a library called [gh-badges][old-gh-badges], both developed by [Thaddée Tyl][espadrine]. The project merged with shields.io by making it use the b.adge.me code and closed b.adge.me. -The original badge specification was developed in 2013 by -[Olivier Lacan][olivierlacan]. It was inspired by the Travis CI and similar -badges (there were a lot fewer, back then). In 2014 Thaddée Tyl redesigned -it with help from a Travis CI employee and convinced everyone to switch to -it. The old design is what today is called the plastic style; the new one -is the flat style. +The original badge specification was developed in 2013 by [Olivier Lacan][olivierlacan]. It was inspired by the Travis CI and similar badges (there were a lot fewer, back then). In 2014 Thaddée Tyl redesigned it with help from a Travis CI employee and convinced everyone to switch to it. The old design is what today is called the plastic style; the new one is the flat style. -You can read more about [the project's inception][thread], -[the motivation of the SVG badge specification][motivation], and -[the specification itself][spec]. +You can read more about [the project's inception][thread], [the motivation of the SVG badge specification][motivation], and [the specification itself][spec]. [olivierlacan]: https://github.com/olivierlacan [espadrine]: https://github.com/espadrine @@ -202,33 +152,24 @@ You can read more about [the project's inception][thread], Maintainers: -- [calebcartwright](https://github.com/calebcartwright) (core team) -- [chris48s](https://github.com/chris48s) (core team) -- [Daniel15](https://github.com/Daniel15) (core team) -- [paulmelnikow](https://github.com/paulmelnikow) (core team) -- [platan](https://github.com/platan) (core team) -- [PyvesB](https://github.com/PyvesB) (core team) -- [RedSparr0w](https://github.com/RedSparr0w) (core team) - -Operations: - - [calebcartwright](https://github.com/calebcartwright) -- [chris48s](https://github.com/chris48s) +- [jNullj](https://github.com/jnullj) +- [LitoMore](https://github.com/LitoMore) - [paulmelnikow](https://github.com/paulmelnikow) - [PyvesB](https://github.com/PyvesB) Alumni: +- [chris48s](https://github.com/chris48s) +- [Daniel15](https://github.com/Daniel15) - [espadrine](https://github.com/espadrine) - [olivierlacan](https://github.com/olivierlacan) +- [platan](https://github.com/platan) +- [RedSparr0w](https://github.com/RedSparr0w) ## License -All assets and code are under the [CC0 LICENSE](LICENSE) and in the public -domain unless specified otherwise. - -The assets in `logo/` are trademarks of their respective companies and are -under their terms and license. +Shields.io is dual licensed and distributed under the terms of both the [MIT license](LICENSE-MIT) and the [Apache license (Version 2.0)](LICENSE-APACHE). ## Community diff --git a/SECURITY.md b/SECURITY.md index 6d4ba715934a5..435da11190632 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -7,10 +7,10 @@ Please follow this guidance when reporting security issues affecting: - [Shields.io](https://shields.io) - [Raster.shields.io](https://raster.shields.io) - Self-hosted Shields instances -- The [svg-to-image-proxy](https://www.npmjs.com/package/svg-to-image-proxy) NPM package +- The [squint](https://github.com/badges/squint) raster proxy - The [badge-maker](https://www.npmjs.com/package/badge-maker) NPM package -The [gh-badges](https://www.npmjs.com/package/gh-badges) NPM package is now deprecated and will no longer receive fixes for bugs or security issues. +The [gh-badges](https://www.npmjs.com/package/gh-badges) and [svg-to-image-proxy](https://www.npmjs.com/package/svg-to-image-proxy) NPM packages are now deprecated and will no longer receive fixes for bugs or security issues. ## Reporting a Vulnerability @@ -23,3 +23,7 @@ Report security bugs in third-party modules to the person or team maintaining th We aim to patch confirmed vulnerabilities within 90 days or less, disclosing the details of those vulnerabilities when a patch is published. We ask that you refrain from sharing your report with others while we work on our patch. We may want to coordinate an advisory with you to be published simultaneously with the patch, but you are also welcome to self-disclose after 90 days if you prefer. We will never publish information about you or our communications with you without your permission. + +## Bounties + +Everyone who works on shields is an unpaid volunteer. That includes the core team, contributors and people who report security vulnerabilities. This means we are unable to offer bug or security bounties. diff --git a/__snapshots__/make-badge.spec.js b/__snapshots__/make-badge.spec.js index df826a8ca14a3..34daf28d46fc4 100644 --- a/__snapshots__/make-badge.spec.js +++ b/__snapshots__/make-badge.spec.js @@ -1,21 +1,21 @@ exports['The badge generator SVG should match snapshot 1'] = ` cactus: grown + - + - + - - - cactus - - - - grown - + + + cactus + + + + grown + @@ -61,18 +63,18 @@ exports['The badge generator SVG should match snapshot 1'] = ` exports['The badge generator "flat" template badge generation should match snapshots: message/label, no logo 1'] = ` cactus: grown + - + @@ -85,34 +87,36 @@ exports['The badge generator "flat" template badge generation should match snaps text-rendering="geometricPrecision" font-size="110" > - - - cactus - - - - grown - + + + cactus + + + + grown + @@ -121,20 +125,18 @@ exports['The badge generator "flat" template badge generation should match snaps exports['The badge generator "flat" template badge generation should match snapshots: message/label, with logo 1'] = ` cactus: grown + - - - + @@ -152,36 +154,38 @@ exports['The badge generator "flat" template badge generation should match snaps y="3" width="14" height="14" - xlink:href="data:image/svg+xml;base64,PHN2ZyB4bWxu" + href="data:image/svg+xml;base64,PHN2ZyB4bWxu" /> - - - cactus - - - - grown - + + + cactus + + + + grown + @@ -190,18 +194,18 @@ exports['The badge generator "flat" template badge generation should match snaps exports['The badge generator "flat" template badge generation should match snapshots: message only, no logo 1'] = ` grown + - + @@ -214,20 +218,21 @@ exports['The badge generator "flat" template badge generation should match snaps text-rendering="geometricPrecision" font-size="110" > - - - grown - + + + grown + @@ -236,18 +241,18 @@ exports['The badge generator "flat" template badge generation should match snaps exports['The badge generator "flat" template badge generation should match snapshots: message only, with logo 1'] = ` grown + - + @@ -265,22 +270,23 @@ exports['The badge generator "flat" template badge generation should match snaps y="3" width="14" height="14" - xlink:href="data:image/svg+xml;base64,PHN2ZyB4bWxu" + href="data:image/svg+xml;base64,PHN2ZyB4bWxu" /> - - - grown - + + + grown + @@ -289,18 +295,18 @@ exports['The badge generator "flat" template badge generation should match snaps exports['The badge generator "flat" template badge generation should match snapshots: message only, with logo and labelColor 1'] = ` grown + - + @@ -318,39 +324,36 @@ exports['The badge generator "flat" template badge generation should match snaps y="3" width="14" height="14" - xlink:href="data:image/svg+xml;base64,PHN2ZyB4bWxu" + href="data:image/svg+xml;base64,PHN2ZyB4bWxu" /> - - - grown - + + + grown + ` exports['The badge generator "flat" template badge generation should match snapshots: message/label, with links 1'] = ` - + + - + @@ -363,39 +366,41 @@ exports['The badge generator "flat" template badge generation should match snaps text-rendering="geometricPrecision" font-size="110" > - + - - - cactus - + + + cactus + - + - - - grown - + + + grown + @@ -405,18 +410,18 @@ exports['The badge generator "flat" template badge generation should match snaps exports['The badge generator "flat" template badge generation should match snapshots: black text when the label color is light 1'] = ` cactus: grown + - + @@ -429,34 +434,36 @@ exports['The badge generator "flat" template badge generation should match snaps text-rendering="geometricPrecision" font-size="110" > - - - cactus - - - - grown - + + + cactus + + + + grown + @@ -465,18 +472,18 @@ exports['The badge generator "flat" template badge generation should match snaps exports['The badge generator "flat" template badge generation should match snapshots: black text when the message color is light 1'] = ` cactus: grown + - + @@ -489,34 +496,36 @@ exports['The badge generator "flat" template badge generation should match snaps text-rendering="geometricPrecision" font-size="110" > - - - cactus - - - - grown - + + + cactus + + + + grown + @@ -525,7 +534,6 @@ exports['The badge generator "flat" template badge generation should match snaps exports['The badge generator "flat-square" template badge generation should match snapshots: message/label, no logo 1'] = ` - - cactus - - - grown - + cactus + grown @@ -557,7 +561,6 @@ exports['The badge generator "flat-square" template badge generation should matc exports['The badge generator "flat-square" template badge generation should match snapshots: message/label, with logo 1'] = ` - - cactus - - - grown - + cactus + grown @@ -596,7 +595,6 @@ exports['The badge generator "flat-square" template badge generation should matc exports['The badge generator "flat-square" template badge generation should match snapshots: message only, no logo 1'] = ` - - grown - + grown @@ -625,7 +621,6 @@ exports['The badge generator "flat-square" template badge generation should matc exports['The badge generator "flat-square" template badge generation should match snapshots: message only, with logo 1'] = ` - - grown - + grown @@ -661,7 +654,6 @@ exports['The badge generator "flat-square" template badge generation should matc exports['The badge generator "flat-square" template badge generation should match snapshots: message only, with logo and labelColor 1'] = ` - - grown - + grown ` exports['The badge generator "flat-square" template badge generation should match snapshots: message/label, with links 1'] = ` - + @@ -712,17 +697,13 @@ exports['The badge generator "flat-square" template badge generation should matc text-rendering="geometricPrecision" font-size="110" > - + - - cactus - + cactus - + - - grown - + grown @@ -732,7 +713,6 @@ exports['The badge generator "flat-square" template badge generation should matc exports['The badge generator "flat-square" template badge generation should match snapshots: black text when the label color is light 1'] = ` - + cactus - - grown - + grown @@ -764,7 +742,6 @@ exports['The badge generator "flat-square" template badge generation should matc exports['The badge generator "flat-square" template badge generation should match snapshots: black text when the message color is light 1'] = ` - - cactus - - + cactus + grown @@ -796,20 +771,20 @@ exports['The badge generator "flat-square" template badge generation should matc exports['The badge generator "plastic" template badge generation should match snapshots: message/label, no logo 1'] = ` cactus: grown + - + @@ -822,34 +797,36 @@ exports['The badge generator "plastic" template badge generation should match sn text-rendering="geometricPrecision" font-size="110" > - - - cactus - - - - grown - + + + cactus + + + + grown + @@ -858,22 +835,20 @@ exports['The badge generator "plastic" template badge generation should match sn exports['The badge generator "plastic" template badge generation should match snapshots: message/label, with logo 1'] = ` cactus: grown + - - - + @@ -891,36 +866,38 @@ exports['The badge generator "plastic" template badge generation should match sn y="2" width="14" height="14" - xlink:href="data:image/svg+xml;base64,PHN2ZyB4bWxu" + href="data:image/svg+xml;base64,PHN2ZyB4bWxu" /> - - - cactus - - - - grown - + + + cactus + + + + grown + @@ -929,20 +906,20 @@ exports['The badge generator "plastic" template badge generation should match sn exports['The badge generator "plastic" template badge generation should match snapshots: message only, no logo 1'] = ` grown + - + @@ -955,20 +932,21 @@ exports['The badge generator "plastic" template badge generation should match sn text-rendering="geometricPrecision" font-size="110" > - - - grown - + + + grown + @@ -977,20 +955,20 @@ exports['The badge generator "plastic" template badge generation should match sn exports['The badge generator "plastic" template badge generation should match snapshots: message only, with logo 1'] = ` grown + - + @@ -1008,22 +986,23 @@ exports['The badge generator "plastic" template badge generation should match sn y="2" width="14" height="14" - xlink:href="data:image/svg+xml;base64,PHN2ZyB4bWxu" + href="data:image/svg+xml;base64,PHN2ZyB4bWxu" /> - - - grown - + + + grown + @@ -1032,20 +1011,20 @@ exports['The badge generator "plastic" template badge generation should match sn exports['The badge generator "plastic" template badge generation should match snapshots: message only, with logo and labelColor 1'] = ` grown + - + @@ -1063,41 +1042,38 @@ exports['The badge generator "plastic" template badge generation should match sn y="2" width="14" height="14" - xlink:href="data:image/svg+xml;base64,PHN2ZyB4bWxu" + href="data:image/svg+xml;base64,PHN2ZyB4bWxu" /> - - - grown - + + + grown + ` exports['The badge generator "plastic" template badge generation should match snapshots: message/label, with links 1'] = ` - + + - + @@ -1110,39 +1086,41 @@ exports['The badge generator "plastic" template badge generation should match sn text-rendering="geometricPrecision" font-size="110" > - + - - - cactus - + + + cactus + - + - - - grown - + + + grown + @@ -1152,20 +1130,20 @@ exports['The badge generator "plastic" template badge generation should match sn exports['The badge generator "plastic" template badge generation should match snapshots: black text when the label color is light 1'] = ` cactus: grown + - + @@ -1178,34 +1156,36 @@ exports['The badge generator "plastic" template badge generation should match sn text-rendering="geometricPrecision" font-size="110" > - - - cactus - - - - grown - + + + cactus + + + + grown + @@ -1214,20 +1194,20 @@ exports['The badge generator "plastic" template badge generation should match sn exports['The badge generator "plastic" template badge generation should match snapshots: black text when the message color is light 1'] = ` cactus: grown + - + @@ -1240,34 +1220,36 @@ exports['The badge generator "plastic" template badge generation should match sn text-rendering="geometricPrecision" font-size="110" > - - - cactus - - - - grown - + + + cactus + + + + grown + @@ -1276,7 +1258,6 @@ exports['The badge generator "plastic" template badge generation should match sn exports['The badge generator "for-the-badge" template badge generation should match snapshots: message/label, no logo 1'] = ` - - CACTUS - + CACTUS GROWN @@ -1315,7 +1293,6 @@ exports['The badge generator "for-the-badge" template badge generation should ma exports['The badge generator "for-the-badge" template badge generation should match snapshots: message/label, with logo 1'] = ` - - CACTUS - + CACTUS GROWN @@ -1361,7 +1335,6 @@ exports['The badge generator "for-the-badge" template badge generation should ma exports['The badge generator "for-the-badge" template badge generation should match snapshots: message only, no logo 1'] = ` GROWN @@ -1396,7 +1368,6 @@ exports['The badge generator "for-the-badge" template badge generation should ma exports['The badge generator "for-the-badge" template badge generation should match snapshots: message only, with logo 1'] = ` GROWN @@ -1438,7 +1408,6 @@ exports['The badge generator "for-the-badge" template badge generation should ma exports['The badge generator "for-the-badge" template badge generation should match snapshots: message only, with logo and labelColor 1'] = ` GROWN @@ -1479,12 +1447,7 @@ exports['The badge generator "for-the-badge" template badge generation should ma ` exports['The badge generator "for-the-badge" template badge generation should match snapshots: message/label, with links 1'] = ` - + @@ -1496,26 +1459,19 @@ exports['The badge generator "for-the-badge" template badge generation should ma text-rendering="geometricPrecision" font-size="100" > - + - + CACTUS - + GROWN @@ -1529,7 +1485,6 @@ exports['The badge generator "for-the-badge" template badge generation should ma exports['The badge generator "for-the-badge" template badge generation should match snapshots: black text when the label color is light 1'] = ` GROWN @@ -1568,7 +1522,6 @@ exports['The badge generator "for-the-badge" template badge generation should ma exports['The badge generator "for-the-badge" template badge generation should match snapshots: black text when the message color is light 1'] = ` - - CACTUS - + CACTUS GROWN @@ -1607,7 +1558,6 @@ exports['The badge generator "for-the-badge" template badge generation should ma exports['The badge generator "social" template badge generation should match snapshots: message/label, no logo 1'] = ` - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - + - + + +` + +exports['The badge generator "flat-square" template badge generation should match snapshots: message with custom suffix 1'] = ` + + cactus: grown + + + + cactus + grown + + + +` + +exports['The badge generator "plastic" template badge generation should match snapshots: message with custom suffix 1'] = ` + + cactus: grown + + + + + + + + + + + + + + + + + + cactus + + + + grown + + + + +` + +exports['The badge generator "for-the-badge" template badge generation should match snapshots: message with custom suffix 1'] = ` + + CACTUS: GROWN + + + + + + + CACTUS + + GROWN + + + + +` + +exports['The badge generator "social" template badge generation should match snapshots: message with custom suffix 1'] = ` + + Cactus: grown + + + + + + + + + + + + + + + + + ` + +exports['The badge generator badges with logos should always produce the same badge default badge with logo 1'] = ` + + label: message + + + + + + + + + + + + + + + + label + + + + message + + + + +` + +exports['The badge generator badges with logo-only should always produce the same badge flat badge, logo-only 1'] = ` + + + + + + + + + + + + + + + + + + +` + +exports['The badge generator badges with logo-only should always produce the same badge flat-square badge, logo-only 1'] = ` + + + + + + + + + + + +` + +exports['The badge generator badges with logo-only should always produce the same badge social badge, logo-only 1'] = ` + + + + + + + + + + + + + + + + + + +` + +exports['The badge generator badges with logo-only should always produce the same badge plastic badge, logo-only 1'] = ` + + + + + + + + + + + + + + + + + + + + +` + +exports['The badge generator badges with logo-only should always produce the same badge for-the-badge badge, logo-only 1'] = ` + + + + + + + + + + + +` diff --git a/app.json b/app.json deleted file mode 100644 index c3557c7164b7e..0000000000000 --- a/app.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "name": "Shields", - "description": "Concise, consistent, and legible badges in SVG and raster format.", - "keywords": ["badge", "github", "svg", "status"], - "website": "https://shields.io/", - "repository": "https://github.com/badges/shields", - "logo": "https://shields.io/favicon.png", - "env": { - "CYPRESS_INSTALL_BINARY": { - "description": "Disable the cypress binary installation", - "value": "0", - "required": false - }, - "HUSKY_SKIP_INSTALL": { - "description": "Skip the husky git hook setup", - "value": "1", - "required": false - }, - "WHEELMAP_TOKEN": { - "description": "Configure the token to be used for the Wheelmap service.", - "required": false - }, - "GH_TOKEN": { - "description": "Configure the token to be used for the GitHub services.", - "required": false - }, - "TWITCH_CLIENT_ID": { - "description": "Configure the client id to be used for the Twitch service.", - "required": false - }, - "TWITCH_CLIENT_SECRET": { - "description": "Configure the client secret to be used for the Twitch service.", - "required": false - }, - "WEBLATE_API_KEY": { - "description": "Configure the API key to be used for the Weblate service.", - "required": false - } - }, - "formation": { - "web": { - "quantity": 1, - "size": "free" - } - } -} diff --git a/badge-maker/CHANGELOG.md b/badge-maker/CHANGELOG.md index 3cf6c3c37f06f..2b5b275419784 100644 --- a/badge-maker/CHANGELOG.md +++ b/badge-maker/CHANGELOG.md @@ -1,8 +1,58 @@ # Changelog -## 4.0.0 [WIP] +## 6.0.0 -- Drop compatibility with Node 10 +### Visual Changes + +- Generated badges have a new standard palette with higher text contrast. See [this PR](https://github.com/badges/shields/pull/11783) for more information. + +### Other Changes + +- badge-maker was switched from CC0 to MIT and Apache 2.0 licenses. See [this blog post](https://shields.io/blog/mit-apache-license) for more information. +- The structure of the generated SVGs has been optimised. + +## 5.0.2 + +### Bug Fixes + +- Fix export for require + +## 5.0.1 + +### Bug Fixes + +- Fix ESM type exports + +### Other Changes + +- Drop use-strict from badge-maker header + +## 5.0.0 + +### Breaking Changes + +- Drop compatibility with Node < 20 +- Switch from CommonJS to ESModules + +### Other Changes + +- Switching to using `href`s instead of the old `xlink:href` syntax + +## 4.1.0 + +### Features + +- Add `idSuffix` param. This can be used to ensure every element id within the SVG is unique + +## 4.0.0 + +### Breaking Changes + +- Drop compatibility with Node < 16 + +### Features + +- Add `links` and `logoBase64` params ## 3.3.1 @@ -37,8 +87,7 @@ - Deprecated parameters have been removed. In version 2.2.0 the `colorA`, `colorB` and `colorscheme` params were deprecated. In version 3.0.0 these have been removed. - Only SVG output format is now provided. JSON format has been dropped and the `format` key has been removed. - The `text` array has been replaced by `label` and `message` keys. -- The `template` key has been renamed `style`. - To upgrade from v2.1.1, change your code from: +- The `template` key has been renamed `style`. To upgrade from v2.1.1, change your code from: ```js const { BadgeFactory } = require('gh-badges') const bf = new BadgeFactory() @@ -101,7 +150,6 @@ ### Deprecations - `labelColor` and `color` are now the recommended attribute names for label color and message color. - - `colorA` (now an alias for `labelColor`), - `colorB` (now an alias for `color`) and - `colorscheme` (now an alias for `color`) @@ -140,8 +188,7 @@ ## 2.1.0 - 2018-11-18 -gh-badges v2.1.0 implements a new text width measurer which uses a lookup table, removing the dependency -on PDFKit. It is no longer necessary to provide a local copy of Verdana for accurate text width computation. +gh-badges v2.1.0 implements a new text width measurer which uses a lookup table, removing the dependency on PDFKit. It is no longer necessary to provide a local copy of Verdana for accurate text width computation. As such, the `fontPath` and `precomputeWidths` parameters are now deprecated. The recommended call to create an instance of `BadgeFactory` is now @@ -209,8 +256,7 @@ const svg = bf.create(format) ## 2.0.0 - 2018-11-09 -gh-badges v2.0.0 declares a new public interface which is synchronous. -If your version 1.3.0 code looked like this: +gh-badges v2.0.0 declares a new public interface which is synchronous. If your version 1.3.0 code looked like this: ```js const badge = require('gh-badges') @@ -275,7 +321,7 @@ badge.loadFont('/path/to/Verdana.ttf', err => { { text: ['build', 'passed'], colorscheme: 'green', template: 'flat' }, (svg, err) => { // svg is a string containing your badge - } + }, ) }) ``` diff --git a/badge-maker/LICENSE b/badge-maker/LICENSE deleted file mode 100644 index 670154e353886..0000000000000 --- a/badge-maker/LICENSE +++ /dev/null @@ -1,116 +0,0 @@ -CC0 1.0 Universal - -Statement of Purpose - -The laws of most jurisdictions throughout the world automatically confer -exclusive Copyright and Related Rights (defined below) upon the creator and -subsequent owner(s) (each and all, an "owner") of an original work of -authorship and/or a database (each, a "Work"). - -Certain owners wish to permanently relinquish those rights to a Work for the -purpose of contributing to a commons of creative, cultural and scientific -works ("Commons") that the public can reliably and without fear of later -claims of infringement build upon, modify, incorporate in other works, reuse -and redistribute as freely as possible in any form whatsoever and for any -purposes, including without limitation commercial purposes. These owners may -contribute to the Commons to promote the ideal of a free culture and the -further production of creative, cultural and scientific works, or to gain -reputation or greater distribution for their Work in part through the use and -efforts of others. - -For these and/or other purposes and motivations, and without any expectation -of additional consideration or compensation, the person associating CC0 with a -Work (the "Affirmer"), to the extent that he or she is an owner of Copyright -and Related Rights in the Work, voluntarily elects to apply CC0 to the Work -and publicly distribute the Work under its terms, with knowledge of his or her -Copyright and Related Rights in the Work and the meaning and intended legal -effect of CC0 on those rights. - -1. Copyright and Related Rights. A Work made available under CC0 may be -protected by copyright and related or neighboring rights ("Copyright and -Related Rights"). Copyright and Related Rights include, but are not limited -to, the following: - - i. the right to reproduce, adapt, distribute, perform, display, communicate, - and translate a Work; - - ii. moral rights retained by the original author(s) and/or performer(s); - - iii. publicity and privacy rights pertaining to a person's image or likeness - depicted in a Work; - - iv. rights protecting against unfair competition in regards to a Work, - subject to the limitations in paragraph 4(a), below; - - v. rights protecting the extraction, dissemination, use and reuse of data in - a Work; - - vi. database rights (such as those arising under Directive 96/9/EC of the - European Parliament and of the Council of 11 March 1996 on the legal - protection of databases, and under any national implementation thereof, - including any amended or successor version of such directive); and - - vii. other similar, equivalent or corresponding rights throughout the world - based on applicable law or treaty, and any national implementations thereof. - -2. Waiver. To the greatest extent permitted by, but not in contravention of, -applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and -unconditionally waives, abandons, and surrenders all of Affirmer's Copyright -and Related Rights and associated claims and causes of action, whether now -known or unknown (including existing as well as future claims and causes of -action), in the Work (i) in all territories worldwide, (ii) for the maximum -duration provided by applicable law or treaty (including future time -extensions), (iii) in any current or future medium and for any number of -copies, and (iv) for any purpose whatsoever, including without limitation -commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes -the Waiver for the benefit of each member of the public at large and to the -detriment of Affirmer's heirs and successors, fully intending that such Waiver -shall not be subject to revocation, rescission, cancellation, termination, or -any other legal or equitable action to disrupt the quiet enjoyment of the Work -by the public as contemplated by Affirmer's express Statement of Purpose. - -3. Public License Fallback. Should any part of the Waiver for any reason be -judged legally invalid or ineffective under applicable law, then the Waiver -shall be preserved to the maximum extent permitted taking into account -Affirmer's express Statement of Purpose. In addition, to the extent the Waiver -is so judged Affirmer hereby grants to each affected person a royalty-free, -non transferable, non sublicensable, non exclusive, irrevocable and -unconditional license to exercise Affirmer's Copyright and Related Rights in -the Work (i) in all territories worldwide, (ii) for the maximum duration -provided by applicable law or treaty (including future time extensions), (iii) -in any current or future medium and for any number of copies, and (iv) for any -purpose whatsoever, including without limitation commercial, advertising or -promotional purposes (the "License"). The License shall be deemed effective as -of the date CC0 was applied by Affirmer to the Work. Should any part of the -License for any reason be judged legally invalid or ineffective under -applicable law, such partial invalidity or ineffectiveness shall not -invalidate the remainder of the License, and in such case Affirmer hereby -affirms that he or she will not (i) exercise any of his or her remaining -Copyright and Related Rights in the Work or (ii) assert any associated claims -and causes of action with respect to the Work, in either case contrary to -Affirmer's express Statement of Purpose. - -4. Limitations and Disclaimers. - - a. No trademark or patent rights held by Affirmer are waived, abandoned, - surrendered, licensed or otherwise affected by this document. - - b. Affirmer offers the Work as-is and makes no representations or warranties - of any kind concerning the Work, express, implied, statutory or otherwise, - including without limitation warranties of title, merchantability, fitness - for a particular purpose, non infringement, or the absence of latent or - other defects, accuracy, or the present or absence of errors, whether or not - discoverable, all to the greatest extent permissible under applicable law. - - c. Affirmer disclaims responsibility for clearing rights of other persons - that may apply to the Work or any use thereof, including without limitation - any person's Copyright and Related Rights in the Work. Further, Affirmer - disclaims responsibility for obtaining any necessary consents, permissions - or other rights required for any use of the Work. - - d. Affirmer understands and acknowledges that Creative Commons is not a - party to this document and has no duty or obligation with respect to this - CC0 or use of the Work. - -For more information, please see - diff --git a/badge-maker/LICENSE-APACHE b/badge-maker/LICENSE-APACHE new file mode 100644 index 0000000000000..4c2e465d106a3 --- /dev/null +++ b/badge-maker/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright Thaddée Tyl, Paul Melnikow, Pierre-Yves Bigourdan and contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/badge-maker/LICENSE-MIT b/badge-maker/LICENSE-MIT new file mode 100644 index 0000000000000..bcb82f0bc732d --- /dev/null +++ b/badge-maker/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Thaddée Tyl, Paul Melnikow, Pierre-Yves Bigourdan and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/badge-maker/README.md b/badge-maker/README.md index a6d2e71466d84..1826b701c8ac6 100644 --- a/badge-maker/README.md +++ b/badge-maker/README.md @@ -1,8 +1,6 @@ # badge-maker -[![npm version](https://img.shields.io/npm/v/badge-maker.svg)](https://npmjs.org/package/badge-maker) -[![npm license](https://img.shields.io/npm/l/badge-maker.svg)](https://npmjs.org/package/badge-maker) -[![npm type definitions](https://img.shields.io/npm/types/badge-maker)](https://npmjs.org/package/badge-maker) +[![npm version](https://img.shields.io/npm/v/badge-maker.svg)](https://npmjs.org/package/badge-maker) [![npm license](https://img.shields.io/npm/l/badge-maker.svg)](https://npmjs.org/package/badge-maker) [![npm type definitions](https://img.shields.io/npm/types/badge-maker)](https://npmjs.org/package/badge-maker) ## Installation @@ -16,20 +14,12 @@ npm install badge-maker ```sh npm install -g badge-maker -badge build passed :green > mybadge.svg +badge build passed :brightgreen > mybadge.svg ``` ### As a library -With CommonJS in JavaScript, - ```js -const { makeBadge, ValidationError } = require('badge-maker') -``` - -With ESM or TypeScript, - -```ts import { makeBadge, ValidationError } from 'badge-maker' ``` @@ -37,7 +27,7 @@ import { makeBadge, ValidationError } from 'badge-maker' const format = { label: 'build', message: 'passed', - color: 'green', + color: 'brightgreen', } const svg = makeBadge(format) @@ -52,8 +42,7 @@ try { ### Node version support -The latest version of badge-maker supports all currently maintained Node -versions. See the [Node Release Schedule][]. +The latest version of badge-maker supports all currently maintained Node versions. See the [Node Release Schedule][]. [node release schedule]: https://github.com/nodejs/Release#release-schedule @@ -66,11 +55,18 @@ The format is the following: label: 'build', // (Optional) Badge label message: 'passed', // (Required) Badge message labelColor: '#555', // (Optional) Label color - color: '#4c1', // (Optional) Message color + color: '#4c0', // (Optional) Message color + logoBase64: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2NCIgaGVpZ2h0PSI2NCI+PHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiByeD0iOCIgZmlsbD0iI2IxY2U1NiIvPjxwYXRoIGQ9Ik04IDBoMjR2NjRIOGMtNC40MzIgMC04LTMuNTY4LTgtOFY4YzAtNC40MzIgMy41NjgtOCA4LTh6IiBmaWxsPSIjNWQ1ZDVkIi8+PC9zdmc+', // (Optional) Any custom logo can be passed in a URL parameter by base64 encoding + links: ['https://example.com', 'https://example.com'], // (Optional) Links array of maximum two links // (Optional) One of: 'plastic', 'flat', 'flat-square', 'for-the-badge' or 'social' // Each offers a different visual design. style: 'flat', + + // (Optional) A string with only letters, numbers, -, and _. This can be used + // to ensure every element id within the SVG is unique and prevent CSS + // cross-contamination when the SVG badge is rendered inline in HTML pages. + idSuffix: 'dd' } ``` @@ -134,9 +130,4 @@ There are three ways to specify `color` and `labelColor`: ## Raster Formats -Conversion to raster formats is no longer directly supported. In javascript -code, SVG badges can be converted to raster formats using a library like -[gm](https://www.npmjs.com/package/gm). On the console, the output of `badge` -can be piped to a utility like -[imagemagick](https://imagemagick.org/script/command-line-processing.php) -e.g: `badge build passed :green | magick svg:- gif:-`. +Conversion to raster formats is no longer directly supported. In javascript code, SVG badges can be converted to raster formats using a library like [gm](https://www.npmjs.com/package/gm). On the console, the output of `badge` can be piped to a utility like [imagemagick](https://imagemagick.org/script/command-line-processing.php) e.g: `badge build passed :green | magick svg:- gif:-`. diff --git a/badge-maker/index.d.ts b/badge-maker/index.d.ts index 38d71e22b8344..96ad5332a770c 100644 --- a/badge-maker/index.d.ts +++ b/badge-maker/index.d.ts @@ -4,6 +4,9 @@ interface Format { labelColor?: string color?: string style?: 'plastic' | 'flat' | 'flat-square' | 'for-the-badge' | 'social' + logoBase64?: string + links?: Array + idSuffix?: string } export declare class ValidationError extends Error {} diff --git a/badge-maker/index.test-d.ts b/badge-maker/index.test-d.ts index 9897e0798188a..7e9246cd975b3 100644 --- a/badge-maker/index.test-d.ts +++ b/badge-maker/index.test-d.ts @@ -7,19 +7,19 @@ expectError( makeBadge({ message: 'passed', style: 'invalid style', - }) + }), ) expectType( makeBadge({ message: 'passed', - }) + }), ) expectType( makeBadge({ label: 'build', message: 'passed', - }) + }), ) expectType( makeBadge({ @@ -28,7 +28,7 @@ expectType( labelColor: 'green', color: 'red', style: 'flat', - }) + }), ) const error = new ValidationError() diff --git a/badge-maker/lib/badge-cli.js b/badge-maker/lib/badge-cli.js index a930a01e4c867..ecfef4e4369bb 100755 --- a/badge-maker/lib/badge-cli.js +++ b/badge-maker/lib/badge-cli.js @@ -1,9 +1,7 @@ #!/usr/bin/env node -'use strict' - -const { namedColors } = require('./color') -const { makeBadge } = require('./index') +import { namedColors } from './color.js' +import { makeBadge } from './index.js' if (process.argv.length < 4) { console.log('Usage: badge label message [:color] [@style]') diff --git a/badge-maker/lib/badge-cli.spec.js b/badge-maker/lib/badge-cli.spec.js index 4b6fbb3dc9f90..91ee7ac2b9537 100644 --- a/badge-maker/lib/badge-cli.spec.js +++ b/badge-maker/lib/badge-cli.spec.js @@ -1,25 +1,28 @@ -'use strict' +import path from 'path' +import { fileURLToPath } from 'url' +import { execFile } from 'child_process' +import { promisify } from 'util' +import { expect, use } from 'chai' +import sinonChai from 'sinon-chai' +use(sinonChai) -const path = require('path') -const isSvg = require('is-svg') -const { spawn } = require('child-process-promise') -const { expect, use } = require('chai') -use(require('chai-string')) -use(require('sinon-chai')) +const dirName = path.dirname(fileURLToPath(import.meta.url)) function runCli(args) { - return spawn('node', [path.join(__dirname, 'badge-cli.js'), ...args], { - capture: ['stdout'], - }) + return promisify(execFile)('node', [ + path.join(dirName, 'badge-cli.js'), + ...args, + ]) } describe('The CLI', function () { it('should provide a help message', async function () { const { stdout } = await runCli([]) - expect(stdout).to.startWith('Usage') + expect(stdout.startsWith('Usage')).to.be.true }) it('should produce default badges', async function () { + const { default: isSvg } = await import('is-svg') const { stdout } = await runCli(['cactus', 'grown']) expect(stdout) .to.satisfy(isSvg) @@ -28,11 +31,13 @@ describe('The CLI', function () { }) it('should produce colorschemed badges', async function () { + const { default: isSvg } = await import('is-svg') const { stdout } = await runCli(['cactus', 'grown', ':green']) expect(stdout).to.satisfy(isSvg) }) it('should produce right-color badges', async function () { + const { default: isSvg } = await import('is-svg') const { stdout } = await runCli(['cactus', 'grown', '#abcdef']) expect(stdout).to.satisfy(isSvg).and.to.include('#abcdef') }) diff --git a/badge-maker/lib/badge-renderers.js b/badge-maker/lib/badge-renderers.js index 56e3792b8b6fb..a594dfe961ce2 100644 --- a/badge-maker/lib/badge-renderers.js +++ b/badge-maker/lib/badge-renderers.js @@ -1,8 +1,6 @@ -'use strict' - -const anafanafo = require('anafanafo') -const { brightness } = require('./color') -const { XmlElement, ElementList } = require('./xml') +import anafanafo from 'anafanafo' +import { brightness } from './color.js' +import { XmlElement, ElementList } from './xml.js' // https://github.com/badges/shields/pull/1132 const FONT_SCALE_UP_FACTOR = 10 @@ -12,6 +10,8 @@ const FONT_FAMILY = 'Verdana,Geneva,DejaVu Sans,sans-serif' const WIDTH_FONT = '11px Verdana' const SOCIAL_FONT_FAMILY = 'Helvetica Neue,Helvetica,Arial,sans-serif' +const DEFAULT_TEXT_FILL = '#fff' + function capitalize(s) { return `${s.charAt(0).toUpperCase()}${s.slice(1)}` } @@ -19,12 +19,18 @@ function capitalize(s) { function colorsForBackground(color) { const brightnessThreshold = 0.69 if (brightness(color) <= brightnessThreshold) { - return { textColor: '#fff', shadowColor: '#010101' } + return { textColor: DEFAULT_TEXT_FILL, shadowColor: '#010101' } } else { return { textColor: '#333', shadowColor: '#ccc' } } } +function withTextFill(attrs, textColor) { + // For non-social badges, parent text groups set fill="#fff", so child text fill + // attributes that match can be omitted to shrink the SVG. + return textColor === DEFAULT_TEXT_FILL ? attrs : { ...attrs, fill: textColor } +} + function roundUpToOdd(val) { return val % 2 === 0 ? val + 1 : val } @@ -62,14 +68,14 @@ function getLogoElement({ logo, horizPadding, badgeHeight, logoWidth }) { y: 0.5 * (badgeHeight - logoHeight), width: logoWidth, height: logoHeight, - 'xlink:href': logo, + href: logo, }, }) } function renderBadge( { links, leftWidth, rightWidth, height, accessibleText }, - content + content, ) { const width = leftWidth + rightWidth const leftLink = links[0] @@ -83,13 +89,12 @@ function renderBadge( ? new XmlElement({ name: 'a', content, - attrs: { target: '_blank', 'xlink:href': leftLink }, + attrs: { target: '_blank', href: leftLink }, }) : new ElementList({ content }) const svgAttrs = { xmlns: 'http://www.w3.org/2000/svg', - 'xmlns:xlink': 'http://www.w3.org/1999/xlink', width, height, } @@ -126,8 +131,9 @@ class Badge { logo, logoWidth, logoPadding, - color = '#4c1', + color = '#4b0', labelColor, + idSuffix = '', }) { const horizPadding = 5 const hasLogo = !!logo @@ -159,7 +165,7 @@ class Badge { } let rightWidth = messageWidth + 2 * horizPadding if (hasLogo && !hasLabel) { - rightWidth += totalLogoWidth + horizPadding - 1 + rightWidth += totalLogoWidth + (message.length ? horizPadding - 1 : 0) } const width = leftWidth + rightWidth @@ -178,6 +184,7 @@ class Badge { this.label = label this.message = message this.accessibleText = accessibleText + this.idSuffix = idSuffix this.logoElement = getLogoElement({ logo, @@ -198,36 +205,56 @@ class Badge { const { textColor, shadowColor } = colorsForBackground(color) const x = FONT_SCALE_UP_FACTOR * (leftMargin + 0.5 * textWidth + this.horizPadding) - - const text = new XmlElement({ - name: 'text', - content: [content], - attrs: { - x, - y: 140 + this.constructor.verticalMargin, - transform: FONT_SCALE_DOWN_VALUE, - fill: textColor, - textLength: FONT_SCALE_UP_FACTOR * textWidth, - }, - }) - - const shadowText = new XmlElement({ - name: 'text', - content: [content], - attrs: { - 'aria-hidden': 'true', - x, - y: 150 + this.constructor.verticalMargin, - fill: shadowColor, - 'fill-opacity': '.3', - transform: FONT_SCALE_DOWN_VALUE, - textLength: FONT_SCALE_UP_FACTOR * textWidth, - }, - }) - const shadow = this.constructor.shadow ? shadowText : '' + const y = 140 + this.constructor.verticalMargin + const textLength = FONT_SCALE_UP_FACTOR * textWidth + + let element + if (this.constructor.shadow) { + const text = new XmlElement({ + name: 'text', + content: [content], + attrs: withTextFill({ x, y, textLength }, textColor), + }) + const shadowY = y + 10 + const shadowText = new XmlElement({ + name: 'text', + content: [content], + attrs: { x, y: shadowY, 'fill-opacity': '.3', textLength }, + }) + const shadowBlur = new XmlElement({ + name: 'text', + content: [content], + attrs: { + x, + y: shadowY, + 'fill-opacity': '.8', + filter: 'url(#blur)', + textLength, + }, + }) + const shadowGroup = new XmlElement({ + name: 'g', + content: [shadowBlur, shadowText], + attrs: { 'aria-hidden': 'true', fill: shadowColor }, + }) + element = new XmlElement({ + name: 'g', + content: [shadowGroup, text], + attrs: { transform: FONT_SCALE_DOWN_VALUE }, + }) + } else { + element = new XmlElement({ + name: 'text', + content: [content], + attrs: withTextFill( + { x, y, textLength, transform: FONT_SCALE_DOWN_VALUE }, + textColor, + ), + }) + } if (!link) { - return new ElementList({ content: [shadow, text] }) + return element } const rect = new XmlElement({ @@ -241,8 +268,8 @@ class Badge { }) return new XmlElement({ name: 'a', - content: [rect, shadow, text], - attrs: { target: '_blank', 'xlink:href': link }, + content: [rect, element], + attrs: { target: '_blank', href: link }, }) } @@ -282,11 +309,10 @@ class Badge { width: this.width, height: this.constructor.height, rx, - fill: '#fff', }, }), ], - attrs: { id: 'r' }, + attrs: { id: `r${this.idSuffix}` }, }) } @@ -313,7 +339,7 @@ class Badge { attrs: { width: this.width, height: this.constructor.height, - fill: 'url(#s)', + fill: `url(#s${this.idSuffix})`, }, }) const content = withGradient @@ -331,7 +357,7 @@ class Badge { this.getMessageElement(), ], attrs: { - fill: '#fff', + fill: DEFAULT_TEXT_FILL, 'text-anchor': 'middle', 'font-family': FONT_FAMILY, 'text-rendering': 'geometricPrecision', @@ -340,6 +366,19 @@ class Badge { }) } + getBlurElement() { + return new XmlElement({ + name: 'filter', + content: [ + new XmlElement({ + name: 'feGaussianBlur', + attrs: { stdDeviation: '16' }, + }), + ], + attrs: { id: 'blur' }, + }) + } + render() { throw new Error('Not implemented') } @@ -379,14 +418,16 @@ class Plastic extends Badge { attrs: { offset: 1, 'stop-color': '#000', 'stop-opacity': '.5' }, }), ], - attrs: { id: 's', x2: 0, y2: '100%' }, + attrs: { id: `s${this.idSuffix}`, x2: 0, y2: '100%' }, }) + const blur = this.getBlurElement() + const clipPath = this.getClipPathElement(4) const backgroundGroup = this.getBackgroundGroupElement({ withGradient: true, - attrs: { 'clip-path': 'url(#r)' }, + attrs: { 'clip-path': `url(#r${this.idSuffix})` }, }) return renderBadge( @@ -397,7 +438,7 @@ class Plastic extends Badge { accessibleText: this.accessibleText, height: this.constructor.height, }, - [gradient, clipPath, backgroundGroup, this.foregroundGroupElement] + [blur, gradient, clipPath, backgroundGroup, this.foregroundGroupElement], ) } } @@ -416,6 +457,8 @@ class Flat extends Badge { } render() { + const blur = this.getBlurElement() + const gradient = new XmlElement({ name: 'linearGradient', content: [ @@ -428,14 +471,14 @@ class Flat extends Badge { attrs: { offset: 1, 'stop-opacity': '.1' }, }), ], - attrs: { id: 's', x2: 0, y2: '100%' }, + attrs: { id: `s${this.idSuffix}`, x2: 0, y2: '100%' }, }) const clipPath = this.getClipPathElement(3) const backgroundGroup = this.getBackgroundGroupElement({ withGradient: true, - attrs: { 'clip-path': 'url(#r)' }, + attrs: { 'clip-path': `url(#r${this.idSuffix})` }, }) return renderBadge( @@ -446,7 +489,7 @@ class Flat extends Badge { accessibleText: this.accessibleText, height: this.constructor.height, }, - [gradient, clipPath, backgroundGroup, this.foregroundGroupElement] + [blur, gradient, clipPath, backgroundGroup, this.foregroundGroupElement], ) } } @@ -478,7 +521,7 @@ class FlatSquare extends Badge { accessibleText: this.accessibleText, height: this.constructor.height, }, - [backgroundGroup, this.foregroundGroupElement] + [backgroundGroup, this.foregroundGroupElement], ) } } @@ -490,8 +533,9 @@ function social({ logo, logoWidth, logoPadding, - color = '#4c1', + color = '#4b0', labelColor = '#555', + idSuffix = '', }) { // Social label is styled with a leading capital. Convert to caps here so // width can be measured using the correct characters. @@ -526,7 +570,7 @@ function social({ name: 'rect', attrs: { x: messageBubbleMainX, - y: 0.5, + y: '.5', width: messageRectWidth, height: internalHeight, rx: 2, @@ -538,7 +582,7 @@ function social({ attrs: { x: messageBubbleNotchX, y: 7.5, - width: 0.5, + width: '.5', height: 5, stroke: '#fafafa', }, @@ -547,7 +591,6 @@ function social({ name: 'path', attrs: { d: `M${messageBubbleMainX} 6.5 l-3 3v1 l3 3`, - stroke: 'd5d5d5', fill: '#fafafa', }, }), @@ -565,9 +608,9 @@ function social({ const rect = new XmlElement({ name: 'rect', attrs: { - id: 'llink', + id: `llink${idSuffix}`, stroke: '#d5d5d5', - fill: 'url(#a)', + fill: `url(#a${idSuffix})`, x: '.5', y: '.5', width: labelRectWidth, @@ -602,7 +645,7 @@ function social({ ? new XmlElement({ name: 'a', content: [shadow, text, rect], - attrs: { target: '_blank', 'xlink:href': leftLink }, + attrs: { target: '_blank', href: leftLink }, }) : new ElementList({ content: [rect, shadow, text] }) } @@ -640,7 +683,7 @@ function social({ name: 'text', content: [message], attrs: { - id: 'rlink', + id: `rlink${idSuffix}`, x: messageTextX, y: 140, transform: FONT_SCALE_DOWN_VALUE, @@ -652,7 +695,7 @@ function social({ ? new XmlElement({ name: 'a', content: [rect, shadow, text], - attrs: { target: '_blank', 'xlink:href': rightLink }, + attrs: { target: '_blank', href: rightLink }, }) : new ElementList({ content: [shadow, text] }) } @@ -660,7 +703,7 @@ function social({ const style = new XmlElement({ name: 'style', content: [ - 'a:hover #llink{fill:url(#b);stroke:#ccc}a:hover #rlink{fill:#4183c4}', + `a:hover #llink${idSuffix}{fill:url(#b${idSuffix});stroke:#ccc}a:hover #rlink${idSuffix}{fill:#4183c4}`, ], }) const gradients = new ElementList({ @@ -681,7 +724,7 @@ function social({ attrs: { offset: 1, 'stop-opacity': '.1' }, }), ], - attrs: { id: 'a', x2: 0, y2: '100%' }, + attrs: { id: `a${idSuffix}`, x2: 0, y2: '100%' }, }), new XmlElement({ name: 'linearGradient', @@ -695,7 +738,7 @@ function social({ attrs: { offset: 1, 'stop-opacity': '.1' }, }), ], - attrs: { id: 'b', x2: 0, y2: '100%' }, + attrs: { id: `b${idSuffix}`, x2: 0, y2: '100%' }, }), ], }) @@ -704,8 +747,8 @@ function social({ attrs: { stroke: 'none', fill: '#fcfcfc', - x: 0.5, - y: 0.5, + x: '.5', + y: '.5', width: labelRectWidth, height: internalHeight, rx: 2, @@ -748,7 +791,7 @@ function social({ accessibleText, height: externalHeight, }, - [style, gradients, backgroundGroup, logoElement, foregroundGroup] + [style, gradients, backgroundGroup, logoElement, foregroundGroup], ) } @@ -758,7 +801,7 @@ function forTheBadge({ links, logo, logoWidth, - color = '#4c1', + color = '#4b0', labelColor, }) { const FONT_SIZE = 10 @@ -801,11 +844,13 @@ function forTheBadge({ // there is no label. When `needsLabelRect` is true, render a label rect and a // message rect; when false, only a message rect. const hasLabel = Boolean(label.length) + const noText = !hasLabel && !message const needsLabelRect = hasLabel || (logo && labelColor) + const gutter = noText ? LOGO_TEXT_GUTTER - LOGO_MARGIN : LOGO_TEXT_GUTTER let logoMinX, labelTextMinX if (logo) { logoMinX = LOGO_MARGIN - labelTextMinX = logoMinX + logoWidth + LOGO_TEXT_GUTTER + labelTextMinX = logoMinX + logoWidth + gutter } else { labelTextMinX = TEXT_MARGIN } @@ -820,9 +865,8 @@ function forTheBadge({ messageRectWidth = 2 * TEXT_MARGIN + messageTextWidth } else { if (logo) { - messageTextMinX = TEXT_MARGIN + logoWidth + LOGO_TEXT_GUTTER - messageRectWidth = - 2 * TEXT_MARGIN + logoWidth + LOGO_TEXT_GUTTER + messageTextWidth + messageTextMinX = TEXT_MARGIN + logoWidth + gutter + messageRectWidth = 2 * TEXT_MARGIN + logoWidth + gutter + messageTextWidth } else { messageTextMinX = TEXT_MARGIN messageRectWidth = 2 * TEXT_MARGIN + messageTextWidth @@ -842,13 +886,15 @@ function forTheBadge({ const text = new XmlElement({ name: 'text', content: [label], - attrs: { - transform: FONT_SCALE_DOWN_VALUE, - x: FONT_SCALE_UP_FACTOR * midX, - y: 175, - textLength: FONT_SCALE_UP_FACTOR * labelTextWidth, - fill: textColor, - }, + attrs: withTextFill( + { + transform: FONT_SCALE_DOWN_VALUE, + x: FONT_SCALE_UP_FACTOR * midX, + y: 175, + textLength: FONT_SCALE_UP_FACTOR * labelTextWidth, + }, + textColor, + ), }) if (hasLeftLink && !shouldWrapBodyWithLink({ links })) { @@ -865,7 +911,7 @@ function forTheBadge({ content: [rect, text], attrs: { target: '_blank', - 'xlink:href': leftLink, + href: leftLink, }, }) } else { @@ -879,14 +925,16 @@ function forTheBadge({ const text = new XmlElement({ name: 'text', content: [message], - attrs: { - transform: FONT_SCALE_DOWN_VALUE, - x: FONT_SCALE_UP_FACTOR * midX, - y: 175, - textLength: FONT_SCALE_UP_FACTOR * messageTextWidth, - fill: textColor, - 'font-weight': 'bold', - }, + attrs: withTextFill( + { + transform: FONT_SCALE_DOWN_VALUE, + x: FONT_SCALE_UP_FACTOR * midX, + y: 175, + textLength: FONT_SCALE_UP_FACTOR * messageTextWidth, + 'font-weight': 'bold', + }, + textColor, + ), }) if (hasRightLink) { @@ -904,7 +952,7 @@ function forTheBadge({ content: [rect, text], attrs: { target: '_blank', - 'xlink:href': rightLink, + href: rightLink, }, }) } else { @@ -961,7 +1009,7 @@ function forTheBadge({ getMessageElement(), ], attrs: { - fill: '#fff', + fill: DEFAULT_TEXT_FILL, 'text-anchor': 'middle', 'font-family': FONT_FAMILY, 'text-rendering': 'geometricPrecision', @@ -978,11 +1026,11 @@ function forTheBadge({ accessibleText: createAccessibleText({ label, message }), height: BADGE_HEIGHT, }, - [backgroundGroup, foregroundGroup] + [backgroundGroup, foregroundGroup], ) } -module.exports = { +export default { plastic: params => Plastic.render(params), flat: params => Flat.render(params), 'flat-square': params => FlatSquare.render(params), diff --git a/badge-maker/lib/color.js b/badge-maker/lib/color.js index 391e28e4984a4..1935650c80f6c 100644 --- a/badge-maker/lib/color.js +++ b/badge-maker/lib/color.js @@ -1,18 +1,16 @@ -'use strict' - -const { fromString } = require('css-color-converter') +import { fromString } from 'css-color-converter' // When updating these, be sure also to update the list in `badge-maker/README.md`. -const namedColors = { - brightgreen: '#4c1', - green: '#97ca00', - yellow: '#dfb317', - yellowgreen: '#a4a61d', - orange: '#fe7d37', - red: '#e05d44', +export const namedColors = { + brightgreen: '#4b0', + green: '#67ac09', + yellow: '#d8b800', + yellowgreen: '#95991a', + orange: '#ea7233', + red: '#dd4343', blue: '#007ec6', grey: '#555', - lightgrey: '#9f9f9f', + lightgrey: '#939393', } const aliases = { @@ -33,7 +31,7 @@ Object.entries(aliases).forEach(([alias, original]) => { // This function returns false for `#ccc`. However `isCSSColor('#ccc')` is // true. const hexColorRegex = /^([\da-f]{3}){1,2}$/i -function isHexColor(s = '') { +export function isHexColor(s = '') { return hexColorRegex.test(s) } @@ -41,7 +39,7 @@ function isCSSColor(color) { return typeof color === 'string' && fromString(color.trim()) } -function normalizeColor(color) { +export function normalizeColor(color) { if (color === undefined) { return undefined } else if (color in namedColors) { @@ -49,7 +47,7 @@ function normalizeColor(color) { } else if (color in aliases) { return aliases[color] } else if (isHexColor(color)) { - return `#${color.toLowerCase()}` + return `#${color.toString().toLowerCase()}` } else if (isCSSColor(color)) { return color.toLowerCase() } else { @@ -57,7 +55,7 @@ function normalizeColor(color) { } } -function toSvgColor(color) { +export function toSvgColor(color) { const normalized = normalizeColor(color) if (normalized in namedColors) { return namedColors[normalized] @@ -68,7 +66,7 @@ function toSvgColor(color) { } } -function brightness(color) { +export function brightness(color) { if (color) { const cssColor = fromString(color) if (cssColor) { @@ -78,11 +76,3 @@ function brightness(color) { } return 0 } - -module.exports = { - namedColors, - isHexColor, - normalizeColor, - toSvgColor, - brightness, -} diff --git a/badge-maker/lib/color.spec.js b/badge-maker/lib/color.spec.js index 65326f9d56854..eaef02adcaa3e 100644 --- a/badge-maker/lib/color.spec.js +++ b/badge-maker/lib/color.spec.js @@ -1,15 +1,8 @@ -'use strict' - -const { test, given, forCases } = require('sazerac') -const { - isHexColor, - normalizeColor, - toSvgColor, - brightness, -} = require('./color') +import { test, given, forCases } from 'sazerac' +import { isHexColor, normalizeColor, toSvgColor, brightness } from './color.js' test(isHexColor, () => { - forCases([given('f00bae'), given('4c1'), given('ABC123')]).expect(true) + forCases([given('f00bae'), given('4b0'), given('ABC123')]).expect(true) forCases([ given('f00bar'), given(''), @@ -25,8 +18,10 @@ test(isHexColor, () => { test(normalizeColor, () => { given('red').expect('red') given('blue').expect('blue') - given('4c1').expect('#4c1') + given('4b0').expect('#4b0') given('f00f00').expect('#f00f00') + given('111111').expect('#111111') + given(111111).expect('#111111') given('ABC123').expect('#abc123') given('#ccc').expect('#ccc') given('#fffe').expect('#fffe') @@ -66,18 +61,18 @@ test(normalizeColor, () => { }) test(toSvgColor, () => { - given('red').expect('#e05d44') + given('red').expect('#dd4343') given('blue').expect('#007ec6') - given('4c1').expect('#4c1') + given('4b0').expect('#4b0') given('f00f00').expect('#f00f00') given('ABC123').expect('#abc123') given('#ABC123').expect('#abc123') given('papayawhip').expect('papayawhip') given('purple').expect('purple') forCases([given(''), given(undefined), given('not-a-color')]).expect( - undefined + undefined, ) - given('lightgray').expect('#9f9f9f') + given('lightgray').expect('#939393') given('informational').expect('#007ec6') }) diff --git a/badge-maker/lib/constants.js b/badge-maker/lib/constants.js new file mode 100644 index 0000000000000..6119380f6b946 --- /dev/null +++ b/badge-maker/lib/constants.js @@ -0,0 +1 @@ +export const DEFAULT_LOGO_HEIGHT = 14 diff --git a/badge-maker/lib/index.js b/badge-maker/lib/index.js index f4eed2c5806cd..8db5b42ce89d9 100644 --- a/badge-maker/lib/index.js +++ b/badge-maker/lib/index.js @@ -1,11 +1,10 @@ -'use strict' /** * @module badge-maker */ -const _makeBadge = require('./make-badge') +import _makeBadge from './make-badge.js' -class ValidationError extends Error {} +export class ValidationError extends Error {} function _validate(format) { if (format !== Object(format)) { @@ -16,13 +15,30 @@ function _validate(format) { throw new ValidationError('Field `message` is required') } - const stringFields = ['labelColor', 'color', 'message', 'label'] + const stringFields = ['labelColor', 'color', 'message', 'label', 'logoBase64'] stringFields.forEach(function (field) { if (field in format && typeof format[field] !== 'string') { throw new ValidationError(`Field \`${field}\` must be of type string`) } }) + if ('links' in format) { + if (!Array.isArray(format.links)) { + throw new ValidationError('Field `links` must be an array of strings') + } else { + if (format.links.length > 2) { + throw new ValidationError( + 'Field `links` must not have more than 2 elements', + ) + } + format.links.forEach(function (field) { + if (typeof field !== 'string') { + throw new ValidationError('Field `links` must be an array of strings') + } + }) + } + } + const styleValues = [ 'plastic', 'flat', @@ -32,21 +48,37 @@ function _validate(format) { ] if ('style' in format && !styleValues.includes(format.style)) { throw new ValidationError( - `Field \`style\` must be one of (${styleValues.toString()})` + `Field \`style\` must be one of (${styleValues.toString()})`, + ) + } + if ('idSuffix' in format && !/^[a-zA-Z0-9\-_]*$/.test(format.idSuffix)) { + throw new ValidationError( + 'Field `idSuffix` must contain only numbers, letters, -, and _', ) } } function _clean(format) { - const expectedKeys = ['label', 'message', 'labelColor', 'color', 'style'] + const expectedKeys = [ + 'label', + 'message', + 'labelColor', + 'color', + 'style', + 'logoBase64', + 'links', + 'idSuffix', + ] const cleaned = {} Object.keys(format).forEach(key => { - if (format[key] != null && expectedKeys.includes(key)) { + if (format[key] != null && key === 'logoBase64') { + cleaned.logo = format[key] + } else if (format[key] != null && expectedKeys.includes(key)) { cleaned[key] = format[key] } else { throw new ValidationError( - `Unexpected field '${key}'. Allowed values are (${expectedKeys.toString()})` + `Unexpected field '${key}'. Allowed values are (${expectedKeys.toString()})`, ) } }) @@ -65,17 +97,15 @@ function _clean(format) { * @param {string} format.message (Required) Badge message (e.g: 'passing') * @param {string} format.labelColor (Optional) Label color * @param {string} format.color (Optional) Message color - * @param {string} format.style (Optional) Visual style e.g: 'flat' + * @param {string} format.style (Optional) Visual style (e.g: 'flat') + * @param {string} format.logoBase64 (Optional) Logo data URL + * @param {Array} format.links (Optional) Links array (e.g: ['https://example.com', 'https://example.com']) + * @param {string} format.idSuffix (Optional) Suffix for IDs, e.g. 1, 2, and 3 for three invocations that will be used on the same page. * @returns {string} Badge in SVG format * @see https://github.com/badges/shields/tree/master/badge-maker/README.md */ -function makeBadge(format) { +export function makeBadge(format) { _validate(format) const cleanedFormat = _clean(format) return _makeBadge(cleanedFormat) } - -module.exports = { - makeBadge, - ValidationError, -} diff --git a/badge-maker/lib/index.spec.js b/badge-maker/lib/index.spec.js index 68da62655b182..aafedc80dfdf0 100644 --- a/badge-maker/lib/index.spec.js +++ b/badge-maker/lib/index.spec.js @@ -1,21 +1,19 @@ -'use strict' - -const { expect } = require('chai') -const isSvg = require('is-svg') -const { makeBadge, ValidationError } = require('.') +import { expect } from 'chai' +import { makeBadge, ValidationError } from './index.js' describe('makeBadge function', function () { - it('should produce badge with valid input', function () { + it('should produce badge with valid input', async function () { + const { default: isSvg } = await import('is-svg') expect( makeBadge({ label: 'build', message: 'passed', - }) + }), ).to.satisfy(isSvg) expect( makeBadge({ message: 'passed', - }) + }), ).to.satisfy(isSvg) expect( makeBadge({ @@ -23,8 +21,23 @@ describe('makeBadge function', function () { message: 'passed', color: 'green', style: 'flat', - }) + }), ).to.satisfy(isSvg) + expect( + makeBadge({ + label: 'build', + message: 'passed', + color: 'green', + style: 'flat', + labelColor: 'blue', + logoBase64: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', + links: ['https://example.com', 'https://example.com'], + }), + ) + .to.satisfy(isSvg) + // explicitly make an assertion about logoBase64 + // this param is not a straight passthrough + .and.to.include('data:image/svg+xml;base64,PHN2ZyB4bWxu') }) it('should throw a ValidationError with invalid inputs', function () { @@ -32,44 +45,59 @@ describe('makeBadge function', function () { console.log(x) expect(() => makeBadge(x)).to.throw( ValidationError, - 'makeBadge takes an argument of type object' + 'makeBadge takes an argument of type object', ) }) expect(() => makeBadge({})).to.throw( ValidationError, - 'Field `message` is required' + 'Field `message` is required', ) expect(() => makeBadge({ label: 'build' })).to.throw( ValidationError, - 'Field `message` is required' + 'Field `message` is required', ) expect(() => - makeBadge({ label: 'build', message: 'passed', labelColor: 7 }) + makeBadge({ label: 'build', message: 'passed', labelColor: 7 }), ).to.throw(ValidationError, 'Field `labelColor` must be of type string') expect(() => - makeBadge({ label: 'build', message: 'passed', format: 'png' }) + makeBadge({ label: 'build', message: 'passed', logoBase64: 7 }), + ).to.throw(ValidationError, 'Field `logoBase64` must be of type string') + expect(() => + makeBadge({ label: 'build', message: 'passed', links: 'test' }), + ).to.throw(ValidationError, 'Field `links` must be an array of strings') + expect(() => + makeBadge({ label: 'build', message: 'passed', links: [1] }), + ).to.throw(ValidationError, 'Field `links` must be an array of strings') + expect(() => + makeBadge({ label: 'build', message: 'passed', links: ['1', '2', '3'] }), + ).to.throw( + ValidationError, + 'Field `links` must not have more than 2 elements', + ) + expect(() => + makeBadge({ label: 'build', message: 'passed', format: 'png' }), ).to.throw(ValidationError, "Unexpected field 'format'") expect(() => - makeBadge({ label: 'build', message: 'passed', template: 'flat' }) + makeBadge({ label: 'build', message: 'passed', template: 'flat' }), ).to.throw(ValidationError, "Unexpected field 'template'") expect(() => - makeBadge({ label: 'build', message: 'passed', foo: 'bar' }) + makeBadge({ label: 'build', message: 'passed', foo: 'bar' }), ).to.throw(ValidationError, "Unexpected field 'foo'") expect(() => makeBadge({ label: 'build', message: 'passed', style: 'something else', - }) + }), ).to.throw( ValidationError, - 'Field `style` must be one of (plastic,flat,flat-square,for-the-badge,social)' + 'Field `style` must be one of (plastic,flat,flat-square,for-the-badge,social)', ) expect(() => - makeBadge({ label: 'build', message: 'passed', style: 'popout' }) + makeBadge({ label: 'build', message: 'passed', idSuffix: '\\' }), ).to.throw( ValidationError, - 'Field `style` must be one of (plastic,flat,flat-square,for-the-badge,social)' + 'Field `idSuffix` must contain only numbers, letters, -, and _', ) }) }) diff --git a/badge-maker/lib/make-badge.js b/badge-maker/lib/make-badge.js index 495dd4ecbef48..a02400c2ea068 100644 --- a/badge-maker/lib/make-badge.js +++ b/badge-maker/lib/make-badge.js @@ -1,14 +1,13 @@ -'use strict' - -const { normalizeColor, toSvgColor } = require('./color') -const badgeRenderers = require('./badge-renderers') -const { stripXmlWhitespace } = require('./xml') +import { normalizeColor, toSvgColor } from './color.js' +import badgeRenderers from './badge-renderers.js' +import { stripXmlWhitespace } from './xml.js' +import { DEFAULT_LOGO_HEIGHT } from './constants.js' /* 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({ +export default function makeBadge({ format, style = 'flat', label, @@ -16,9 +15,10 @@ module.exports = function makeBadge({ color, labelColor, logo, - logoPosition, + logoSize, logoWidth, links = ['', ''], + idSuffix, }) { // String coercion and whitespace removal. label = `${label}`.trim() @@ -37,6 +37,7 @@ module.exports = function makeBadge({ link: links, name: label, value: message, + idSuffix, }) } @@ -45,7 +46,7 @@ module.exports = function makeBadge({ throw new Error(`Unknown badge style: '${style}'`) } - logoWidth = +logoWidth || (logo ? 14 : 0) + logoWidth = +logoWidth || (logo ? DEFAULT_LOGO_HEIGHT : 0) return stripXmlWhitespace( render({ @@ -53,11 +54,12 @@ module.exports = function makeBadge({ message, links, logo, - logoPosition, logoWidth, + logoSize, logoPadding: logo && label.length ? 3 : 0, color: toSvgColor(color), labelColor: toSvgColor(labelColor), - }) + idSuffix, + }), ) } diff --git a/badge-maker/lib/make-badge.spec.js b/badge-maker/lib/make-badge.spec.js index 3dc574723b92d..819401266440b 100644 --- a/badge-maker/lib/make-badge.spec.js +++ b/badge-maker/lib/make-badge.spec.js @@ -1,14 +1,11 @@ -'use strict' - -const { test, given, forCases } = require('sazerac') -const { expect } = require('chai') -const snapshot = require('snap-shot-it') -const isSvg = require('is-svg') -const prettier = require('prettier') -const makeBadge = require('./make-badge') - -function expectBadgeToMatchSnapshot(format) { - snapshot(prettier.format(makeBadge(format), { parser: 'html' })) +import { test, given, forCases } from 'sazerac' +import { expect } from 'chai' +import snapshot from 'snap-shot-it' +import prettier from 'prettier' +import makeBadge from './make-badge.js' + +async function expectBadgeToMatchSnapshot(format) { + snapshot(await prettier.format(makeBadge(format), { parser: 'html' })) } function testColor(color = '', colorAttr = 'color') { @@ -18,7 +15,7 @@ function testColor(color = '', colorAttr = 'color') { message: 'Bob', [colorAttr]: color, format: 'json', - }) + }), ).color } @@ -27,11 +24,11 @@ describe('The badge generator', function () { test(testColor, () => { // valid hex forCases([ - given('#4c1'), - given('#4C1'), - given('4C1'), - given('4c1'), - ]).expect('#4c1') + given('#4b0'), + given('#4b0'), + given('4B0'), + given('4b0'), + ]).expect('#4b0') forCases([ given('#abc123'), given('#ABC123'), @@ -68,27 +65,28 @@ describe('The badge generator', function () { given('bluish'), given('almostred'), given('brightmaroon'), - given('cactus') + given('cactus'), ).expect(undefined) }) }) describe('color aliases', function () { test(testColor, () => { - forCases([given('#4c1', 'color')]).expect('#4c1') + forCases([given('#4b0', 'color')]).expect('#4b0') }) }) describe('SVG', function () { - it('should produce SVG', function () { + it('should produce SVG', async function () { + const { default: isSvg } = await import('is-svg') expect(makeBadge({ label: 'cactus', message: 'grown', format: 'svg' })) .to.satisfy(isSvg) .and.to.include('cactus') .and.to.include('grown') }) - it('should match snapshot', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshot', async function () { + await expectBadgeToMatchSnapshot({ label: 'cactus', message: 'grown', format: 'svg', @@ -113,7 +111,8 @@ describe('The badge generator', function () { }) }) - it('should replace undefined svg badge style with "flat"', function () { + it('should replace undefined svg badge style with "flat"', async function () { + const { default: isSvg } = await import('is-svg') const jsonBadgeWithUnknownStyle = makeBadge({ label: 'name', message: 'Bob', @@ -137,14 +136,14 @@ describe('The badge generator', function () { message: 'Bob', format: 'svg', style: 'unknown_style', - }) + }), ).to.throw(Error, "Unknown badge style: 'unknown_style'") }) }) describe('"flat" template badge generation', function () { - it('should match snapshots: message/label, no logo', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message/label, no logo', async function () { + await expectBadgeToMatchSnapshot({ label: 'cactus', message: 'grown', format: 'svg', @@ -154,8 +153,8 @@ describe('The badge generator', function () { }) }) - it('should match snapshots: message/label, with logo', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message/label, with logo', async function () { + await expectBadgeToMatchSnapshot({ label: 'cactus', message: 'grown', format: 'svg', @@ -166,8 +165,20 @@ describe('The badge generator', function () { }) }) - it('should match snapshots: message only, no logo', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message with custom suffix', async function () { + await expectBadgeToMatchSnapshot({ + label: 'cactus', + message: 'grown', + format: 'svg', + color: '#b3e', + labelColor: '#0f0', + logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', + idSuffix: '1', + }) + }) + + it('should match snapshots: message only, no logo', async function () { + await expectBadgeToMatchSnapshot({ label: '', message: 'grown', format: 'svg', @@ -176,8 +187,8 @@ describe('The badge generator', function () { }) }) - it('should match snapshots: message only, with logo', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message only, with logo', async function () { + await expectBadgeToMatchSnapshot({ label: '', message: 'grown', format: 'svg', @@ -187,8 +198,8 @@ describe('The badge generator', function () { }) }) - it('should match snapshots: message only, with logo and labelColor', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message only, with logo and labelColor', async function () { + await expectBadgeToMatchSnapshot({ label: '', message: 'grown', format: 'svg', @@ -199,8 +210,8 @@ describe('The badge generator', function () { }) }) - it('should match snapshots: message/label, with links', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message/label, with links', async function () { + await expectBadgeToMatchSnapshot({ label: 'cactus', message: 'grown', format: 'svg', @@ -211,8 +222,8 @@ describe('The badge generator', function () { }) }) - it('should match snapshots: black text when the label color is light', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: black text when the label color is light', async function () { + await expectBadgeToMatchSnapshot({ label: 'cactus', message: 'grown', format: 'svg', @@ -222,8 +233,8 @@ describe('The badge generator', function () { }) }) - it('should match snapshots: black text when the message color is light', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: black text when the message color is light', async function () { + await expectBadgeToMatchSnapshot({ label: 'cactus', message: 'grown', format: 'svg', @@ -235,8 +246,8 @@ describe('The badge generator', function () { }) describe('"flat-square" template badge generation', function () { - it('should match snapshots: message/label, no logo', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message/label, no logo', async function () { + await expectBadgeToMatchSnapshot({ label: 'cactus', message: 'grown', format: 'svg', @@ -246,8 +257,20 @@ describe('The badge generator', function () { }) }) - it('should match snapshots: message/label, with logo', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message/label, with logo', async function () { + await expectBadgeToMatchSnapshot({ + label: 'cactus', + message: 'grown', + format: 'svg', + style: 'flat-square', + color: '#b3e', + labelColor: '#0f0', + logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', + }) + }) + + it('should match snapshots: message with custom suffix', async function () { + await expectBadgeToMatchSnapshot({ label: 'cactus', message: 'grown', format: 'svg', @@ -255,11 +278,12 @@ describe('The badge generator', function () { color: '#b3e', labelColor: '#0f0', logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', + idSuffix: '1', }) }) - it('should match snapshots: message only, no logo', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message only, no logo', async function () { + await expectBadgeToMatchSnapshot({ label: '', message: 'grown', format: 'svg', @@ -268,8 +292,8 @@ describe('The badge generator', function () { }) }) - it('should match snapshots: message only, with logo', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message only, with logo', async function () { + await expectBadgeToMatchSnapshot({ label: '', message: 'grown', format: 'svg', @@ -279,8 +303,8 @@ describe('The badge generator', function () { }) }) - it('should match snapshots: message only, with logo and labelColor', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message only, with logo and labelColor', async function () { + await expectBadgeToMatchSnapshot({ label: '', message: 'grown', format: 'svg', @@ -291,8 +315,8 @@ describe('The badge generator', function () { }) }) - it('should match snapshots: message/label, with links', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message/label, with links', async function () { + await expectBadgeToMatchSnapshot({ label: 'cactus', message: 'grown', format: 'svg', @@ -303,8 +327,8 @@ describe('The badge generator', function () { }) }) - it('should match snapshots: black text when the label color is light', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: black text when the label color is light', async function () { + await expectBadgeToMatchSnapshot({ label: 'cactus', message: 'grown', format: 'svg', @@ -314,8 +338,8 @@ describe('The badge generator', function () { }) }) - it('should match snapshots: black text when the message color is light', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: black text when the message color is light', async function () { + await expectBadgeToMatchSnapshot({ label: 'cactus', message: 'grown', format: 'svg', @@ -327,8 +351,8 @@ describe('The badge generator', function () { }) describe('"plastic" template badge generation', function () { - it('should match snapshots: message/label, no logo', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message/label, no logo', async function () { + await expectBadgeToMatchSnapshot({ label: 'cactus', message: 'grown', format: 'svg', @@ -338,8 +362,8 @@ describe('The badge generator', function () { }) }) - it('should match snapshots: message/label, with logo', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message/label, with logo', async function () { + await expectBadgeToMatchSnapshot({ label: 'cactus', message: 'grown', format: 'svg', @@ -350,8 +374,21 @@ describe('The badge generator', function () { }) }) - it('should match snapshots: message only, no logo', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message with custom suffix', async function () { + await expectBadgeToMatchSnapshot({ + label: 'cactus', + message: 'grown', + format: 'svg', + style: 'plastic', + color: '#b3e', + labelColor: '#0f0', + logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', + idSuffix: '1', + }) + }) + + it('should match snapshots: message only, no logo', async function () { + await expectBadgeToMatchSnapshot({ label: '', message: 'grown', format: 'svg', @@ -360,8 +397,8 @@ describe('The badge generator', function () { }) }) - it('should match snapshots: message only, with logo', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message only, with logo', async function () { + await expectBadgeToMatchSnapshot({ label: '', message: 'grown', format: 'svg', @@ -371,8 +408,8 @@ describe('The badge generator', function () { }) }) - it('should match snapshots: message only, with logo and labelColor', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message only, with logo and labelColor', async function () { + await expectBadgeToMatchSnapshot({ label: '', message: 'grown', format: 'svg', @@ -383,8 +420,8 @@ describe('The badge generator', function () { }) }) - it('should match snapshots: message/label, with links', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message/label, with links', async function () { + await expectBadgeToMatchSnapshot({ label: 'cactus', message: 'grown', format: 'svg', @@ -395,8 +432,8 @@ describe('The badge generator', function () { }) }) - it('should match snapshots: black text when the label color is light', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: black text when the label color is light', async function () { + await expectBadgeToMatchSnapshot({ label: 'cactus', message: 'grown', format: 'svg', @@ -406,8 +443,8 @@ describe('The badge generator', function () { }) }) - it('should match snapshots: black text when the message color is light', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: black text when the message color is light', async function () { + await expectBadgeToMatchSnapshot({ label: 'cactus', message: 'grown', format: 'svg', @@ -427,7 +464,7 @@ describe('The badge generator', function () { message: 1999, format: 'svg', style: 'for-the-badge', - }) + }), ) .to.include('1998') .and.to.include('1999') @@ -440,25 +477,37 @@ describe('The badge generator', function () { message: '1 string', format: 'svg', style: 'for-the-badge', - }) + }), ) .to.include('LABEL') .and.to.include('1 STRING') }) - it('should match snapshots: message/label, no logo', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message/label, no logo', async function () { + await expectBadgeToMatchSnapshot({ + label: 'cactus', + message: 'grown', + format: 'svg', + style: 'for-the-badge', + color: '#b3e', + labelColor: '#0f0', + }) + }) + + it('should match snapshots: message/label, with logo', async function () { + await expectBadgeToMatchSnapshot({ label: 'cactus', message: 'grown', format: 'svg', style: 'for-the-badge', color: '#b3e', labelColor: '#0f0', + logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', }) }) - it('should match snapshots: message/label, with logo', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message with custom suffix', async function () { + await expectBadgeToMatchSnapshot({ label: 'cactus', message: 'grown', format: 'svg', @@ -466,11 +515,12 @@ describe('The badge generator', function () { color: '#b3e', labelColor: '#0f0', logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', + idSuffix: '1', }) }) - it('should match snapshots: message only, no logo', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message only, no logo', async function () { + await expectBadgeToMatchSnapshot({ label: '', message: 'grown', format: 'svg', @@ -479,8 +529,8 @@ describe('The badge generator', function () { }) }) - it('should match snapshots: message only, with logo', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message only, with logo', async function () { + await expectBadgeToMatchSnapshot({ label: '', message: 'grown', format: 'svg', @@ -490,8 +540,8 @@ describe('The badge generator', function () { }) }) - it('should match snapshots: message only, with logo and labelColor', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message only, with logo and labelColor', async function () { + await expectBadgeToMatchSnapshot({ label: '', message: 'grown', format: 'svg', @@ -502,8 +552,8 @@ describe('The badge generator', function () { }) }) - it('should match snapshots: message/label, with links', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message/label, with links', async function () { + await expectBadgeToMatchSnapshot({ label: 'cactus', message: 'grown', format: 'svg', @@ -514,8 +564,8 @@ describe('The badge generator', function () { }) }) - it('should match snapshots: black text when the label color is light', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: black text when the label color is light', async function () { + await expectBadgeToMatchSnapshot({ label: 'cactus', message: 'grown', format: 'svg', @@ -525,8 +575,8 @@ describe('The badge generator', function () { }) }) - it('should match snapshots: black text when the message color is light', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: black text when the message color is light', async function () { + await expectBadgeToMatchSnapshot({ label: 'cactus', message: 'grown', format: 'svg', @@ -545,7 +595,7 @@ describe('The badge generator', function () { message: 'some-value', format: 'svg', style: 'social', - }) + }), ) .to.include('Some-key') .and.to.include('some-value') @@ -559,25 +609,37 @@ describe('The badge generator', function () { message: 'some-value', format: 'json', style: 'social', - }) + }), ) .to.include('""') .and.to.include('some-value') }) - it('should match snapshots: message/label, no logo', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message/label, no logo', async function () { + await expectBadgeToMatchSnapshot({ + label: 'cactus', + message: 'grown', + format: 'svg', + style: 'social', + color: '#b3e', + labelColor: '#0f0', + }) + }) + + it('should match snapshots: message/label, with logo', async function () { + await expectBadgeToMatchSnapshot({ label: 'cactus', message: 'grown', format: 'svg', style: 'social', color: '#b3e', labelColor: '#0f0', + logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', }) }) - it('should match snapshots: message/label, with logo', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message with custom suffix', async function () { + await expectBadgeToMatchSnapshot({ label: 'cactus', message: 'grown', format: 'svg', @@ -585,11 +647,12 @@ describe('The badge generator', function () { color: '#b3e', labelColor: '#0f0', logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', + idSuffix: '1', }) }) - it('should match snapshots: message only, no logo', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message only, no logo', async function () { + await expectBadgeToMatchSnapshot({ label: '', message: 'grown', format: 'svg', @@ -598,8 +661,8 @@ describe('The badge generator', function () { }) }) - it('should match snapshots: message only, with logo', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message only, with logo', async function () { + await expectBadgeToMatchSnapshot({ label: '', message: 'grown', format: 'svg', @@ -609,8 +672,8 @@ describe('The badge generator', function () { }) }) - it('should match snapshots: message only, with logo and labelColor', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message only, with logo and labelColor', async function () { + await expectBadgeToMatchSnapshot({ label: '', message: 'grown', format: 'svg', @@ -621,8 +684,8 @@ describe('The badge generator', function () { }) }) - it('should match snapshots: message/label, with links', function () { - expectBadgeToMatchSnapshot({ + it('should match snapshots: message/label, with links', async function () { + await expectBadgeToMatchSnapshot({ label: 'cactus', message: 'grown', format: 'svg', @@ -635,8 +698,8 @@ describe('The badge generator', function () { }) describe('badges with logos should always produce the same badge', function () { - it('badge with logo', function () { - expectBadgeToMatchSnapshot({ + it('default badge with logo', async function () { + await expectBadgeToMatchSnapshot({ label: 'label', message: 'message', format: 'svg', @@ -644,4 +707,56 @@ describe('The badge generator', function () { }) }) }) + + describe('badges with logo-only should always produce the same badge', function () { + it('flat badge, logo-only', async function () { + await expectBadgeToMatchSnapshot({ + label: '', + message: '', + format: 'svg', + logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', + style: 'flat', + }) + }) + + it('flat-square badge, logo-only', async function () { + await expectBadgeToMatchSnapshot({ + label: '', + message: '', + format: 'svg', + logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', + style: 'flat-square', + }) + }) + + it('for-the-badge badge, logo-only', async function () { + await expectBadgeToMatchSnapshot({ + label: '', + message: '', + format: 'svg', + logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', + style: 'for-the-badge', + }) + }) + + it('social badge, logo-only', async function () { + await expectBadgeToMatchSnapshot({ + label: '', + message: '', + format: 'svg', + logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', + style: 'social', + }) + }) + + it('plastic badge, logo-only', async function () { + await expectBadgeToMatchSnapshot({ + label: '', + message: '', + format: 'svg', + logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', + style: 'plastic', + }) + }) + }) }) diff --git a/badge-maker/lib/xml.js b/badge-maker/lib/xml.js index 1916113c47317..54d2ad0ff4057 100644 --- a/badge-maker/lib/xml.js +++ b/badge-maker/lib/xml.js @@ -2,13 +2,11 @@ * @module */ -'use strict' - -function stripXmlWhitespace(xml) { +export function stripXmlWhitespace(xml) { return xml.replace(/>\s+/g, '>').replace(/<\s+/g, '<').trim() } -function escapeXml(s) { +export function escapeXml(s) { if (typeof s === 'number') { return s } else if (s === undefined || typeof s !== 'string') { @@ -26,16 +24,16 @@ function escapeXml(s) { /** * Representation of an XML element */ -class XmlElement { +export class XmlElement { /** * Xml Element Constructor * * @param {object} attrs Refer to individual attrs * @param {string} attrs.name * Name of the XML tag - * @param {Array.} [attrs.content=[]] + * @param {Array.} [attrs.content=[]] * Array of objects to render inside the tag. content may contain a mix of - * string and XmlElement objects. If content is `[]` or ommitted the + * string and XmlElement objects. If content is `[]` or omitted the * element will be rendered as a self-closing element. * @param {object} [attrs.attrs={}] * Object representing the tag's attributes as name/value pairs @@ -66,7 +64,7 @@ class XmlElement { }) .join(' ') return stripXmlWhitespace( - `<${this.name}${attrsStr}>${content}` + `<${this.name}${attrsStr}>${content}`, ) } return stripXmlWhitespace(`<${this.name}${attrsStr}/>`) @@ -77,7 +75,7 @@ class XmlElement { * Convenience class. Sometimes it is useful to return an object that behaves * like an XmlElement but renders multiple XML tags (not wrapped in a ). */ -class ElementList { +export class ElementList { constructor({ content = [] }) { this.content = content } @@ -88,9 +86,7 @@ class ElementList { typeof el.render === 'function' ? acc + el.render() : acc + escapeXml(el), - '' + '', ) } } - -module.exports = { escapeXml, stripXmlWhitespace, XmlElement, ElementList } diff --git a/badge-maker/lib/xml.spec.js b/badge-maker/lib/xml.spec.js index 78ea84bada11c..a54e6a6d10cab 100644 --- a/badge-maker/lib/xml.spec.js +++ b/badge-maker/lib/xml.spec.js @@ -1,7 +1,5 @@ -'use strict' - -const { test, given } = require('sazerac') -const { XmlElement } = require('./xml') +import { test, given } from 'sazerac' +import { XmlElement } from './xml.js' function testRender(params) { return new XmlElement(params).render() diff --git a/badge-maker/package.json b/badge-maker/package.json index fc6702acb71ba..d91faf9ea5a0b 100644 --- a/badge-maker/package.json +++ b/badge-maker/package.json @@ -1,6 +1,14 @@ { "name": "badge-maker", - "version": "3.3.1", + "version": "6.0.0", + "type": "module", + "exports": { + ".": { + "types": "./index.d.ts", + "import": "./lib/index.js", + "default": "./lib/index.js" + } + }, "description": "Shields.io badge library", "keywords": [ "GitHub", @@ -10,14 +18,17 @@ "shields.io" ], "types": "index.d.ts", - "main": "lib/index.js", "repository": { "type": "git", "url": "git+https://github.com/badges/shields.git", "directory": "badge-maker" }, "author": "Thaddée Tyl ", - "license": "CC0-1.0", + "contributors": [ + "Paul Melnikow", + "Pierre-Yves Bigourdan" + ], + "license": "(MIT OR Apache-2.0)", "bugs": { "url": "https://github.com/badges/shields/issues" }, @@ -26,8 +37,7 @@ "badge": "lib/badge-cli.js" }, "engines": { - "node": ">= 12", - "npm": ">= 6" + "node": ">=20" }, "collective": { "type": "opencollective", diff --git a/config/custom-environment-variables.yml b/config/custom-environment-variables.yml index 3afe51a5450b5..d8f8d47446657 100644 --- a/config/custom-environment-variables.yml +++ b/config/custom-environment-variables.yml @@ -25,16 +25,15 @@ public: rasterUrl: 'RASTER_URL' - cors: - allowedOrigin: - __name: 'ALLOWED_ORIGIN' - __format: 'json' - services: + bitbucket: + authorizedOrigins: 'BITBUCKET_ORIGINS' bitbucketServer: authorizedOrigins: 'BITBUCKET_SERVER_ORIGINS' drone: authorizedOrigins: 'DRONE_ORIGINS' + gitea: + authorizedOrigins: 'GITEA_ORIGINS' github: baseUri: 'GITHUB_URL' debug: @@ -52,6 +51,8 @@ public: authorizedOrigins: 'NPM_ORIGINS' obs: authorizedOrigins: 'OBS_ORIGINS' + pypi: + baseUri: 'PYPI_URL' sonar: authorizedOrigins: 'SONAR_ORIGINS' teamcity: @@ -63,7 +64,8 @@ public: cacheHeaders: defaultCacheLengthSeconds: 'BADGE_MAX_AGE_SECONDS' - fetchLimit: 'FETCH_LIMIT' + fetchLimitBytes: 'FETCH_LIMIT_BYTES' + userAgentBase: 'USER_AGENT_BASE' requestTimeoutSeconds: 'REQUEST_TIMEOUT_SECONDS' requestTimeoutMaxAgeSeconds: 'REQUEST_TIMEOUT_MAX_AGE_SECONDS' @@ -76,8 +78,12 @@ private: bitbucket_password: 'BITBUCKET_PASS' bitbucket_server_username: 'BITBUCKET_SERVER_USER' bitbucket_server_password: 'BITBUCKET_SERVER_PASS' + curseforge_api_key: 'CURSEFORGE_API_KEY' discord_bot_token: 'DISCORD_BOT_TOKEN' + dockerhub_username: 'DOCKERHUB_USER' + dockerhub_pat: 'DOCKERHUB_PAT' drone_token: 'DRONE_TOKEN' + gitea_token: 'GITEA_TOKEN' gh_client_id: 'GH_CLIENT_ID' gh_client_secret: 'GH_CLIENT_SECRET' gh_token: 'GH_TOKEN' @@ -86,22 +92,26 @@ private: jenkins_pass: 'JENKINS_PASS' jira_user: 'JIRA_USER' jira_pass: 'JIRA_PASS' + librariesio_tokens: 'LIBRARIESIO_TOKENS' nexus_user: 'NEXUS_USER' nexus_pass: 'NEXUS_PASS' npm_token: 'NPM_TOKEN' obs_user: 'OBS_USER' obs_pass: 'OBS_PASS' - redis_url: 'REDIS_URL' + opencollective_token: 'OPENCOLLECTIVE_TOKEN' + pepy_key: 'PEPY_KEY' + postgres_url: 'POSTGRES_URL' + reddit_client_id: 'REDDIT_CLIENT_ID' + reddit_client_secret: 'REDDIT_CLIENT_SECRET' sentry_dsn: 'SENTRY_DSN' - shields_secret: 'SHIELDS_SECRET' sl_insight_userUuid: 'SL_INSIGHT_USER_UUID' sl_insight_apiToken: 'SL_INSIGHT_API_TOKEN' sonarqube_token: 'SONARQUBE_TOKEN' + stackapps_api_key: 'STACKAPPS_API_KEY' teamcity_user: 'TEAMCITY_USER' teamcity_pass: 'TEAMCITY_PASS' twitch_client_id: 'TWITCH_CLIENT_ID' twitch_client_secret: 'TWITCH_CLIENT_SECRET' - wheelmap_token: 'WHEELMAP_TOKEN' influx_username: 'INFLUX_USERNAME' influx_password: 'INFLUX_PASSWORD' weblate_api_key: 'WEBLATE_API_KEY' diff --git a/config/default.yml b/config/default.yml index 41dc14e5f319c..f508753521837 100644 --- a/config/default.yml +++ b/config/default.yml @@ -1,7 +1,6 @@ public: bind: address: '::' - metrics: prometheus: enabled: false @@ -12,32 +11,29 @@ public: intervalSeconds: 15 ssl: isSecure: false - - cors: - allowedOrigin: [] - services: + bitbucket: + authorizedOrigins: 'https://bitbucket.org' github: - baseUri: 'https://api.github.com/' + baseUri: 'https://api.github.com' debug: enabled: false intervalSeconds: 200 + restApiVersion: '2026-03-10' obs: authorizedOrigins: 'https://api.opensuse.org' + pypi: + baseUri: 'https://pypi.org' weblate: authorizedOrigins: 'https://hosted.weblate.org' trace: false - cacheHeaders: defaultCacheLengthSeconds: 120 - handleInternalErrors: true - - fetchLimit: '10MB' - + fetchLimitBytes: 10485760 # 10MB + userAgentBase: 'shields (self-hosted)' requestTimeoutSeconds: 120 requestTimeoutMaxAgeSeconds: 30 - + allowUnsecuredEndpointRequests: false requireCloudflare: false - private: {} diff --git a/config/development.yml b/config/development.yml index e4b3598e606bb..c45285d48e94b 100644 --- a/config/development.yml +++ b/config/development.yml @@ -2,7 +2,4 @@ public: bind: address: 'localhost' - cors: - allowedOrigin: ['http://localhost:3000'] - handleInternalErrors: false diff --git a/config/local-shields-io-production.template.yml b/config/local-shields-io-production.template.yml index efb55146282ae..d4d302e451582 100644 --- a/config/local-shields-io-production.template.yml +++ b/config/local-shields-io-production.template.yml @@ -1,10 +1,12 @@ private: # These are the keys which are set on the production servers. + curseforge_api_key: ... discord_bot_token: ... gh_client_id: ... gh_client_secret: ... gitlab_token: ... - redis_url: ... + reddit_client_id: ... + reddit_client_secret: ... sentry_dsn: ... shields_secret: ... sl_insight_userUuid: ... diff --git a/config/local.template.yml b/config/local.template.yml index ae1c51c619ebf..9d2f66bfc9671 100644 --- a/config/local.template.yml +++ b/config/local.template.yml @@ -4,10 +4,13 @@ private: # The possible values are documented in `doc/server-secrets.md`. Note that # you can also set these values through environment variables, which may be # preferable for self hosting. + curseforge_api_key: '...' gh_token: '...' gitlab_token: '...' obs_user: '...' obs_pass: '...' + reddit_client_id: '...' + reddit_client_secret: '...' twitch_client_id: '...' twitch_client_secret: '...' weblate_api_key: '...' diff --git a/config/shields-io-production.yml b/config/shields-io-production.yml index 9120e062daabb..9ebb4c511002a 100644 --- a/config/shields-io-production.yml +++ b/config/shields-io-production.yml @@ -6,13 +6,17 @@ public: enabled: true url: https://metrics.shields.io/telegraf instanceIdFrom: env-var - instanceIdEnvVarName: HEROKU_DYNO_ID + instanceIdEnvVarName: FLY_ALLOC_ID envLabel: shields-production ssl: - isSecure: true + isSecure: false - cors: - allowedOrigin: ['http://shields.io', 'https://shields.io'] + services: + gitlab: + authorizedOrigins: 'https://gitlab.com' rasterUrl: 'https://raster.shields.io' + userAgentBase: 'Shields.io' + requireCloudflare: true + requestTimeoutSeconds: 8 diff --git a/config/test.yml b/config/test.yml index 0c53ed5ba8492..b1d558053a784 100644 --- a/config/test.yml +++ b/config/test.yml @@ -8,3 +8,5 @@ public: rasterUrl: 'http://raster.example.test' handleInternalErrors: false + + allowUnsecuredEndpointRequests: false diff --git a/core/badge-urls/make-badge-url.d.ts b/core/badge-urls/make-badge-url.d.ts deleted file mode 100644 index 0da9d898988cb..0000000000000 --- a/core/badge-urls/make-badge-url.d.ts +++ /dev/null @@ -1,112 +0,0 @@ -export function badgeUrlFromPath({ - baseUrl, - path, - queryParams, - style, - format, - longCache, -}: { - baseUrl?: string - path: string - queryParams: { [k: string]: string | number | boolean } - style?: string - format?: string - longCache?: boolean -}): string - -export function badgeUrlFromPattern({ - baseUrl, - pattern, - namedParams, - queryParams, - style, - format, - longCache, -}: { - baseUrl?: string - pattern: string - namedParams: { [k: string]: string } - queryParams: { [k: string]: string | number | boolean } - style?: string - format?: string - longCache?: boolean -}): string - -export function encodeField(s: string): string - -export function staticBadgeUrl({ - baseUrl, - label, - message, - labelColor, - color, - style, - namedLogo, - format, - links, -}: { - baseUrl?: string - label: string - message: string - labelColor?: string - color?: string - style?: string - namedLogo?: string - format?: string - links?: string[] -}): string - -export function queryStringStaticBadgeUrl({ - baseUrl, - label, - message, - color, - labelColor, - style, - namedLogo, - logoColor, - logoWidth, - logoPosition, - format, -}: { - baseUrl?: string - label: string - message: string - color?: string - labelColor?: string - style?: string - namedLogo?: string - logoColor?: string - logoWidth?: number - logoPosition?: number - format?: string -}): string - -export function dynamicBadgeUrl({ - baseUrl, - datatype, - label, - dataUrl, - query, - prefix, - suffix, - color, - style, - format, -}: { - baseUrl?: string - datatype: string - label: string - dataUrl: string - query: string - prefix: string - suffix: string - color?: string - style?: string - format?: string -}): string - -export function rasterRedirectUrl( - { rasterUrl }: { rasterUrl: string }, - badgeUrl: string -): string diff --git a/core/badge-urls/make-badge-url.js b/core/badge-urls/make-badge-url.js index 82e3f5fa25124..71dcf29c9298d 100644 --- a/core/badge-urls/make-badge-url.js +++ b/core/badge-urls/make-badge-url.js @@ -1,148 +1,15 @@ // Avoid "Attempted import error: 'URL' is not exported from 'url'" in frontend. import url from 'url' -import queryString from 'query-string' -import { compile } from 'path-to-regexp' - -function badgeUrlFromPath({ - baseUrl = '', - path, - queryParams, - style, - format = '', - longCache = false, -}) { - const outExt = format.length ? `.${format}` : '' - - const outQueryString = queryString.stringify({ - cacheSeconds: longCache ? '2592000' : undefined, - style, - ...queryParams, - }) - const suffix = outQueryString ? `?${outQueryString}` : '' - - return `${baseUrl}${path}${outExt}${suffix}` -} - -function badgeUrlFromPattern({ - baseUrl = '', - pattern, - namedParams, - queryParams, - style, - format = '', - longCache = false, -}) { - const toPath = compile(pattern, { - strict: true, - sensitive: true, - encode: encodeURIComponent, - }) - - const path = toPath(namedParams) - - return badgeUrlFromPath({ - baseUrl, - path, - queryParams, - style, - format, - longCache, - }) -} - -function encodeField(s) { - return encodeURIComponent(s.replace(/-/g, '--').replace(/_/g, '__')) -} - -function staticBadgeUrl({ - baseUrl = '', - label, - message, - labelColor, - color = 'lightgray', - style, - namedLogo, - format = '', - links = [], -}) { - const path = [label, message, color].map(encodeField).join('-') - const outQueryString = queryString.stringify({ - labelColor, - style, - logo: namedLogo, - link: links, - }) - const outExt = format.length ? `.${format}` : '' - const suffix = outQueryString ? `?${outQueryString}` : '' - return `${baseUrl}/badge/${path}${outExt}${suffix}` -} - -function queryStringStaticBadgeUrl({ - baseUrl = '', - label, - message, - color, - labelColor, - style, - namedLogo, - logoColor, - logoWidth, - logoPosition, - format = '', -}) { - // schemaVersion could be a parameter if we iterate on it, - // for now it's hardcoded to the only supported version. - const schemaVersion = '1' - const suffix = `?${queryString.stringify({ - label, - message, - color, - labelColor, - style, - logo: namedLogo, - logoColor, - logoWidth, - logoPosition, - })}` - const outExt = format.length ? `.${format}` : '' - return `${baseUrl}/static/v${schemaVersion}${outExt}${suffix}` -} - -function dynamicBadgeUrl({ - baseUrl, - datatype, - label, - dataUrl, - query, - prefix, - suffix, - color, - style, - format = '', -}) { - const outExt = format.length ? `.${format}` : '' - - const queryParams = { - label, - url: dataUrl, - query, - style, - } - - if (color) { - queryParams.color = color - } - if (prefix) { - queryParams.prefix = prefix - } - if (suffix) { - queryParams.suffix = suffix - } - - const outQueryString = queryString.stringify(queryParams) - return `${baseUrl}/badge/dynamic/${datatype}${outExt}?${outQueryString}` -} +/** + * Build a redirect URL for the raster (non-SVG) badge variant. Preserves the + * path and query string from the original badge request. + * + * @param {object} options - Options for the redirect. + * @param {string} options.rasterUrl - Base URL for raster badge rendering. + * @param {string} badgeUrl - The original badge request URL. + * @returns {string} Redirect URL pointing to the raster endpoint. + */ function rasterRedirectUrl({ rasterUrl }, badgeUrl) { // Ensure we're always using the `rasterUrl` by using just the path from // the request URL. @@ -152,12 +19,4 @@ function rasterRedirectUrl({ rasterUrl }, badgeUrl) { return result } -export { - badgeUrlFromPath, - badgeUrlFromPattern, - encodeField, - staticBadgeUrl, - queryStringStaticBadgeUrl, - dynamicBadgeUrl, - rasterRedirectUrl, -} +export { rasterRedirectUrl } diff --git a/core/badge-urls/make-badge-url.spec.js b/core/badge-urls/make-badge-url.spec.js deleted file mode 100644 index da0b156d5ea71..0000000000000 --- a/core/badge-urls/make-badge-url.spec.js +++ /dev/null @@ -1,155 +0,0 @@ -import { test, given } from 'sazerac' -import { - badgeUrlFromPath, - badgeUrlFromPattern, - encodeField, - staticBadgeUrl, - queryStringStaticBadgeUrl, - dynamicBadgeUrl, -} from './make-badge-url.js' - -describe('Badge URL generation functions', function () { - test(badgeUrlFromPath, () => { - given({ - baseUrl: 'http://example.com', - path: '/npm/v/gh-badges', - style: 'flat-square', - longCache: true, - }).expect( - 'http://example.com/npm/v/gh-badges?cacheSeconds=2592000&style=flat-square' - ) - }) - - test(badgeUrlFromPattern, () => { - given({ - baseUrl: 'http://example.com', - pattern: '/npm/v/:packageName', - namedParams: { packageName: 'gh-badges' }, - style: 'flat-square', - longCache: true, - }).expect( - 'http://example.com/npm/v/gh-badges?cacheSeconds=2592000&style=flat-square' - ) - }) - - test(encodeField, () => { - given('foo').expect('foo') - given('').expect('') - given('happy go lucky').expect('happy%20go%20lucky') - given('do-right').expect('do--right') - given('it_is_a_snake').expect('it__is__a__snake') - }) - - test(staticBadgeUrl, () => { - given({ - label: 'foo', - message: 'bar', - color: 'blue', - style: 'flat-square', - }).expect('/badge/foo-bar-blue?style=flat-square') - given({ - label: 'foo', - message: 'bar', - color: 'blue', - style: 'flat-square', - format: 'png', - namedLogo: 'github', - }).expect('/badge/foo-bar-blue.png?logo=github&style=flat-square') - given({ - label: 'Hello World', - message: 'Привет Мир', - color: '#aabbcc', - }).expect( - '/badge/Hello%20World-%D0%9F%D1%80%D0%B8%D0%B2%D0%B5%D1%82%20%D0%9C%D0%B8%D1%80-%23aabbcc' - ) - given({ - label: '123-123', - message: 'abc-abc', - color: 'blue', - }).expect('/badge/123--123-abc--abc-blue') - given({ - label: '123-123', - message: '', - color: 'blue', - style: 'social', - }).expect('/badge/123--123--blue?style=social') - given({ - label: '', - message: 'blue', - color: 'blue', - }).expect('/badge/-blue-blue') - }) - - test(queryStringStaticBadgeUrl, () => { - // the query-string library sorts parameters by name - given({ - label: 'foo', - message: 'bar', - color: 'blue', - style: 'flat-square', - }).expect('/static/v1?color=blue&label=foo&message=bar&style=flat-square') - given({ - label: 'foo Bar', - message: 'bar Baz', - color: 'blue', - style: 'flat-square', - format: 'png', - namedLogo: 'github', - }).expect( - '/static/v1.png?color=blue&label=foo%20Bar&logo=github&message=bar%20Baz&style=flat-square' - ) - given({ - label: 'Hello World', - message: 'Привет Мир', - color: '#aabbcc', - }).expect( - '/static/v1?color=%23aabbcc&label=Hello%20World&message=%D0%9F%D1%80%D0%B8%D0%B2%D0%B5%D1%82%20%D0%9C%D0%B8%D1%80' - ) - }) - - test(dynamicBadgeUrl, () => { - const dataUrl = 'http://example.com/foo.json' - const query = '$.bar' - const prefix = 'value: ' - given({ - baseUrl: 'http://img.example.com', - datatype: 'json', - label: 'foo', - dataUrl, - query, - prefix, - style: 'plastic', - }).expect( - [ - 'http://img.example.com/badge/dynamic/json', - '?label=foo', - `&prefix=${encodeURIComponent(prefix)}`, - `&query=${encodeURIComponent(query)}`, - '&style=plastic', - `&url=${encodeURIComponent(dataUrl)}`, - ].join('') - ) - const suffix = '<- value' - const color = 'blue' - given({ - baseUrl: 'http://img.example.com', - datatype: 'json', - label: 'foo', - dataUrl, - query, - suffix, - color, - style: 'plastic', - }).expect( - [ - 'http://img.example.com/badge/dynamic/json', - '?color=blue', - '&label=foo', - `&query=${encodeURIComponent(query)}`, - '&style=plastic', - `&suffix=${encodeURIComponent(suffix)}`, - `&url=${encodeURIComponent(dataUrl)}`, - ].join('') - ) - }) -}) diff --git a/core/badge-urls/path-helpers.js b/core/badge-urls/path-helpers.js index 74c1004f30e42..27f6d74a40df7 100644 --- a/core/badge-urls/path-helpers.js +++ b/core/badge-urls/path-helpers.js @@ -1,5 +1,14 @@ -// Escapes `t` using the format specified in -// +/** + * Escape a string according to the badge format encoding scheme. Underscores + * and dashes in the input are decoded using the rules from + * https://github.com/espadrine/gh-badges/issues/12#issuecomment-31518129 + * + * Single underscores become spaces, double underscores become single + * underscores, and double dashes become single dashes. + * + * @param {string} t - The format-encoded string to decode. + * @returns {string} The decoded string. + */ function escapeFormat(t) { return ( t diff --git a/core/badge-urls/path-helpers.spec.js b/core/badge-urls/path-helpers.spec.js index 88de5a4869ebd..904660d1e517d 100644 --- a/core/badge-urls/path-helpers.spec.js +++ b/core/badge-urls/path-helpers.spec.js @@ -7,7 +7,7 @@ describe('Badge URL helper functions', function () { given('single trailing underscore_').expect('single trailing underscore ') given('__double leading underscores').expect('_double leading underscores') given('double trailing underscores__').expect( - 'double trailing underscores_' + 'double trailing underscores_', ) given('treble___underscores').expect('treble_ underscores') given('fourfold____underscores').expect('fourfold__underscores') diff --git a/core/base-service/auth-helper.js b/core/base-service/auth-helper.js index 8c9c7976be972..4fadbc99bcd58 100644 --- a/core/base-service/auth-helper.js +++ b/core/base-service/auth-helper.js @@ -1,5 +1,13 @@ import { URL } from 'url' -import { InvalidParameter } from './errors.js' +import dayjs from 'dayjs' +import Joi from 'joi' +import checkErrorResponse from './check-error-response.js' +import { InvalidParameter, InvalidResponse } from './errors.js' +import { fetch } from './got.js' +import { parseJson } from './json.js' +import validate from './validate.js' + +let jwtCache = Object.create(null) class AuthHelper { constructor( @@ -11,7 +19,7 @@ class AuthHelper { isRequired = false, defaultToEmptyStringForUser = false, }, - config + config, ) { if (!userKey && !passKey) { throw Error('Expected userKey or passKey to be set') @@ -74,7 +82,7 @@ class AuthHelper { } static _isInsecureSslRequest({ options = {} }) { - const { strictSSL = true } = options + const strictSSL = options?.https?.rejectUnauthorized ?? true return strictSSL !== true } @@ -87,7 +95,7 @@ class AuthHelper { } } - shouldAuthenticateRequest({ url, options = {} }) { + isAllowedOrigin(url) { let parsed try { parsed = new URL(url) @@ -97,7 +105,11 @@ class AuthHelper { const { protocol, host } = parsed const origin = `${protocol}//${host}` - const originViolation = !this._authorizedOrigins.includes(origin) + return this._authorizedOrigins.includes(origin) + } + + shouldAuthenticateRequest({ url, options = {} }) { + const originViolation = !this.isAllowedOrigin(url) const strictSslCheckViolation = this._requireStrictSslToAuthenticate && @@ -107,8 +119,10 @@ class AuthHelper { } get _basicAuth() { - const { _user: user, _pass: pass } = this - return this.isConfigured ? { user, pass } : undefined + const { _user: username, _pass: password } = this + return this.isConfigured + ? { username: username || '', password: password || '' } + : undefined } /* @@ -131,7 +145,7 @@ class AuthHelper { const { options, ...rest } = requestParams return { options: { - auth, + ...auth, ...options, }, ...rest, @@ -140,7 +154,7 @@ class AuthHelper { withBasicAuth(requestParams) { return this._withAnyAuth(requestParams, requestParams => - this.constructor._mergeAuth(requestParams, this._basicAuth) + this.constructor._mergeAuth(requestParams, this._basicAuth), ) } @@ -151,6 +165,11 @@ class AuthHelper { : undefined } + _apiKeyHeader(apiKeyHeader) { + const { _pass: pass } = this + return this.isConfigured ? { [apiKeyHeader]: pass } : undefined + } + static _mergeHeaders(requestParams, headers) { const { options: { headers: existingHeaders, ...restOptions } = {}, @@ -168,24 +187,32 @@ class AuthHelper { } } + withApiKeyHeader(requestParams, header = 'x-api-key') { + return this._withAnyAuth(requestParams, requestParams => + this.constructor._mergeHeaders(requestParams, this._apiKeyHeader(header)), + ) + } + withBearerAuthHeader( requestParams, - bearerKey = 'Bearer' // lgtm [js/hardcoded-credentials] + bearerKey = 'Bearer', // lgtm [js/hardcoded-credentials] ) { return this._withAnyAuth(requestParams, requestParams => this.constructor._mergeHeaders( requestParams, - this._bearerAuthHeader(bearerKey) - ) + this._bearerAuthHeader(bearerKey), + ), ) } static _mergeQueryParams(requestParams, query) { - const { options: { qs: existingQuery, ...restOptions } = {}, ...rest } = - requestParams + const { + options: { searchParams: existingQuery, ...restOptions } = {}, + ...rest + } = requestParams return { options: { - qs: { + searchParams: { ...existingQuery, ...query, }, @@ -200,9 +227,106 @@ class AuthHelper { this.constructor._mergeQueryParams(requestParams, { ...(userKey ? { [userKey]: this._user } : undefined), ...(passKey ? { [passKey]: this._pass } : undefined), + }), + ) + } + + static _getJwtExpiry(token, max = dayjs().add(1, 'hours').unix()) { + // get the expiry timestamp for this JWT (capped at a max length) + const parts = token.split('.') + + if (parts.length < 2) { + throw new InvalidResponse({ + prettyMessage: 'invalid response data from auth endpoint', }) + } + + const json = validate( + { + ErrorClass: InvalidResponse, + prettyErrorMessage: 'invalid response data from auth endpoint', + }, + parseJson(Buffer.from(parts[1], 'base64').toString()), + Joi.object({ exp: Joi.number().required() }).required(), ) + + return Math.min(json.exp, max) } + + static _isJwtValid(expiry) { + // we consider the token valid if the expiry + // datetime is later than (now + 1 minute) + return dayjs.unix(expiry).isAfter(dayjs().add(1, 'minutes')) + } + + async _getJwt(loginEndpoint) { + const { _user: username, _pass: password } = this + + // attempt to get JWT from cache + if ( + jwtCache?.[loginEndpoint]?.[username]?.token && + jwtCache?.[loginEndpoint]?.[username]?.expiry && + this.constructor._isJwtValid(jwtCache[loginEndpoint][username].expiry) + ) { + // cache hit + return jwtCache[loginEndpoint][username].token + } + + // cache miss - request a new JWT + const originViolation = !this.isAllowedOrigin(loginEndpoint) + if (originViolation) { + throw new InvalidParameter({ + prettyMessage: 'requested origin not authorized', + }) + } + + const { buffer } = await checkErrorResponse({})( + await fetch(loginEndpoint, { + method: 'POST', + form: { username, password }, + }), + ) + + const json = validate( + { + ErrorClass: InvalidResponse, + prettyErrorMessage: 'invalid response data from auth endpoint', + }, + parseJson(buffer), + Joi.object({ token: Joi.string().required() }).required(), + ) + + const token = json.token + const expiry = this.constructor._getJwtExpiry(token) + + // store in the cache + if (!(loginEndpoint in jwtCache)) { + jwtCache[loginEndpoint] = {} + } + jwtCache[loginEndpoint][username] = { token, expiry } + + return token + } + + async _getJwtAuthHeader(loginEndpoint) { + if (!this.isConfigured) { + return undefined + } + + const token = await this._getJwt(loginEndpoint) + return { Authorization: `Bearer ${token}` } + } + + async withJwtAuth(requestParams, loginEndpoint) { + const authHeader = await this._getJwtAuthHeader(loginEndpoint) + return this._withAnyAuth(requestParams, requestParams => + this.constructor._mergeHeaders(requestParams, authHeader), + ) + } +} + +function clearJwtCache() { + jwtCache = Object.create(null) } -export { AuthHelper } +export { AuthHelper, clearJwtCache } diff --git a/core/base-service/auth-helper.spec.js b/core/base-service/auth-helper.spec.js index fd36f950575ac..beb4142eb2e88 100644 --- a/core/base-service/auth-helper.spec.js +++ b/core/base-service/auth-helper.spec.js @@ -1,19 +1,45 @@ +import dayjs from 'dayjs' +import nock from 'nock' import { expect } from 'chai' import { test, given, forCases } from 'sazerac' -import { AuthHelper } from './auth-helper.js' -import { InvalidParameter } from './errors.js' +import { AuthHelper, clearJwtCache } from './auth-helper.js' +import { InvalidParameter, InvalidResponse } from './errors.js' + +function base64UrlEncode(input) { + const base64 = btoa(JSON.stringify(input)) + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function getMockJwt(extras) { + // this function returns a mock JWT that contains enough + // for a unit test but ignores important aspects e.g: signing + + const header = { + alg: 'HS256', + typ: 'JWT', + } + const payload = { + iat: Math.floor(Date.now() / 1000), + ...extras, + } + + const encodedHeader = base64UrlEncode(header) + const encodedPayload = base64UrlEncode(payload) + return `${encodedHeader}.${encodedPayload}` +} describe('AuthHelper', function () { describe('constructor checks', function () { it('throws without userKey or passKey', function () { expect(() => new AuthHelper({}, {})).to.throw( Error, - 'Expected userKey or passKey to be set' + 'Expected userKey or passKey to be set', ) }) it('throws without serviceKey or authorizedOrigins', function () { expect( - () => new AuthHelper({ userKey: 'myci_user', passKey: 'myci_pass' }, {}) + () => + new AuthHelper({ userKey: 'myci_user', passKey: 'myci_pass' }, {}), ).to.throw(Error, 'Expected authorizedOrigins or serviceKey to be set') }) it('throws when authorizedOrigins is not an array', function () { @@ -25,8 +51,8 @@ describe('AuthHelper', function () { passKey: 'myci_pass', authorizedOrigins: true, }, - { private: {} } - ) + { private: {} }, + ), ).to.throw(Error, 'Expected authorizedOrigins to be an array of origins') }) }) @@ -35,7 +61,7 @@ describe('AuthHelper', function () { function validate(config, privateConfig) { return new AuthHelper( { authorizedOrigins: ['https://example.test'], ...config }, - { private: privateConfig } + { private: privateConfig }, ).isValid } test(validate, () => { @@ -43,20 +69,20 @@ describe('AuthHelper', function () { // Fully configured user + pass. given( { userKey: 'myci_user', passKey: 'myci_pass', isRequired: true }, - { myci_user: 'admin', myci_pass: 'abc123' } + { myci_user: 'admin', myci_pass: 'abc123' }, ), given( { userKey: 'myci_user', passKey: 'myci_pass' }, - { myci_user: 'admin', myci_pass: 'abc123' } + { myci_user: 'admin', myci_pass: 'abc123' }, ), // Fully configured user or pass. given( { userKey: 'myci_user', isRequired: true }, - { myci_user: 'admin' } + { myci_user: 'admin' }, ), given( { passKey: 'myci_pass', isRequired: true }, - { myci_pass: 'abc123' } + { myci_pass: 'abc123' }, ), given({ userKey: 'myci_user' }, { myci_user: 'admin' }), given({ passKey: 'myci_pass' }, { myci_pass: 'abc123' }), @@ -70,16 +96,16 @@ describe('AuthHelper', function () { // Partly configured. given( { userKey: 'myci_user', passKey: 'myci_pass', isRequired: true }, - { myci_user: 'admin' } + { myci_user: 'admin' }, ), given( { userKey: 'myci_user', passKey: 'myci_pass' }, - { myci_user: 'admin' } + { myci_user: 'admin' }, ), // Missing required config. given( { userKey: 'myci_user', passKey: 'myci_pass', isRequired: true }, - {} + {}, ), given({ userKey: 'myci_user', isRequired: true }, {}), given({ passKey: 'myci_pass', isRequired: true }, {}), @@ -91,37 +117,37 @@ describe('AuthHelper', function () { function validate(config, privateConfig) { return new AuthHelper( { authorizedOrigins: ['https://example.test'], ...config }, - { private: privateConfig } + { private: privateConfig }, )._basicAuth } test(validate, () => { forCases([ given( { userKey: 'myci_user', passKey: 'myci_pass', isRequired: true }, - { myci_user: 'admin', myci_pass: 'abc123' } + { myci_user: 'admin', myci_pass: 'abc123' }, ), given( { userKey: 'myci_user', passKey: 'myci_pass' }, - { myci_user: 'admin', myci_pass: 'abc123' } + { myci_user: 'admin', myci_pass: 'abc123' }, ), - ]).expect({ user: 'admin', pass: 'abc123' }) + ]).expect({ username: 'admin', password: 'abc123' }) given({ userKey: 'myci_user' }, { myci_user: 'admin' }).expect({ - user: 'admin', - pass: undefined, + username: 'admin', + password: '', }) given({ passKey: 'myci_pass' }, { myci_pass: 'abc123' }).expect({ - user: undefined, - pass: 'abc123', + username: '', + password: 'abc123', }) given({ userKey: 'myci_user', passKey: 'myci_pass' }, {}).expect( - undefined + undefined, ) given( { passKey: 'myci_pass', defaultToEmptyStringForUser: true }, - { myci_pass: 'abc123' } + { myci_pass: 'abc123' }, ).expect({ - user: '', - pass: 'abc123', + username: '', + password: 'abc123', }) }) }) @@ -131,15 +157,18 @@ describe('AuthHelper', function () { forCases([ given({ url: 'http://example.test' }), given({ url: 'http://example.test', options: {} }), - given({ url: 'http://example.test', options: { strictSSL: true } }), given({ url: 'http://example.test', - options: { strictSSL: undefined }, + options: { https: { rejectUnauthorized: true } }, + }), + given({ + url: 'http://example.test', + options: { https: { rejectUnauthorized: undefined } }, }), ]).expect(false) given({ url: 'http://example.test', - options: { strictSSL: false }, + options: { https: { rejectUnauthorized: false } }, }).expect(true) }) }) @@ -163,7 +192,9 @@ describe('AuthHelper', function () { }) it('throws for insecure requests', function () { expect(() => - authHelper.enforceStrictSsl({ options: { strictSSL: false } }) + authHelper.enforceStrictSsl({ + options: { https: { rejectUnauthorized: false } }, + }), ).to.throw(InvalidParameter) }) }) @@ -185,7 +216,9 @@ describe('AuthHelper', function () { }) it('does not throw for insecure requests', function () { expect(() => - authHelper.enforceStrictSsl({ options: { strictSSL: false } }) + authHelper.enforceStrictSsl({ + options: { https: { rejectUnauthorized: false } }, + }), ).not.to.throw() }) }) @@ -220,7 +253,7 @@ describe('AuthHelper', function () { test(shouldAuthenticateRequest, () => { given({ url: 'https://myci.test/api', - options: { strictSSL: false }, + options: { https: { rejectUnauthorized: false } }, }).expect(false) }) }) @@ -258,7 +291,7 @@ describe('AuthHelper', function () { test(shouldAuthenticateRequest, () => { given({ url: 'https://myci.test', - options: { strictSSL: false }, + options: { https: { rejectUnauthorized: false } }, }).expect(true) }) }) @@ -311,7 +344,7 @@ describe('AuthHelper', function () { }, }, private: { myci_user: 'admin', myci_pass: 'abc123' }, - } + }, ) const withBasicAuth = requestOptions => authHelper.withBasicAuth(requestOptions) @@ -323,7 +356,8 @@ describe('AuthHelper', function () { }).expect({ url: 'https://myci.test/api', options: { - auth: { user: 'admin', pass: 'abc123' }, + username: 'admin', + password: 'abc123', }, }) given({ @@ -335,7 +369,8 @@ describe('AuthHelper', function () { url: 'https://myci.test/api', options: { headers: { Accept: 'application/json' }, - auth: { user: 'admin', pass: 'abc123' }, + username: 'admin', + password: 'abc123', }, }) }) @@ -366,9 +401,158 @@ describe('AuthHelper', function () { expect(() => withBasicAuth({ url: 'https://myci.test/api', - options: { strictSSL: false }, - }) + options: { https: { rejectUnauthorized: false } }, + }), ).to.throw(InvalidParameter) }) }) + + context('JTW Auth', function () { + describe('_isJwtValid', function () { + test(AuthHelper._isJwtValid, () => { + given(dayjs().add(1, 'month').unix()).expect(true) + given(dayjs().add(2, 'minutes').unix()).expect(true) + given(dayjs().add(30, 'seconds').unix()).expect(false) + given(dayjs().unix()).expect(false) + given(dayjs().subtract(1, 'seconds').unix()).expect(false) + }) + }) + + describe('_getJwtExpiry', function () { + it('extracts expiry from valid JWT', function () { + const nowPlus30Mins = dayjs().add(30, 'minutes').unix() + expect( + AuthHelper._getJwtExpiry(getMockJwt({ exp: nowPlus30Mins })), + ).to.equal(nowPlus30Mins) + }) + + it('caps expiry at max', function () { + const nowPlus1Hour = dayjs().add(1, 'hours').unix() + const nowPlus2Hours = dayjs().add(2, 'hours').unix() + expect( + AuthHelper._getJwtExpiry(getMockJwt({ exp: nowPlus2Hours })), + ).to.equal(nowPlus1Hour) + }) + + it('throws if JWT does not contain exp', function () { + expect(() => { + AuthHelper._getJwtExpiry(getMockJwt({})) + }).to.throw(InvalidResponse) + }) + + it('throws if JWT is invalid', function () { + expect(() => { + AuthHelper._getJwtExpiry('abc') + }).to.throw(InvalidResponse) + }) + }) + + describe('withJwtAuth', function () { + const authHelper = new AuthHelper( + { + userKey: 'jwt_user', + passKey: 'jwt_pass', + authorizedOrigins: ['https://example.com'], + isRequired: false, + }, + { private: { jwt_user: 'fred', jwt_pass: 'abc123' } }, + ) + + beforeEach(function () { + clearJwtCache() + }) + + it('should use cached response if valid', async function () { + // the expiry is far enough in the future that the token + // will still be valid on the second hit + const mockToken = getMockJwt({ exp: dayjs().add(1, 'hours').unix() }) + + // .times(1) ensures if we try to make a second call to this endpoint, + // we will throw `Nock: No match for request` + nock('https://example.com') + .post('/login') + .times(1) + .reply(200, { token: mockToken }) + const params1 = await authHelper.withJwtAuth( + { url: 'https://example.com/some-endpoint' }, + 'https://example.com/login', + ) + expect(nock.isDone()).to.equal(true) + expect(params1).to.deep.equal({ + options: { + headers: { + Authorization: `Bearer ${mockToken}`, + }, + }, + url: 'https://example.com/some-endpoint', + }) + + // second time round, we'll get the same response again + // but this time served from cache + const params2 = await authHelper.withJwtAuth( + { url: 'https://example.com/some-endpoint' }, + 'https://example.com/login', + ) + expect(params2).to.deep.equal({ + options: { + headers: { + Authorization: `Bearer ${mockToken}`, + }, + }, + url: 'https://example.com/some-endpoint', + }) + + nock.cleanAll() + }) + + it('should not use cached response if expired', async function () { + // this time we define a token expiry is close enough + // that the token will not be valid on the second call + const mockToken1 = getMockJwt({ + exp: dayjs().add(20, 'seconds').unix(), + }) + nock('https://example.com') + .post('/login') + .times(1) + .reply(200, { token: mockToken1 }) + const params1 = await authHelper.withJwtAuth( + { url: 'https://example.com/some-endpoint' }, + 'https://example.com/login', + ) + expect(nock.isDone()).to.equal(true) + expect(params1).to.deep.equal({ + options: { + headers: { + Authorization: `Bearer ${mockToken1}`, + }, + }, + url: 'https://example.com/some-endpoint', + }) + + // second time round we make another network request + const mockToken2 = getMockJwt({ + exp: dayjs().add(20, 'seconds').unix(), + }) + nock('https://example.com') + .post('/login') + .times(1) + .reply(200, { token: mockToken2 }) + const params2 = await authHelper.withJwtAuth( + { url: 'https://example.com/some-endpoint' }, + 'https://example.com/login', + ) + expect(nock.isDone()).to.equal(true) + expect(params2).to.deep.equal({ + options: { + headers: { + Authorization: `Bearer ${mockToken2}`, + }, + }, + url: 'https://example.com/some-endpoint', + }) + + nock.cleanAll() + }) + }) + }) }) diff --git a/core/base-service/base-graphql.js b/core/base-service/base-graphql.js index bbdcb9224802c..e70a81c0d4c52 100644 --- a/core/base-service/base-graphql.js +++ b/core/base-service/base-graphql.js @@ -20,13 +20,15 @@ class BaseGraphqlService extends BaseService { /** * Parse data from JSON endpoint * - * @param {string} buffer JSON repsonse from upstream API + * @param {string} buffer JSON response from upstream API * @returns {object} Parsed response */ _parseJson(buffer) { return parseJson(buffer) } + static headers = { Accept: 'application/json' } + /** * Request data from an upstream GraphQL API, * parse it and validate against a schema @@ -38,14 +40,22 @@ class BaseGraphqlService extends BaseService { * representing the query clause of GraphQL POST body * e.g. gql`{ query { ... } }` * @param {object} attrs.variables Variables clause of GraphQL POST body - * @param {object} [attrs.options={}] Options to pass to request. See - * [documentation](https://github.com/request/request#requestoptions-callback) + * @param {object} [attrs.options={}] Options to pass to got. See + * [documentation](https://github.com/sindresorhus/got/blob/main/documentation/2-options.md) * @param {object} [attrs.httpErrorMessages={}] Key-value map of HTTP status codes * and custom error messages e.g: `{ 404: 'package not found' }`. * This can be used to extend or override the * [default](https://github.com/badges/shields/blob/master/core/base-service/check-error-response.js#L5) + * @param {object} [attrs.systemErrors={}] Key-value map of got network exception codes + * and an object of params to pass when we construct an Inaccessible exception object + * e.g: `{ ECONNRESET: { prettyMessage: 'connection reset' } }`. + * See {@link https://github.com/sindresorhus/got/blob/main/documentation/7-retry.md#errorcodes got error codes} + * for allowed keys + * and {@link module:core/base-service/errors~RuntimeErrorProps} for allowed values + * @param {number[]} [attrs.logErrors=[429]] An array of http error codes + * that will be logged (to sentry, if configured). * @param {Function} [attrs.transformJson=data => data] Function which takes the raw json and transforms it before - * further procesing. In case of multiple query in a single graphql call and few of them + * further processing. In case of multiple query in a single graphql call and few of them * throw error, partial data might be used ignoring the error. * @param {Function} [attrs.transformErrors=defaultTransformErrors] * Function which takes an errors object from a GraphQL @@ -53,7 +63,7 @@ class BaseGraphqlService extends BaseService { * The default is to return the first entry of the `errors` array as * an InvalidResponse. * @returns {object} Parsed response - * @see https://github.com/request/request#requestoptions-callback + * @see https://github.com/sindresorhus/got/blob/main/documentation/2-options.md */ async _requestGraphql({ schema, @@ -62,11 +72,13 @@ class BaseGraphqlService extends BaseService { variables = {}, options = {}, httpErrorMessages = {}, + systemErrors = {}, + logErrors = [429], transformJson = data => data, transformErrors = defaultTransformErrors, }) { const mergedOptions = { - ...{ headers: { Accept: 'application/json' } }, + ...{ headers: this.constructor.headers }, ...options, } mergedOptions.method = 'POST' @@ -74,7 +86,9 @@ class BaseGraphqlService extends BaseService { const { buffer } = await this._request({ url, options: mergedOptions, - errorMessages: httpErrorMessages, + httpErrors: httpErrorMessages, + systemErrors, + logErrors, }) const json = transformJson(this._parseJson(buffer)) if (json.errors) { @@ -83,7 +97,7 @@ class BaseGraphqlService extends BaseService { throw exception } else { throw Error( - `transformErrors() must return a ShieldsRuntimeError; got ${exception}` + `transformErrors() must return a ShieldsRuntimeError; got ${exception}`, ) } } diff --git a/core/base-service/base-graphql.spec.js b/core/base-service/base-graphql.spec.js index ee59b8ddecd87..b22f96ce12ebc 100644 --- a/core/base-service/base-graphql.spec.js +++ b/core/base-service/base-graphql.spec.js @@ -29,33 +29,33 @@ class DummyGraphqlService extends BaseGraphqlService { describe('BaseGraphqlService', function () { describe('Making requests', function () { - let sendAndCacheRequest + let requestFetcher beforeEach(function () { - sendAndCacheRequest = sinon.stub().returns( + requestFetcher = sinon.stub().returns( Promise.resolve({ buffer: '{"some": "json"}', res: { statusCode: 200 }, - }) + }), ) }) - it('invokes _sendAndCacheRequest', async function () { + it('invokes _requestFetcher', async function () { await DummyGraphqlService.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } + { requestFetcher }, + { handleInternalErrors: false }, ) - expect(sendAndCacheRequest).to.have.been.calledOnceWith( + expect(requestFetcher).to.have.been.calledOnceWith( 'http://example.com/graphql', { - body: '{"query":"{\\n requiredString\\n}\\n","variables":{}}', + body: '{"query":"{\\n requiredString\\n}","variables":{}}', headers: { Accept: 'application/json' }, method: 'POST', - } + }, ) }) - it('forwards options to _sendAndCacheRequest', async function () { + it('forwards options to _requestFetcher', async function () { class WithOptions extends DummyGraphqlService { async handle() { const { value } = await this._requestGraphql({ @@ -66,55 +66,55 @@ describe('BaseGraphqlService', function () { requiredString } `, - options: { qs: { queryParam: 123 } }, + options: { searchParams: { queryParam: 123 } }, }) return { message: value } } } await WithOptions.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } + { requestFetcher }, + { handleInternalErrors: false }, ) - expect(sendAndCacheRequest).to.have.been.calledOnceWith( + expect(requestFetcher).to.have.been.calledOnceWith( 'http://example.com/graphql', { - body: '{"query":"{\\n requiredString\\n}\\n","variables":{}}', + body: '{"query":"{\\n requiredString\\n}","variables":{}}', headers: { Accept: 'application/json' }, method: 'POST', - qs: { queryParam: 123 }, - } + searchParams: { queryParam: 123 }, + }, ) }) }) describe('Making badges', function () { it('handles valid json responses', async function () { - const sendAndCacheRequest = async () => ({ + const requestFetcher = async () => ({ buffer: '{"requiredString": "some-string"}', res: { statusCode: 200 }, }) expect( await DummyGraphqlService.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } - ) + { requestFetcher }, + { handleInternalErrors: false }, + ), ).to.deep.equal({ message: 'some-string', }) }) it('handles json responses which do not match the schema', async function () { - const sendAndCacheRequest = async () => ({ + const requestFetcher = async () => ({ buffer: '{"unexpectedKey": "some-string"}', res: { statusCode: 200 }, }) expect( await DummyGraphqlService.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } - ) + { requestFetcher }, + { handleInternalErrors: false }, + ), ).to.deep.equal({ isError: true, color: 'lightgray', @@ -123,15 +123,15 @@ describe('BaseGraphqlService', function () { }) it('handles unparseable json responses', async function () { - const sendAndCacheRequest = async () => ({ + const requestFetcher = async () => ({ buffer: 'not json', res: { statusCode: 200 }, }) expect( await DummyGraphqlService.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } - ) + { requestFetcher }, + { handleInternalErrors: false }, + ), ).to.deep.equal({ isError: true, color: 'lightgray', @@ -142,15 +142,15 @@ describe('BaseGraphqlService', function () { describe('Error handling', function () { it('handles generic error', async function () { - const sendAndCacheRequest = async () => ({ + const requestFetcher = async () => ({ buffer: '{ "errors": [ { "message": "oh noes!!" } ] }', res: { statusCode: 200 }, }) expect( await DummyGraphqlService.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } - ) + { requestFetcher }, + { handleInternalErrors: false }, + ), ).to.deep.equal({ isError: true, color: 'lightgray', @@ -181,15 +181,15 @@ describe('BaseGraphqlService', function () { } } - const sendAndCacheRequest = async () => ({ + const requestFetcher = async () => ({ buffer: '{ "errors": [ { "message": "oh noes!!" } ] }', res: { statusCode: 200 }, }) expect( await WithErrorHandler.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } - ) + { requestFetcher }, + { handleInternalErrors: false }, + ), ).to.deep.equal({ isError: true, color: 'lightgray', diff --git a/core/base-service/base-json.js b/core/base-service/base-json.js index 7f4730d2d2058..c87bafe038b2c 100644 --- a/core/base-service/base-json.js +++ b/core/base-service/base-json.js @@ -14,13 +14,15 @@ class BaseJsonService extends BaseService { /** * Parse data from JSON endpoint * - * @param {string} buffer JSON repsonse from upstream API + * @param {string} buffer JSON response from upstream API * @returns {object} Parsed response */ _parseJson(buffer) { return parseJson(buffer) } + static headers = { Accept: 'application/json' } + /** * Request data from an upstream API serving JSON, * parse it and validate against a schema @@ -28,24 +30,41 @@ class BaseJsonService extends BaseService { * @param {object} attrs Refer to individual attrs * @param {Joi} attrs.schema Joi schema to validate the response against * @param {string} attrs.url URL to request - * @param {object} [attrs.options={}] Options to pass to request. See - * [documentation](https://github.com/request/request#requestoptions-callback) - * @param {object} [attrs.errorMessages={}] Key-value map of status codes + * @param {object} [attrs.options={}] Options to pass to got. See + * [documentation](https://github.com/sindresorhus/got/blob/main/documentation/2-options.md) + * @param {object} [attrs.httpErrors={}] Key-value map of status codes * and custom error messages e.g: `{ 404: 'package not found' }`. * This can be used to extend or override the * [default](https://github.com/badges/shields/blob/master/core/base-service/check-error-response.js#L5) + * @param {object} [attrs.systemErrors={}] Key-value map of got network exception codes + * and an object of params to pass when we construct an Inaccessible exception object + * e.g: `{ ECONNRESET: { prettyMessage: 'connection reset' } }`. + * See {@link https://github.com/sindresorhus/got/blob/main/documentation/7-retry.md#errorcodes got error codes} + * for allowed keys + * and {@link module:core/base-service/errors~RuntimeErrorProps} for allowed values + * @param {number[]} [attrs.logErrors=[429]] An array of http error codes + * that will be logged (to sentry, if configured). * @returns {object} Parsed response - * @see https://github.com/request/request#requestoptions-callback + * @see https://github.com/sindresorhus/got/blob/main/documentation/2-options.md */ - async _requestJson({ schema, url, options = {}, errorMessages = {} }) { + async _requestJson({ + schema, + url, + options = {}, + httpErrors = {}, + systemErrors = {}, + logErrors = [429], + }) { const mergedOptions = { - ...{ headers: { Accept: 'application/json' } }, + ...{ headers: this.constructor.headers }, ...options, } const { buffer } = await this._request({ url, options: mergedOptions, - errorMessages, + httpErrors, + systemErrors, + logErrors, }) const json = this._parseJson(buffer) return this.constructor._validate(json, schema) diff --git a/core/base-service/base-json.spec.js b/core/base-service/base-json.spec.js index 45665e4702893..fc894d7a89fd9 100644 --- a/core/base-service/base-json.spec.js +++ b/core/base-service/base-json.spec.js @@ -22,84 +22,84 @@ class DummyJsonService extends BaseJsonService { describe('BaseJsonService', function () { describe('Making requests', function () { - let sendAndCacheRequest + let requestFetcher beforeEach(function () { - sendAndCacheRequest = sinon.stub().returns( + requestFetcher = sinon.stub().returns( Promise.resolve({ buffer: '{"some": "json"}', res: { statusCode: 200 }, - }) + }), ) }) - it('invokes _sendAndCacheRequest', async function () { + it('invokes _requestFetcher', async function () { await DummyJsonService.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } + { requestFetcher }, + { handleInternalErrors: false }, ) - expect(sendAndCacheRequest).to.have.been.calledOnceWith( + expect(requestFetcher).to.have.been.calledOnceWith( 'http://example.com/foo.json', { headers: { Accept: 'application/json' }, - } + }, ) }) - it('forwards options to _sendAndCacheRequest', async function () { + it('forwards options to _requestFetcher', async function () { class WithOptions extends DummyJsonService { async handle() { const { value } = await this._requestJson({ schema: dummySchema, url: 'http://example.com/foo.json', - options: { method: 'POST', qs: { queryParam: 123 } }, + options: { method: 'POST', searchParams: { queryParam: 123 } }, }) return { message: value } } } await WithOptions.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } + { requestFetcher }, + { handleInternalErrors: false }, ) - expect(sendAndCacheRequest).to.have.been.calledOnceWith( + expect(requestFetcher).to.have.been.calledOnceWith( 'http://example.com/foo.json', { headers: { Accept: 'application/json' }, method: 'POST', - qs: { queryParam: 123 }, - } + searchParams: { queryParam: 123 }, + }, ) }) }) describe('Making badges', function () { it('handles valid json responses', async function () { - const sendAndCacheRequest = async () => ({ + const requestFetcher = async () => ({ buffer: '{"requiredString": "some-string"}', res: { statusCode: 200 }, }) expect( await DummyJsonService.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } - ) + { requestFetcher }, + { handleInternalErrors: false }, + ), ).to.deep.equal({ message: 'some-string', }) }) it('handles json responses which do not match the schema', async function () { - const sendAndCacheRequest = async () => ({ + const requestFetcher = async () => ({ buffer: '{"unexpectedKey": "some-string"}', res: { statusCode: 200 }, }) expect( await DummyJsonService.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } - ) + { requestFetcher }, + { handleInternalErrors: false }, + ), ).to.deep.equal({ isError: true, color: 'lightgray', @@ -108,15 +108,15 @@ describe('BaseJsonService', function () { }) it('handles unparseable json responses', async function () { - const sendAndCacheRequest = async () => ({ + const requestFetcher = async () => ({ buffer: 'not json', res: { statusCode: 200 }, }) expect( await DummyJsonService.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } - ) + { requestFetcher }, + { handleInternalErrors: false }, + ), ).to.deep.equal({ isError: true, color: 'lightgray', diff --git a/core/base-service/base-jsonl.js b/core/base-service/base-jsonl.js new file mode 100644 index 0000000000000..0879da31baf5d --- /dev/null +++ b/core/base-service/base-jsonl.js @@ -0,0 +1,82 @@ +/** + * @module + */ + +import BaseService from './base.js' +import { parseJsonl } from './jsonl.js' + +/** + * Services which query a JSONL endpoint should extend BaseJsonlService + * + * @abstract + */ +class BaseJsonlService extends BaseService { + /** + * Parse data from a JSONL endpoint + * + * @param {string} buffer JSONL response from upstream API + * @returns {object[]} Parsed response + */ + _parseJsonl(buffer) { + return parseJsonl(buffer) + } + + static headers = { + Accept: 'application/jsonl, application/x-ndjson, text/plain', + } + + /** + * Request data from an upstream API serving JSONL, + * parse it line-by-line and validate each entry against a schema + * + * @param {object} attrs Refer to individual attrs + * @param {Joi} attrs.schema Joi schema to validate each response line against + * @param {string} attrs.url URL to request + * @param {object} [attrs.options={}] Options to pass to got. See + * [documentation](https://github.com/sindresorhus/got/blob/main/documentation/2-options.md) + * @param {object} [attrs.httpErrors={}] Key-value map of status codes + * and custom error messages e.g: `{ 404: 'package not found' }`. + * This can be used to extend or override the + * [default](https://github.com/badges/shields/blob/master/core/base-service/check-error-response.js#L5) + * @param {object} [attrs.systemErrors={}] Key-value map of got network exception codes + * and an object of params to pass when we construct an Inaccessible exception object + * e.g: `{ ECONNRESET: { prettyMessage: 'connection reset' } }`. + * See {@link https://github.com/sindresorhus/got/blob/main/documentation/7-retry.md#errorcodes got error codes} + * for allowed keys + * and {@link module:core/base-service/errors~RuntimeErrorProps} for allowed values + * @param {number[]} [attrs.logErrors=[429]] An array of http error codes + * that will be logged (to sentry, if configured). + * @param {string} [attrs.prettyErrorMessage='invalid response data'] + * Error message to surface when schema validation fails. + * @returns {object[]} Parsed response + * @see https://github.com/sindresorhus/got/blob/main/documentation/2-options.md + */ + async _requestJsonl({ + schema, + url, + options = {}, + httpErrors = {}, + systemErrors = {}, + logErrors = [429], + prettyErrorMessage = 'invalid response data', + }) { + const mergedOptions = { + ...{ headers: this.constructor.headers }, + ...options, + } + const { buffer } = await this._request({ + url, + options: mergedOptions, + httpErrors, + systemErrors, + logErrors, + }) + const jsonl = this._parseJsonl(buffer) + + return jsonl.map(line => + this.constructor._validate(line, schema, { prettyErrorMessage }), + ) + } +} + +export default BaseJsonlService diff --git a/core/base-service/base-jsonl.spec.js b/core/base-service/base-jsonl.spec.js new file mode 100644 index 0000000000000..78cb541d4f1c7 --- /dev/null +++ b/core/base-service/base-jsonl.spec.js @@ -0,0 +1,144 @@ +import Joi from 'joi' +import { expect } from 'chai' +import sinon from 'sinon' +import BaseJsonlService from './base-jsonl.js' + +const lineSchema = Joi.object({ + requiredString: Joi.string().required(), +}).required() + +class DummyJsonlService extends BaseJsonlService { + static category = 'cat' + static route = { base: 'foo' } + + async handle() { + const [{ requiredString }] = await this._requestJsonl({ + schema: lineSchema, + url: 'http://example.com/foo.jsonl', + }) + return { message: requiredString } + } +} + +const expectedJsonl = ` +{"requiredString":"some-string"} +{"requiredString":"another-string"} +` + +const unexpectedJsonl = ` +{"unexpectedKey":"some-string"} +` + +describe('BaseJsonlService', function () { + describe('Making requests', function () { + let requestFetcher + beforeEach(function () { + requestFetcher = sinon.stub().returns( + Promise.resolve({ + buffer: expectedJsonl, + res: { statusCode: 200 }, + }), + ) + }) + + it('invokes _requestFetcher', async function () { + await DummyJsonlService.invoke( + { requestFetcher }, + { handleInternalErrors: false }, + ) + + sinon.assert.calledOnceWithExactly( + requestFetcher, + 'http://example.com/foo.jsonl', + { + headers: { + Accept: 'application/jsonl, application/x-ndjson, text/plain', + }, + }, + {}, + ) + }) + + it('forwards options to _requestFetcher', async function () { + class WithOptions extends DummyJsonlService { + async handle() { + const [{ requiredString }] = await this._requestJsonl({ + schema: lineSchema, + url: 'http://example.com/foo.jsonl', + options: { method: 'POST', searchParams: { queryParam: 123 } }, + }) + return { message: requiredString } + } + } + + await WithOptions.invoke( + { requestFetcher }, + { handleInternalErrors: false }, + ) + + sinon.assert.calledOnceWithExactly( + requestFetcher, + 'http://example.com/foo.jsonl', + { + headers: { + Accept: 'application/jsonl, application/x-ndjson, text/plain', + }, + method: 'POST', + searchParams: { queryParam: 123 }, + }, + {}, + ) + }) + }) + + describe('Making badges', function () { + it('handles valid jsonl responses', async function () { + const requestFetcher = async () => ({ + buffer: expectedJsonl, + res: { statusCode: 200 }, + }) + expect( + await DummyJsonlService.invoke( + { requestFetcher }, + { handleInternalErrors: false }, + ), + ).to.deep.equal({ + message: 'some-string', + }) + }) + + it('handles jsonl responses which do not match the schema', async function () { + const requestFetcher = async () => ({ + buffer: unexpectedJsonl, + res: { statusCode: 200 }, + }) + expect( + await DummyJsonlService.invoke( + { requestFetcher }, + { handleInternalErrors: false }, + ), + ).to.deep.equal({ + isError: true, + color: 'lightgray', + message: 'invalid response data', + }) + }) + + it('handles unparseable jsonl responses', async function () { + const requestFetcher = async () => ({ + buffer: 'not json', + res: { statusCode: 200 }, + }) + expect( + await DummyJsonlService.invoke( + { requestFetcher }, + { handleInternalErrors: false }, + ), + ).to.deep.equal({ + isError: true, + color: 'lightgray', + message: 'unparseable jsonl response', + }) + }) + }) +}) diff --git a/core/base-service/base-static.js b/core/base-service/base-static.js index df9eae5efb953..0990d6ec44b6e 100644 --- a/core/base-service/base-static.js +++ b/core/base-service/base-static.js @@ -9,6 +9,14 @@ import { MetricHelper } from './metric-helper.js' import coalesceBadge from './coalesce-badge.js' import { prepareRoute, namedParamsForMatch } from './route.js' +/** + * Base class for services that generate static badges from route parameters + * and query parameters. + * + * Registers the service route, handles cache validation, invokes the service, + * builds the badge data, sets cache headers, renders the badge, and records + * request metrics. + */ export default class BaseStaticService extends BaseService { static register({ camp, metricInstance }, serviceConfig) { const { regex, captureNames } = prepareRoute(this.route) @@ -33,21 +41,25 @@ export default class BaseStaticService extends BaseService { {}, serviceConfig, namedParams, - queryParams + queryParams, ) const badgeData = coalesceBadge( queryParams, serviceData, this.defaultBadgeData, - this + this, ) // The final capture group is the extension. const format = (match.slice(-1)[0] || '.svg').replace(/^\./, '') badgeData.format = format - setCacheHeadersForStaticResource(ask.res) + let maxAge = 24 * 3600 // 1 day + if (!queryParams.logo && !badgeData.isError) { + maxAge = 5 * 24 * 3600 // 5 days + } + setCacheHeadersForStaticResource(ask.res, maxAge) const svg = makeBadge(badgeData) makeSend(format, ask.res, end)(svg) diff --git a/core/base-service/base-svg-scraping.js b/core/base-service/base-svg-scraping.js index 993e0b584bdc5..29051b78b95e1 100644 --- a/core/base-service/base-svg-scraping.js +++ b/core/base-service/base-svg-scraping.js @@ -8,7 +8,7 @@ import BaseService from './base.js' import trace from './trace.js' import { InvalidResponse } from './errors.js' -const defaultValueMatcher = />([^<>]+)<\/text><\/g>/ +const defaultValueMatcher = /.*>([^<>]+)<\/text><\/g>/ const leadingWhitespace = /(?:\r\n\s*|\r\s*|\n\s*)/g /** @@ -42,6 +42,8 @@ class BaseSvgScrapingService extends BaseService { } } + static headers = { Accept: 'image/svg+xml' } + /** * Request data from an endpoint serving SVG, * parse a value from it and validate against a schema @@ -51,31 +53,43 @@ class BaseSvgScrapingService extends BaseService { * @param {RegExp} attrs.valueMatcher * RegExp to match the value we want to parse from the SVG * @param {string} attrs.url URL to request - * @param {object} [attrs.options={}] Options to pass to request. See - * [documentation](https://github.com/request/request#requestoptions-callback) - * @param {object} [attrs.errorMessages={}] Key-value map of status codes + * @param {object} [attrs.options={}] Options to pass to got. See + * [documentation](https://github.com/sindresorhus/got/blob/main/documentation/2-options.md) + * @param {object} [attrs.httpErrors={}] Key-value map of status codes * and custom error messages e.g: `{ 404: 'package not found' }`. * This can be used to extend or override the * [default](https://github.com/badges/shields/blob/master/core/base-service/check-error-response.js#L5) + * @param {object} [attrs.systemErrors={}] Key-value map of got network exception codes + * and an object of params to pass when we construct an Inaccessible exception object + * e.g: `{ ECONNRESET: { prettyMessage: 'connection reset' } }`. + * See {@link https://github.com/sindresorhus/got/blob/main/documentation/7-retry.md#errorcodes got error codes} + * for allowed keys + * and {@link module:core/base-service/errors~RuntimeErrorProps} for allowed values + * @param {number[]} [attrs.logErrors=[429]] An array of http error codes + * that will be logged (to sentry, if configured). * @returns {object} Parsed response - * @see https://github.com/request/request#requestoptions-callback + * @see https://github.com/sindresorhus/got/blob/main/documentation/2-options.md */ async _requestSvg({ schema, valueMatcher, url, options = {}, - errorMessages = {}, + httpErrors = {}, + systemErrors = {}, + logErrors = [429], }) { const logTrace = (...args) => trace.logTrace('fetch', ...args) const mergedOptions = { - ...{ headers: { Accept: 'image/svg+xml' } }, + ...{ headers: this.constructor.headers }, ...options, } const { buffer } = await this._request({ url, options: mergedOptions, - errorMessages, + httpErrors, + systemErrors, + logErrors, }) logTrace(emojic.dart, 'Response SVG', buffer) const data = { diff --git a/core/base-service/base-svg-scraping.spec.js b/core/base-service/base-svg-scraping.spec.js index 728f3bd188c7b..24502a8584203 100644 --- a/core/base-service/base-svg-scraping.spec.js +++ b/core/base-service/base-svg-scraping.spec.js @@ -28,37 +28,37 @@ describe('BaseSvgScrapingService', function () { describe('valueFromSvgBadge', function () { it('should find the correct value', function () { expect(BaseSvgScrapingService.valueFromSvgBadge(exampleSvg)).to.equal( - exampleMessage + exampleMessage, ) }) }) describe('Making requests', function () { - let sendAndCacheRequest + let requestFetcher beforeEach(function () { - sendAndCacheRequest = sinon.stub().returns( + requestFetcher = sinon.stub().returns( Promise.resolve({ buffer: exampleSvg, res: { statusCode: 200 }, - }) + }), ) }) - it('invokes _sendAndCacheRequest with the expected header', async function () { + it('invokes _requestFetcher with the expected header', async function () { await DummySvgScrapingService.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } + { requestFetcher }, + { handleInternalErrors: false }, ) - expect(sendAndCacheRequest).to.have.been.calledOnceWith( + expect(requestFetcher).to.have.been.calledOnceWith( 'http://example.com/foo.svg', { headers: { Accept: 'image/svg+xml' }, - } + }, ) }) - it('forwards options to _sendAndCacheRequest', async function () { + it('forwards options to _requestFetcher', async function () { class WithCustomOptions extends DummySvgScrapingService { async handle() { const { message } = await this._requestSvg({ @@ -66,7 +66,7 @@ describe('BaseSvgScrapingService', function () { url: 'http://example.com/foo.svg', options: { method: 'POST', - qs: { queryParam: 123 }, + searchParams: { queryParam: 123 }, }, }) return { message } @@ -74,32 +74,32 @@ describe('BaseSvgScrapingService', function () { } await WithCustomOptions.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } + { requestFetcher }, + { handleInternalErrors: false }, ) - expect(sendAndCacheRequest).to.have.been.calledOnceWith( + expect(requestFetcher).to.have.been.calledOnceWith( 'http://example.com/foo.svg', { method: 'POST', headers: { Accept: 'image/svg+xml' }, - qs: { queryParam: 123 }, - } + searchParams: { queryParam: 123 }, + }, ) }) }) describe('Making badges', function () { it('handles valid svg responses', async function () { - const sendAndCacheRequest = async () => ({ + const requestFetcher = async () => ({ buffer: exampleSvg, res: { statusCode: 200 }, }) expect( await DummySvgScrapingService.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } - ) + { requestFetcher }, + { handleInternalErrors: false }, + ), ).to.deep.equal({ message: exampleMessage, }) @@ -117,30 +117,30 @@ describe('BaseSvgScrapingService', function () { }) } } - const sendAndCacheRequest = async () => ({ + const requestFetcher = async () => ({ buffer: 'a different message', res: { statusCode: 200 }, }) expect( await WithValueMatcher.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } - ) + { requestFetcher }, + { handleInternalErrors: false }, + ), ).to.deep.equal({ message: 'a different message', }) }) it('handles unparseable svg responses', async function () { - const sendAndCacheRequest = async () => ({ + const requestFetcher = async () => ({ buffer: 'not svg yo', res: { statusCode: 200 }, }) expect( await DummySvgScrapingService.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } - ) + { requestFetcher }, + { handleInternalErrors: false }, + ), ).to.deep.equal({ isError: true, color: 'lightgray', diff --git a/core/base-service/base-toml.js b/core/base-service/base-toml.js new file mode 100644 index 0000000000000..883423b37abe8 --- /dev/null +++ b/core/base-service/base-toml.js @@ -0,0 +1,86 @@ +/** + * @module + */ + +import emojic from 'emojic' +import { parse } from 'smol-toml' +import BaseService from './base.js' +import { InvalidResponse } from './errors.js' +import trace from './trace.js' + +/** + * Services which query a TOML endpoint should extend BaseTomlService + * + * @abstract + */ +class BaseTomlService extends BaseService { + static headers = { + Accept: + // the official header should be application/toml - see https://toml.io/en/v1.0.0#mime-type + // but as this is not registered here https://www.iana.org/assignments/media-types/media-types.xhtml + // some apps use other mime-type like application/x-toml, text/plain etc.... + 'text/x-toml, text/toml, application/x-toml, application/toml, text/plain', + } + + /** + * Request data from an upstream API serving TOML, + * parse it and validate against a schema + * + * @param {object} attrs Refer to individual attrs + * @param {Joi} attrs.schema Joi schema to validate the response against + * @param {string} attrs.url URL to request + * @param {object} [attrs.options={}] Options to pass to got. See + * [documentation](https://github.com/sindresorhus/got/blob/main/documentation/2-options.md) + * @param {object} [attrs.httpErrors={}] Key-value map of status codes + * and custom error messages e.g: `{ 404: 'package not found' }`. + * This can be used to extend or override the + * [default](https://github.com/badges/shields/blob/master/core/base-service/check-error-response.js#L5) + * @param {object} [attrs.systemErrors={}] Key-value map of got network exception codes + * and an object of params to pass when we construct an Inaccessible exception object + * e.g: `{ ECONNRESET: { prettyMessage: 'connection reset' } }`. + * See {@link https://github.com/sindresorhus/got/blob/main/documentation/7-retry.md#errorcodes got error codes} + * for allowed keys + * and {@link module:core/base-service/errors~RuntimeErrorProps} for allowed values + * @param {number[]} [attrs.logErrors=[429]] An array of http error codes + * that will be logged (to sentry, if configured). + * @returns {object} Parsed response + * @see https://github.com/sindresorhus/got/blob/main/documentation/2-options.md + */ + async _requestToml({ + schema, + url, + options = {}, + httpErrors = {}, + systemErrors = {}, + logErrors = [429], + }) { + const logTrace = (...args) => trace.logTrace('fetch', ...args) + const mergedOptions = { + ...{ headers: this.constructor.headers }, + ...options, + } + const { buffer } = await this._request({ + url, + options: mergedOptions, + httpErrors, + systemErrors, + logErrors, + }) + let parsed + try { + parsed = parse(buffer.toString()) + } catch (err) { + logTrace(emojic.dart, 'Response TOML (unparseable)', buffer) + throw new InvalidResponse({ + prettyMessage: 'unparseable toml response', + underlyingError: err, + }) + } + logTrace(emojic.dart, 'Response TOML (before validation)', parsed, { + deep: true, + }) + return this.constructor._validate(parsed, schema) + } +} + +export default BaseTomlService diff --git a/core/base-service/base-toml.spec.js b/core/base-service/base-toml.spec.js new file mode 100644 index 0000000000000..becf3063e4fc9 --- /dev/null +++ b/core/base-service/base-toml.spec.js @@ -0,0 +1,150 @@ +import Joi from 'joi' +import { expect } from 'chai' +import sinon from 'sinon' +import BaseTomlService from './base-toml.js' + +const dummySchema = Joi.object({ + requiredString: Joi.string().required(), +}).required() + +class DummyTomlService extends BaseTomlService { + static category = 'cat' + static route = { base: 'foo' } + + async handle() { + const { requiredString } = await this._requestToml({ + schema: dummySchema, + url: 'http://example.com/foo.toml', + }) + return { message: requiredString } + } +} + +const expectedToml = ` +# example toml +requiredString = "some-string" +` + +const invalidSchemaToml = ` +# example toml - legal toml syntax but invalid schema +unexpectedKey = "some-string" +` + +const invalidTomlSyntax = ` +# example illegal toml syntax that can't be parsed +missing= "space" +colonsCantBeUsed: 42 +missing "assignment" +` + +describe('BaseTomlService', function () { + describe('Making requests', function () { + let requestFetcher + beforeEach(function () { + requestFetcher = sinon.stub().returns( + Promise.resolve({ + buffer: expectedToml, + res: { statusCode: 200 }, + }), + ) + }) + + it('invokes _requestFetcher', async function () { + await DummyTomlService.invoke( + { requestFetcher }, + { handleInternalErrors: false }, + ) + + expect(requestFetcher).to.have.been.calledOnceWith( + 'http://example.com/foo.toml', + { + headers: { + Accept: + 'text/x-toml, text/toml, application/x-toml, application/toml, text/plain', + }, + }, + ) + }) + + it('forwards options to _requestFetcher', async function () { + class WithOptions extends DummyTomlService { + async handle() { + const { requiredString } = await this._requestToml({ + schema: dummySchema, + url: 'http://example.com/foo.toml', + options: { method: 'POST', searchParams: { queryParam: 123 } }, + }) + return { message: requiredString } + } + } + + await WithOptions.invoke( + { requestFetcher }, + { handleInternalErrors: false }, + ) + + expect(requestFetcher).to.have.been.calledOnceWith( + 'http://example.com/foo.toml', + { + headers: { + Accept: + 'text/x-toml, text/toml, application/x-toml, application/toml, text/plain', + }, + method: 'POST', + searchParams: { queryParam: 123 }, + }, + ) + }) + }) + + describe('Making badges', function () { + it('handles valid toml responses', async function () { + const requestFetcher = async () => ({ + buffer: expectedToml, + res: { statusCode: 200 }, + }) + expect( + await DummyTomlService.invoke( + { requestFetcher }, + { handleInternalErrors: false }, + ), + ).to.deep.equal({ + message: 'some-string', + }) + }) + + it('handles toml responses which do not match the schema', async function () { + const requestFetcher = async () => ({ + buffer: invalidSchemaToml, + res: { statusCode: 200 }, + }) + expect( + await DummyTomlService.invoke( + { requestFetcher }, + { handleInternalErrors: false }, + ), + ).to.deep.equal({ + isError: true, + color: 'lightgray', + message: 'invalid response data', + }) + }) + + it('handles unparseable toml responses', async function () { + const requestFetcher = async () => ({ + buffer: invalidTomlSyntax, + res: { statusCode: 200 }, + }) + expect( + await DummyTomlService.invoke( + { requestFetcher }, + { handleInternalErrors: false }, + ), + ).to.deep.equal({ + isError: true, + color: 'lightgray', + message: 'unparseable toml response', + }) + }) + }) +}) diff --git a/core/base-service/base-xml.js b/core/base-service/base-xml.js index ee0c5e9020a0b..0afffeb1dc846 100644 --- a/core/base-service/base-xml.js +++ b/core/base-service/base-xml.js @@ -4,7 +4,7 @@ // See available emoji at http://emoji.muan.co/ import emojic from 'emojic' -import fastXmlParser from 'fast-xml-parser' +import { XMLParser, XMLValidator } from 'fast-xml-parser' import BaseService from './base.js' import trace from './trace.js' import { InvalidResponse } from './errors.js' @@ -15,6 +15,8 @@ import { InvalidResponse } from './errors.js' * @abstract */ class BaseXmlService extends BaseService { + static headers = { Accept: 'application/xml, text/xml' } + /** * Request data from an upstream API serving XML, * parse it and validate against a schema @@ -22,43 +24,56 @@ class BaseXmlService extends BaseService { * @param {object} attrs Refer to individual attrs * @param {Joi} attrs.schema Joi schema to validate the response against * @param {string} attrs.url URL to request - * @param {object} [attrs.options={}] Options to pass to request. See - * [documentation](https://github.com/request/request#requestoptions-callback) - * @param {object} [attrs.errorMessages={}] Key-value map of status codes + * @param {object} [attrs.options={}] Options to pass to got. See + * [documentation](https://github.com/sindresorhus/got/blob/main/documentation/2-options.md) + * @param {object} [attrs.httpErrors={}] Key-value map of status codes * and custom error messages e.g: `{ 404: 'package not found' }`. * This can be used to extend or override the * [default](https://github.com/badges/shields/blob/master/core/base-service/check-error-response.js#L5) + * @param {object} [attrs.systemErrors={}] Key-value map of got network exception codes + * and an object of params to pass when we construct an Inaccessible exception object + * e.g: `{ ECONNRESET: { prettyMessage: 'connection reset' } }`. + * See {@link https://github.com/sindresorhus/got/blob/main/documentation/7-retry.md#errorcodes got error codes} + * for allowed keys + * and {@link module:core/base-service/errors~RuntimeErrorProps} for allowed values + * @param {number[]} [attrs.logErrors=[429]] An array of http error codes + * that will be logged (to sentry, if configured). * @param {object} [attrs.parserOptions={}] Options to pass to fast-xml-parser. See * [documentation](https://github.com/NaturalIntelligence/fast-xml-parser#xml-to-json) * @returns {object} Parsed response - * @see https://github.com/request/request#requestoptions-callback + * @see https://github.com/sindresorhus/got/blob/main/documentation/2-options.md * @see https://github.com/NaturalIntelligence/fast-xml-parser#xml-to-json */ async _requestXml({ schema, url, options = {}, - errorMessages = {}, + httpErrors = {}, + systemErrors = {}, + logErrors = [429], parserOptions = {}, }) { const logTrace = (...args) => trace.logTrace('fetch', ...args) const mergedOptions = { - ...{ headers: { Accept: 'application/xml, text/xml' } }, + ...{ headers: this.constructor.headers }, ...options, } const { buffer } = await this._request({ url, options: mergedOptions, - errorMessages, + httpErrors, + systemErrors, + logErrors, }) - const validateResult = fastXmlParser.validate(buffer) + const validateResult = XMLValidator.validate(buffer) if (validateResult !== true) { throw new InvalidResponse({ prettyMessage: 'unparseable xml response', underlyingError: validateResult.err, }) } - const xml = fastXmlParser.parse(buffer, parserOptions) + const parser = new XMLParser(parserOptions) + const xml = parser.parse(buffer) logTrace(emojic.dart, 'Response XML (before validation)', xml, { deep: true, }) diff --git a/core/base-service/base-xml.spec.js b/core/base-service/base-xml.spec.js index 8db8dfc6f7a10..cf22f02deac44 100644 --- a/core/base-service/base-xml.spec.js +++ b/core/base-service/base-xml.spec.js @@ -22,31 +22,31 @@ class DummyXmlService extends BaseXmlService { describe('BaseXmlService', function () { describe('Making requests', function () { - let sendAndCacheRequest + let requestFetcher beforeEach(function () { - sendAndCacheRequest = sinon.stub().returns( + requestFetcher = sinon.stub().returns( Promise.resolve({ buffer: 'some-string', res: { statusCode: 200 }, - }) + }), ) }) - it('invokes _sendAndCacheRequest', async function () { + it('invokes _requestFetcher', async function () { await DummyXmlService.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } + { requestFetcher }, + { handleInternalErrors: false }, ) - expect(sendAndCacheRequest).to.have.been.calledOnceWith( + expect(requestFetcher).to.have.been.calledOnceWith( 'http://example.com/foo.xml', { headers: { Accept: 'application/xml, text/xml' }, - } + }, ) }) - it('forwards options to _sendAndCacheRequest', async function () { + it('forwards options to _requestFetcher', async function () { class WithCustomOptions extends BaseXmlService { static route = {} @@ -54,39 +54,39 @@ describe('BaseXmlService', function () { const { requiredString } = await this._requestXml({ schema: dummySchema, url: 'http://example.com/foo.xml', - options: { method: 'POST', qs: { queryParam: 123 } }, + options: { method: 'POST', searchParams: { queryParam: 123 } }, }) return { message: requiredString } } } await WithCustomOptions.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } + { requestFetcher }, + { handleInternalErrors: false }, ) - expect(sendAndCacheRequest).to.have.been.calledOnceWith( + expect(requestFetcher).to.have.been.calledOnceWith( 'http://example.com/foo.xml', { headers: { Accept: 'application/xml, text/xml' }, method: 'POST', - qs: { queryParam: 123 }, - } + searchParams: { queryParam: 123 }, + }, ) }) }) describe('Making badges', function () { it('handles valid xml responses', async function () { - const sendAndCacheRequest = async () => ({ + const requestFetcher = async () => ({ buffer: 'some-string', res: { statusCode: 200 }, }) expect( await DummyXmlService.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } - ) + { requestFetcher }, + { handleInternalErrors: false }, + ), ).to.deep.equal({ message: 'some-string', }) @@ -104,31 +104,31 @@ describe('BaseXmlService', function () { return { message: requiredString } } } - const sendAndCacheRequest = async () => ({ + const requestFetcher = async () => ({ buffer: 'some-string with trailing whitespace ', res: { statusCode: 200 }, }) expect( await DummyXmlServiceWithParserOption.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } - ) + { requestFetcher }, + { handleInternalErrors: false }, + ), ).to.deep.equal({ message: 'some-string with trailing whitespace ', }) }) it('handles xml responses which do not match the schema', async function () { - const sendAndCacheRequest = async () => ({ + const requestFetcher = async () => ({ buffer: 'some-string', res: { statusCode: 200 }, }) expect( await DummyXmlService.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } - ) + { requestFetcher }, + { handleInternalErrors: false }, + ), ).to.deep.equal({ isError: true, color: 'lightgray', @@ -137,15 +137,15 @@ describe('BaseXmlService', function () { }) it('handles unparseable xml responses', async function () { - const sendAndCacheRequest = async () => ({ + const requestFetcher = async () => ({ buffer: 'not xml', res: { statusCode: 200 }, }) expect( await DummyXmlService.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } - ) + { requestFetcher }, + { handleInternalErrors: false }, + ), ).to.deep.equal({ isError: true, color: 'lightgray', diff --git a/core/base-service/base-yaml.js b/core/base-service/base-yaml.js index 0dd75930cac42..e0d19a42a270b 100644 --- a/core/base-service/base-yaml.js +++ b/core/base-service/base-yaml.js @@ -14,6 +14,11 @@ import trace from './trace.js' * @abstract */ class BaseYamlService extends BaseService { + static headers = { + Accept: + 'text/x-yaml, text/yaml, application/x-yaml, application/yaml, text/plain', + } + /** * Request data from an upstream API serving YAML, * parse it and validate against a schema @@ -21,37 +26,44 @@ class BaseYamlService extends BaseService { * @param {object} attrs Refer to individual attrs * @param {Joi} attrs.schema Joi schema to validate the response against * @param {string} attrs.url URL to request - * @param {object} [attrs.options={}] Options to pass to request. See - * [documentation](https://github.com/request/request#requestoptions-callback) - * @param {object} [attrs.errorMessages={}] Key-value map of status codes + * @param {object} [attrs.options={}] Options to pass to got. See + * [documentation](https://github.com/sindresorhus/got/blob/main/documentation/2-options.md) + * @param {object} [attrs.httpErrors={}] Key-value map of status codes * and custom error messages e.g: `{ 404: 'package not found' }`. * This can be used to extend or override the * [default](https://github.com/badges/shields/blob/master/core/base-service/check-error-response.js#L5) + * @param {object} [attrs.systemErrors={}] Key-value map of got network exception codes + * and an object of params to pass when we construct an Inaccessible exception object + * e.g: `{ ECONNRESET: { prettyMessage: 'connection reset' } }`. + * See {@link https://github.com/sindresorhus/got/blob/main/documentation/7-retry.md#errorcodes got error codes} + * for allowed keys + * and {@link module:core/base-service/errors~RuntimeErrorProps} for allowed values + * @param {number[]} [attrs.logErrors=[429]] An array of http error codes + * that will be logged (to sentry, if configured). * @param {object} [attrs.encoding='utf8'] Character encoding * @returns {object} Parsed response - * @see https://github.com/request/request#requestoptions-callback + * @see https://github.com/sindresorhus/got/blob/main/documentation/2-options.md */ async _requestYaml({ schema, url, options = {}, - errorMessages = {}, + httpErrors = {}, + systemErrors = {}, + logErrors = [429], encoding = 'utf8', }) { const logTrace = (...args) => trace.logTrace('fetch', ...args) const mergedOptions = { - ...{ - headers: { - Accept: - 'text/x-yaml, text/yaml, application/x-yaml, application/yaml, text/plain', - }, - }, + ...{ headers: this.constructor.headers }, ...options, } const { buffer } = await this._request({ url, options: mergedOptions, - errorMessages, + httpErrors, + systemErrors, + logErrors, }) let parsed try { diff --git a/core/base-service/base-yaml.spec.js b/core/base-service/base-yaml.spec.js index 50b42990c1701..1f8ebef65f380 100644 --- a/core/base-service/base-yaml.spec.js +++ b/core/base-service/base-yaml.spec.js @@ -38,51 +38,51 @@ foo: baz describe('BaseYamlService', function () { describe('Making requests', function () { - let sendAndCacheRequest + let requestFetcher beforeEach(function () { - sendAndCacheRequest = sinon.stub().returns( + requestFetcher = sinon.stub().returns( Promise.resolve({ buffer: expectedYaml, res: { statusCode: 200 }, - }) + }), ) }) - it('invokes _sendAndCacheRequest', async function () { + it('invokes _requestFetcher', async function () { await DummyYamlService.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } + { requestFetcher }, + { handleInternalErrors: false }, ) - expect(sendAndCacheRequest).to.have.been.calledOnceWith( + expect(requestFetcher).to.have.been.calledOnceWith( 'http://example.com/foo.yaml', { headers: { Accept: 'text/x-yaml, text/yaml, application/x-yaml, application/yaml, text/plain', }, - } + }, ) }) - it('forwards options to _sendAndCacheRequest', async function () { + it('forwards options to _requestFetcher', async function () { class WithOptions extends DummyYamlService { async handle() { const { requiredString } = await this._requestYaml({ schema: dummySchema, url: 'http://example.com/foo.yaml', - options: { method: 'POST', qs: { queryParam: 123 } }, + options: { method: 'POST', searchParams: { queryParam: 123 } }, }) return { message: requiredString } } } await WithOptions.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } + { requestFetcher }, + { handleInternalErrors: false }, ) - expect(sendAndCacheRequest).to.have.been.calledOnceWith( + expect(requestFetcher).to.have.been.calledOnceWith( 'http://example.com/foo.yaml', { headers: { @@ -90,38 +90,38 @@ describe('BaseYamlService', function () { 'text/x-yaml, text/yaml, application/x-yaml, application/yaml, text/plain', }, method: 'POST', - qs: { queryParam: 123 }, - } + searchParams: { queryParam: 123 }, + }, ) }) }) describe('Making badges', function () { it('handles valid yaml responses', async function () { - const sendAndCacheRequest = async () => ({ + const requestFetcher = async () => ({ buffer: expectedYaml, res: { statusCode: 200 }, }) expect( await DummyYamlService.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } - ) + { requestFetcher }, + { handleInternalErrors: false }, + ), ).to.deep.equal({ message: 'some-string', }) }) it('handles yaml responses which do not match the schema', async function () { - const sendAndCacheRequest = async () => ({ + const requestFetcher = async () => ({ buffer: unexpectedYaml, res: { statusCode: 200 }, }) expect( await DummyYamlService.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } - ) + { requestFetcher }, + { handleInternalErrors: false }, + ), ).to.deep.equal({ isError: true, color: 'lightgray', @@ -130,15 +130,15 @@ describe('BaseYamlService', function () { }) it('handles unparseable yaml responses', async function () { - const sendAndCacheRequest = async () => ({ + const requestFetcher = async () => ({ buffer: invalidYaml, res: { statusCode: 200 }, }) expect( await DummyYamlService.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } - ) + { requestFetcher }, + { handleInternalErrors: false }, + ), ).to.deep.equal({ isError: true, color: 'lightgray', diff --git a/core/base-service/base.js b/core/base-service/base.js index a547814ea4166..adeddee886f17 100644 --- a/core/base-service/base.js +++ b/core/base-service/base.js @@ -17,10 +17,9 @@ import { Inaccessible, ImproperlyConfigured, InvalidParameter, - Deprecated, } from './errors.js' -import { validateExample, transformExample } from './examples.js' -import { fetchFactory } from './got.js' +import { fetch } from './got.js' +import { getEnum } from './openapi.js' import { makeFullUrl, assertValidRoute, @@ -32,6 +31,8 @@ import { assertValidServiceDefinition } from './service-definitions.js' import trace from './trace.js' import validate from './validate.js' +/** @import { openApiSchema } from './service-definitions.js' */ + const defaultBadgeDataSchema = Joi.object({ label: Joi.string(), color: Joi.string(), @@ -44,13 +45,9 @@ const optionalStringWhenNamedLogoPresent = Joi.alternatives().conditional( { is: Joi.string().required(), then: Joi.string(), - } + }, ) -const optionalNumberWhenAnyLogoPresent = Joi.alternatives() - .conditional('namedLogo', { is: Joi.string().required(), then: Joi.number() }) - .conditional('logoSvg', { is: Joi.string().required(), then: Joi.number() }) - const serviceDataSchema = Joi.object({ isError: Joi.boolean(), label: Joi.string().allow(''), @@ -65,8 +62,7 @@ const serviceDataSchema = Joi.object({ namedLogo: Joi.string(), logoSvg: Joi.string(), logoColor: optionalStringWhenNamedLogoPresent, - logoWidth: optionalNumberWhenAnyLogoPresent, - logoPosition: optionalNumberWhenAnyLogoPresent, + logoSize: optionalStringWhenNamedLogoPresent, cacheSeconds: Joi.number().integer().min(0), style: Joi.string(), }) @@ -90,61 +86,84 @@ class BaseService { throw new Error(`Category not set for ${this.name}`) } - static isDeprecated = false + static isRetired = false /** * Route to mount this service on * * @abstract - * @type {module:core/base-service/base~Route} + * @type {Route} */ static get route() { throw new Error(`Route not defined for ${this.name}`) } + /** + * Extract an array of allowed values from this service's route pattern + * for a given route parameter + * + * @param {string} param The name of a param in this service's route pattern + * @returns {string[]} Array of allowed values for this param + */ + static getEnum(param) { + if (!('pattern' in this.route)) { + throw new Error('getEnum() requires route to have a .pattern property') + } + const enumeration = getEnum(this.route.pattern, param) + if (!Array.isArray(enumeration)) { + throw new Error( + `Could not extract enum for param ${param} from pattern ${this.route.pattern}`, + ) + } + return enumeration + } + /** * Configuration for the authentication helper that prepares credentials * for upstream requests. * * See also the config schema in `./server.js` and `doc/server-secrets.md`. * - * To use the configured auth in the handler or fetch method, pass the - * credentials to the request. For example: - * - `{ options: { auth: this.authHelper.basicAuth } }` - * - `{ options: { headers: this.authHelper.bearerAuthHeader } }` - * - `{ options: { qs: { token: this.authHelper._pass } } }` + * To use the configured auth in the handler or fetch method, wrap the + * _request() input params in a call to one of: + * - this.authHelper.withBasicAuth() + * - this.authHelper.withBearerAuthHeader() + * - this.authHelper.withQueryStringAuth() + * + * For example: + * this._request(this.authHelper.withBasicAuth({ url, schema, options })) * * @abstract - * @type {module:core/base-service/base~Auth} + * @type {Auth} */ static auth = undefined /** - * Array of Example objects describing example URLs for this service. - * These should use the format specified in `route`, - * and can be used to demonstrate how to use badges for this service. - * - * The preferred way to specify an example is with `namedParams` which are - * substituted into the service's compiled route pattern. The rendered badge - * is specified with `staticPreview`. + * An OpenAPI Paths Object describing this service's + * route or routes in OpenAPI format. * - * For services which use a route `format`, the `pattern` can be specified as - * part of the example. - * - * @see {@link module:core/base-service/base~Example} * @abstract - * @type {module:core/base-service/base~Example[]} + * @see https://swagger.io/specification/#paths-object + * @see {@link module:core/base-service/service-definitions~openApiSchema} + * @type {openApiSchema} */ - static examples = [] + static openApi = {} static get _cacheLength() { const cacheLengths = { build: 30, - license: 3600, - version: 300, debug: 60, - downloads: 900, - social: 900, + + 'platform-support': 300, + size: 300, + version: 300, + + chat: 1800, + downloads: 1800, + rating: 1800, + social: 1800, + + license: 14400, } return cacheLengths[this.category] } @@ -154,7 +173,7 @@ class BaseService { * These defaults are used if the value is neither included in the service data * from the handler nor overridden by the user via query parameters. * - * @type {module:core/base-service/base~DefaultBadgeData} + * @type {DefaultBadgeData} */ static defaultBadgeData = {} @@ -170,23 +189,29 @@ class BaseService { Joi.assert( this.defaultBadgeData, defaultBadgeDataSchema, - `Default badge data for ${this.name}` + `Default badge data for ${this.name}`, ) - this.examples.forEach((example, index) => - validateExample(example, index, this) - ) + // ensure openApi spec matches route + const preparedRoute = prepareRoute(this.route) + for (const [key, value] of Object.entries(this.openApi)) { + let example = key + for (const param of value.get.parameters) { + example = example.replace(`{${param.name}}`, param.example) + } + if (!example.match(preparedRoute.regex)) { + throw new Error( + `Inconsistent Open Api spec and Route found for service ${this.name}`, + ) + } + } } static getDefinition() { - const { category, name, isDeprecated } = this + const { category, name, isRetired, openApi } = this const { base, format, pattern } = this.route const queryParams = getQueryParamNames(this.route) - const examples = this.examples.map((example, index) => - transformExample(example, index, this) - ) - let route if (pattern) { route = { pattern: makeFullUrl(base, pattern), queryParams } @@ -196,7 +221,7 @@ class BaseService { route = undefined } - const result = { category, name, isDeprecated, route, examples } + const result = { category, name, isRetired, route, openApi } assertValidServiceDefinition(result, `getDefinition() for ${this.name}`) @@ -204,33 +229,49 @@ class BaseService { } constructor( - { sendAndCacheRequest, authHelper, metricHelper }, - { handleInternalErrors } + { requestFetcher, authHelper, metricHelper }, + { handleInternalErrors }, ) { - this._requestFetcher = sendAndCacheRequest + this._requestFetcher = requestFetcher this.authHelper = authHelper this._handleInternalErrors = handleInternalErrors this._metricHelper = metricHelper } - async _request({ url, options = {}, errorMessages = {} }) { + async _request({ + url, + options = {}, + httpErrors = {}, + systemErrors = {}, + logErrors = [429], + }) { 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) + if ('searchParams' in options && options.searchParams != null) { + const params = new URLSearchParams( + Object.fromEntries( + Object.entries(options.searchParams).filter( + ([k, v]) => v !== undefined, + ), + ), + ) logUrl = `${url}?${params.toString()}` - delete logOptions.qs + delete logOptions.searchParams } logTrace( emojic.bowAndArrow, 'Request', - `${logUrl}\n${JSON.stringify(logOptions, null, 2)}` + `${logUrl}\n${JSON.stringify(logOptions, null, 2)}`, + ) + const { res, buffer } = await this._requestFetcher( + url, + options, + systemErrors, ) - const { res, buffer } = await this._requestFetcher(url, options) await this._meterResponse(res, buffer) logTrace(emojic.dart, 'Response status code', res.statusCode) - return checkErrorResponse(errorMessages)({ buffer, res }) + return checkErrorResponse(httpErrors, logErrors)({ buffer, res }) } static enabledMetrics = [] @@ -256,7 +297,7 @@ class BaseService { prettyErrorMessage = 'invalid response data', includeKeys = false, allowAndStripUnknownKeys = true, - } = {} + } = {}, ) { return validate( { @@ -268,20 +309,20 @@ class BaseService { allowAndStripUnknownKeys, }, data, - schema + schema, ) } /** * Asynchronous function to handle requests for this service. Take the route * parameters (as defined in the `route` property), perform a request using - * `this._sendAndCacheRequest`, and return the badge data. + * `this._requestFetcher`, and return the badge data. * * @abstract * @param {object} namedParams Params parsed from route pattern * defined in this.route.pattern or this.route.capture * @param {object} queryParams Params parsed from the query string - * @returns {module:core/base-service/base~Badge} + * @returns {Badge} * badge Object validated against serviceDataSchema */ async handle(namedParams, queryParams) { @@ -305,22 +346,25 @@ class BaseService { } else if ( error instanceof ImproperlyConfigured || error instanceof InvalidResponse || - error instanceof Inaccessible || - error instanceof Deprecated + error instanceof Inaccessible ) { trace.logTrace('outbound', emojic.noGoodWoman, 'Handled error', error) - return { + const serviceData = { isError: true, message: error.prettyMessage, color: 'lightgray', } + if (error.cacheSeconds !== undefined) { + serviceData.cacheSeconds = error.cacheSeconds + } + return serviceData } else if (this._handleInternalErrors) { if ( !trace.logTrace( 'unhandledError', emojic.boom, 'Unhandled internal error', - error + error, ) ) { // This is where we end up if an unhandled exception is thrown in @@ -338,7 +382,7 @@ class BaseService { 'unhandledError', emojic.boom, 'Unhandled internal error', - error + error, ) throw error } @@ -348,7 +392,7 @@ class BaseService { context = {}, config = {}, namedParams = {}, - queryParams = {} + queryParams = {}, ) { trace.logTrace('inbound', emojic.womanCook, 'Service class', this.name) trace.logTrace('inbound', emojic.ticket, 'Named params', namedParams) @@ -382,13 +426,13 @@ class BaseService { traceSuccessMessage: 'Query params after validation', }, queryParams, - queryParamSchema + queryParamSchema, ) trace.logTrace( 'inbound', emojic.crayon, 'Query params after validation', - queryParams + queryParams, ) } catch (error) { serviceError = error @@ -402,7 +446,7 @@ class BaseService { try { serviceData = await serviceInstance.handle( namedParams, - transformedQueryParams + transformedQueryParams, ) serviceInstance._validateServiceData(serviceData) } catch (error) { @@ -420,10 +464,16 @@ class BaseService { } static register( - { camp, handleRequest, githubApiProvider, metricInstance }, - serviceConfig + { + camp, + handleRequest, + githubApiProvider, + librariesIoApiProvider, + metricInstance, + }, + serviceConfig, ) { - const { cacheHeaders: cacheHeaderConfig, fetchLimitBytes } = serviceConfig + const { cacheHeaders: cacheHeaderConfig } = serviceConfig const { regex, captureNames } = prepareRoute(this.route) const queryParams = getQueryParamNames(this.route) @@ -432,33 +482,31 @@ class BaseService { ServiceClass: this, }) - const fetcher = fetchFactory(fetchLimitBytes) - camp.route( regex, handleRequest(cacheHeaderConfig, { queryParams, - handler: async (queryParams, match, sendBadge, request) => { + handler: async (queryParams, match, sendBadge) => { const metricHandle = metricHelper.startRequest() const namedParams = namedParamsForMatch(captureNames, match, this) const serviceData = await this.invoke( { - sendAndCacheRequest: fetcher, - sendAndCacheRequestWithCallbacks: request, + requestFetcher: fetch, githubApiProvider, + librariesIoApiProvider, metricHelper, }, serviceConfig, namedParams, - queryParams + queryParams, ) const badgeData = coalesceBadge( queryParams, serviceData, this.defaultBadgeData, - this + this, ) // The final capture group is the extension. const format = (match.slice(-1)[0] || '.svg').replace(/^\./, '') @@ -467,8 +515,7 @@ class BaseService { metricHandle.noteResponseSent() }, cacheLength: this._cacheLength, - fetchLimitBytes, - }) + }), ) } } @@ -524,9 +571,11 @@ class BaseService { * receives numeric can use `Joi.string()`. A boolean * parameter should use `Joi.equal('')` and will receive an * empty string on e.g. `?compact_message` and undefined - * when the parameter is absent. (Note that in, - * `examples.queryParams` boolean query params should be given - * `null` values.) + * when the parameter is absent. In the OpenApi definitions, + * this type of param should be documented as + * queryParam({ + * name: 'compact_message', schema: { type: 'boolean' }, example: null + * }) */ /** @@ -541,30 +590,4 @@ class BaseService { * configured credentials are present. */ -/** - * @typedef {object} Example - * @property {string} title - * Descriptive text that will be shown next to the badge. The default - * is to use the service class name, which probably is not what you want. - * @property {object} namedParams - * An object containing the values of named parameters to - * substitute into the compiled route pattern. - * @property {object} queryParams - * An object containing query parameters to include in the - * example URLs. For alphanumeric query parameters, specify a string value. - * For boolean query parameters, specify `null`. - * @property {string} pattern - * The route pattern to compile. Defaults to `this.route.pattern`. - * @property {object} staticPreview - * A rendered badge of the sort returned by `handle()` or - * `render()`: an object containing `message` and optional `label` and - * `color`. This is usually generated by invoking `this.render()` with some - * explicit props. - * @property {string[]} keywords - * Additional keywords, other than words in the title. This helps - * users locate relevant badges. - * @property {string} documentation - * An HTML string that is included in the badge popup. - */ - export default BaseService diff --git a/core/base-service/base.spec.js b/core/base-service/base.spec.js index ac58c86598203..4593d7528e1fd 100644 --- a/core/base-service/base.spec.js +++ b/core/base-service/base.spec.js @@ -1,22 +1,21 @@ import Joi from 'joi' -import chai from 'chai' +import { expect, use } from 'chai' import sinon from 'sinon' import prometheus from 'prom-client' import chaiAsPromised from 'chai-as-promised' import PrometheusMetrics from '../server/prometheus-metrics.js' +import { pathParam, queryParam } from './openapi.js' import trace from './trace.js' import { NotFound, Inaccessible, InvalidResponse, InvalidParameter, - Deprecated, } from './errors.js' import BaseService from './base.js' import { MetricHelper, MetricNames } from './metric-helper.js' import '../register-chai-plugins.spec.js' -const { expect } = chai -chai.use(chaiAsPromised) +use(chaiAsPromised) const queryParamSchema = Joi.object({ queryParamA: Joi.string(), @@ -31,14 +30,17 @@ class DummyService extends BaseService { static category = 'other' static route = { base: 'foo', pattern: ':namedParamA', queryParamSchema } - static examples = [ - { - pattern: ':world', - namedParams: { world: 'World' }, - staticPreview: this.render({ namedParamA: 'foo', queryParamA: 'bar' }), - keywords: ['hello'], + static openApi = { + '/foo/{namedParamA}': { + get: { + summary: 'Dummy Service', + parameters: [ + pathParam({ name: 'namedParamA', example: 'foo' }), + queryParam({ name: 'queryParamA', example: 'bar' }), + ], + }, }, - ] + } static defaultBadgeData = { label: 'cat', namedLogo: 'appveyor' } @@ -72,8 +74,8 @@ describe('BaseService', function () { {}, defaultConfig, { namedParamA: 'bar.bar.bar' }, - { queryParamA: '!' } - ) + { queryParamA: '!' }, + ), ).to.deep.equal({ message: 'Hello namedParamA: bar.bar.bar with queryParamA: !', }) @@ -85,8 +87,8 @@ describe('BaseService', function () { {}, defaultConfig, { namedParamA: 'bar.bar.bar' }, - { queryParamA: ['foo', 'bar'] } - ) + { queryParamA: ['foo', 'bar'] }, + ), ).to.deep.equal({ color: 'red', isError: true, @@ -97,13 +99,13 @@ describe('BaseService', function () { describe('Required overrides', function () { it('Should throw if render() is not overridden', function () { expect(() => BaseService.render()).to.throw( - /^render\(\) function not implemented for BaseService$/ + /^render\(\) function not implemented for BaseService$/, ) }) it('Should throw if route is not overridden', function () { return expect(BaseService.invoke({}, {}, {})).to.be.rejectedWith( - /^Route not defined for BaseService$/ + /^Route not defined for BaseService$/, ) }) @@ -112,52 +114,48 @@ describe('BaseService', function () { } it('Should throw if handle() is not overridden', function () { return expect(WithRoute.invoke({}, {}, {})).to.be.rejectedWith( - /^Handler not implemented for WithRoute$/ + /^Handler not implemented for WithRoute$/, ) }) it('Should throw if category is not overridden', function () { expect(() => BaseService.category).to.throw( - /^Category not set for BaseService$/ + /^Category not set for BaseService$/, ) }) }) describe('Logging', function () { - let sandbox beforeEach(function () { - sandbox = sinon.createSandbox() + sinon.stub(trace, 'logTrace') }) afterEach(function () { - sandbox.restore() - }) - beforeEach(function () { - sandbox.stub(trace, 'logTrace') + sinon.restore() }) it('Invokes the logger as expected', async function () { await DummyService.invoke( {}, defaultConfig, { namedParamA: 'bar.bar.bar' }, - { queryParamA: '!' } + { queryParamA: '!' }, ) expect(trace.logTrace).to.be.calledWithMatch( 'inbound', sinon.match.string, 'Service class', - 'DummyService' + 'DummyService', ) expect(trace.logTrace).to.be.calledWith( 'inbound', sinon.match.string, 'Named params', - { namedParamA: 'bar.bar.bar' } + { namedParamA: 'bar.bar.bar' }, ) expect(trace.logTrace).to.be.calledWith( 'inbound', sinon.match.string, 'Query params after validation', - { queryParamA: '!' } + { queryParamA: '!' }, ) }) }) @@ -175,7 +173,7 @@ describe('BaseService', function () { const serviceData = await LinkService.invoke( {}, { handleInternalErrors: false }, - { namedParamA: 'bar.bar.bar' } + { namedParamA: 'bar.bar.bar' }, ) expect(serviceData).to.deep.equal({ @@ -198,7 +196,7 @@ describe('BaseService', function () { await ThrowingService.invoke( {}, { handleInternalErrors: false }, - { namedParamA: 'bar.bar.bar' } + { namedParamA: 'bar.bar.bar' }, ) expect.fail('Expected to throw') } catch (e) { @@ -216,7 +214,7 @@ describe('BaseService', function () { await ThrowingService.invoke( {}, { handleInternalErrors: false }, - { namedParamA: 'bar.bar.bar' } + { namedParamA: 'bar.bar.bar' }, ) expect.fail('Expected to throw') } catch (e) { @@ -237,8 +235,8 @@ describe('BaseService', function () { await ThrowingService.invoke( {}, { handleInternalErrors: true }, - { namedParamA: 'bar.bar.bar' } - ) + { namedParamA: 'bar.bar.bar' }, + ), ).to.deep.equal({ isError: true, color: 'lightgray', @@ -255,7 +253,7 @@ describe('BaseService', function () { } } expect( - await ThrowingService.invoke({}, {}, { namedParamA: 'bar.bar.bar' }) + await ThrowingService.invoke({}, {}, { namedParamA: 'bar.bar.bar' }), ).to.deep.equal({ isError: true, color: 'red', @@ -270,7 +268,7 @@ describe('BaseService', function () { } } expect( - await ThrowingService.invoke({}, {}, { namedParamA: 'bar.bar.bar' }) + await ThrowingService.invoke({}, {}, { namedParamA: 'bar.bar.bar' }), ).to.deep.equal({ isError: true, color: 'lightgray', @@ -285,7 +283,7 @@ describe('BaseService', function () { } } expect( - await ThrowingService.invoke({}, {}, { namedParamA: 'bar.bar.bar' }) + await ThrowingService.invoke({}, {}, { namedParamA: 'bar.bar.bar' }), ).to.deep.equal({ isError: true, color: 'lightgray', @@ -293,21 +291,6 @@ describe('BaseService', function () { }) }) - it('handles Deprecated', async function () { - class ThrowingService extends DummyService { - async handle() { - throw new Deprecated() - } - } - expect( - await ThrowingService.invoke({}, {}, { namedParamA: 'bar.bar.bar' }) - ).to.deep.equal({ - isError: true, - color: 'lightgray', - message: 'no longer available', - }) - }) - it('handles InvalidParameter errors', async function () { class ThrowingService extends DummyService { async handle() { @@ -315,7 +298,7 @@ describe('BaseService', function () { } } expect( - await ThrowingService.invoke({}, {}, { namedParamA: 'bar.bar.bar' }) + await ThrowingService.invoke({}, {}, { namedParamA: 'bar.bar.bar' }), ).to.deep.equal({ isError: true, color: 'red', @@ -326,7 +309,6 @@ 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)$/ @@ -340,7 +322,7 @@ describe('BaseService', function () { mockHandleRequest = sinon.spy() DummyService.register( { camp: mockCamp, handleRequest: mockHandleRequest }, - defaultConfig + defaultConfig, ) }) @@ -377,7 +359,7 @@ describe('BaseService', function () { namedLogo: undefined, logo: undefined, logoWidth: undefined, - logoPosition: undefined, + logoSize: undefined, links: [], labelColor: undefined, cacheLengthSeconds: undefined, @@ -387,24 +369,24 @@ describe('BaseService', function () { describe('getDefinition', function () { it('returns the expected result', function () { - const { category, name, isDeprecated, route, examples } = + const { category, name, isRetired, route, openApi } = DummyService.getDefinition() expect({ category, name, - isDeprecated, + isRetired, route, }).to.deep.equal({ category: 'other', name: 'DummyService', - isDeprecated: false, + isRetired: false, route: { pattern: '/foo/:namedParamA', queryParams: ['queryParamA', 'legacyQueryParamA'], }, }) // The in-depth tests for examples reside in examples.spec.js - expect(examples).to.have.lengthOf(1) + expect(Object.keys(openApi)).to.have.lengthOf(1) }) }) @@ -417,8 +399,8 @@ describe('BaseService', function () { expect(() => DummyService._validate( { requiredString: ['this', "shouldn't", 'work'] }, - dummySchema - ) + dummySchema, + ), ) .to.throw() .instanceof(InvalidResponse) @@ -426,53 +408,56 @@ describe('BaseService', function () { }) describe('request', function () { - let sandbox beforeEach(function () { - sandbox = sinon.createSandbox() + sinon.stub(trace, 'logTrace') }) afterEach(function () { - sandbox.restore() - }) - beforeEach(function () { - sandbox.stub(trace, 'logTrace') + sinon.restore() }) it('logs appropriate information', async function () { - const sendAndCacheRequest = async () => ({ + const requestFetcher = async () => ({ buffer: '', res: { statusCode: 200 }, }) const serviceInstance = new DummyService( - { sendAndCacheRequest }, - defaultConfig + { requestFetcher }, + defaultConfig, ) const url = 'some-url' - const options = { headers: { Cookie: 'some-cookie' } } + const options = { + headers: { Cookie: 'some-cookie' }, + searchParams: { param1: 'foobar', param2: undefined }, + } await serviceInstance._request({ url, options }) expect(trace.logTrace).to.be.calledWithMatch( 'fetch', sinon.match.string, 'Request', - `${url}\n${JSON.stringify(options, null, 2)}` + `${url}?param1=foobar\n${JSON.stringify( + { headers: options.headers }, + null, + 2, + )}`, ) expect(trace.logTrace).to.be.calledWithMatch( 'fetch', sinon.match.string, 'Response status code', - 200 + 200, ) }) it('handles errors', async function () { - const sendAndCacheRequest = async () => ({ + const requestFetcher = async () => ({ buffer: '', res: { statusCode: 404 }, }) const serviceInstance = new DummyService( - { sendAndCacheRequest }, - defaultConfig + { requestFetcher }, + defaultConfig, ) try { @@ -498,24 +483,24 @@ describe('BaseService', function () { metricInstance: new PrometheusMetrics({ register }), ServiceClass: DummyServiceWithServiceResponseSizeMetricEnabled, }) - const sendAndCacheRequest = async () => ({ + const requestFetcher = async () => ({ buffer: 'x'.repeat(65536 + 1), res: { statusCode: 200 }, }) const serviceInstance = new DummyServiceWithServiceResponseSizeMetricEnabled( - { sendAndCacheRequest, metricHelper }, - defaultConfig + { requestFetcher, metricHelper }, + defaultConfig, ) await serviceInstance._request({ url }) expect(await 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' + 'service_response_bytes_bucket{le="65536",category="other",family="undefined",service="dummy_service_with_service_response_size_metric_enabled"} 0\n', ) .and.to.contain( - 'service_response_bytes_bucket{le="131072",category="other",family="undefined",service="dummy_service_with_service_response_size_metric_enabled"} 1\n' + 'service_response_bytes_bucket{le="131072",category="other",family="undefined",service="dummy_service_with_service_response_size_metric_enabled"} 1\n', ) }) @@ -524,22 +509,23 @@ describe('BaseService', function () { metricInstance: new PrometheusMetrics({ register }), ServiceClass: DummyService, }) - const sendAndCacheRequest = async () => ({ + const requestFetcher = async () => ({ buffer: 'x', res: { statusCode: 200 }, }) const serviceInstance = new DummyService( - { sendAndCacheRequest, metricHelper }, - defaultConfig + { requestFetcher, metricHelper }, + defaultConfig, ) await serviceInstance._request({ url }) expect( - await register.getSingleMetricAsString('service_response_bytes') + await register.getSingleMetricAsString('service_response_bytes'), ).to.not.contain('service_response_bytes_bucket') }) }) + describe('auth', function () { class AuthService extends DummyService { static auth = { @@ -566,8 +552,8 @@ describe('BaseService', function () { }, private: { myci_pass: 'abc123' }, }, - { namedParamA: 'bar.bar.bar' } - ) + { namedParamA: 'bar.bar.bar' }, + ), ).to.deep.equal({ message: 'The CI password is abc123' }) }) @@ -584,8 +570,8 @@ describe('BaseService', function () { }, { namedParamA: 'bar.bar.bar', - } - ) + }, + ), ).to.deep.equal({ color: 'lightgray', isError: true, @@ -593,4 +579,44 @@ describe('BaseService', function () { }) }) }) + + describe('getEnum', function () { + class EnumService extends DummyService { + static route = { + base: 'foo', + pattern: ':namedParamA/:namedParamB(this|that)', + queryParamSchema, + } + } + + it('returns an array of allowed values', async function () { + expect(EnumService.getEnum('namedParamB')).to.deep.equal(['this', 'that']) + }) + + it('throws if param name is invalid', async function () { + expect(() => EnumService.getEnum('notAValidParam')).to.throw( + 'Could not extract enum for param notAValidParam from pattern :namedParamA/:namedParamB(this|that)', + ) + }) + + it('throws if param name is not an enum', async function () { + expect(() => EnumService.getEnum('namedParamA')).to.throw( + 'Could not extract enum for param namedParamA from pattern :namedParamA/:namedParamB(this|that)', + ) + }) + + it('throws if route does not have a pattern', async function () { + class FormatService extends DummyService { + static route = { + base: 'foo', + format: '([^/]+?)', + queryParamSchema, + } + } + + expect(() => FormatService.getEnum('notAValidParam')).to.throw( + 'getEnum() requires route to have a .pattern property', + ) + }) + }) }) diff --git a/core/base-service/cache-headers.js b/core/base-service/cache-headers.js index 14a707efae7e3..bbecf6a53742e 100644 --- a/core/base-service/cache-headers.js +++ b/core/base-service/cache-headers.js @@ -39,14 +39,14 @@ function coalesceCacheLength({ assert(defaultCacheLengthSeconds !== undefined) const cacheLength = coalesce( + serviceOverrideCacheLengthSeconds, serviceDefaultCacheLengthSeconds, - defaultCacheLengthSeconds + defaultCacheLengthSeconds, ) // Overrides can apply _more_ caching, but not less. Query param overriding // can request more overriding than service override, but not less. const candidateOverrides = [ - serviceOverrideCacheLengthSeconds, overrideCacheLengthFromQueryParams(queryParams), ].filter(x => x !== undefined) @@ -90,8 +90,11 @@ function setCacheHeaders({ setHeadersForCacheLength(res, cacheLengthSeconds) } -const staticCacheControlHeader = `max-age=${24 * 3600}, s-maxage=${24 * 3600}` // 1 day. -function setCacheHeadersForStaticResource(res) { +function setCacheHeadersForStaticResource( + res, + maxAge = 24 * 3600, // 1 day +) { + const staticCacheControlHeader = `max-age=${maxAge}, s-maxage=${maxAge}` res.setHeader('Cache-Control', staticCacheControlHeader) res.setHeader('Last-Modified', serverStartTimeGMTString) } diff --git a/core/base-service/cache-headers.spec.js b/core/base-service/cache-headers.spec.js index 9c705116a2fe7..1c22e5e1c0d34 100644 --- a/core/base-service/cache-headers.spec.js +++ b/core/base-service/cache-headers.spec.js @@ -1,5 +1,5 @@ import { test, given } from 'sazerac' -import chai, { expect } from 'chai' +import { expect, use } from 'chai' import sinon from 'sinon' import httpMocks from 'node-mocks-http' import chaiDatetime from 'chai-datetime' @@ -10,7 +10,7 @@ import { setCacheHeadersForStaticResource, serverHasBeenUpSinceResourceCached, } from './cache-headers.js' -chai.use(chaiDatetime) +use(chaiDatetime) describe('Cache header functions', function () { let res @@ -74,12 +74,12 @@ describe('Cache header functions', function () { serviceDefaultCacheLengthSeconds: 900, serviceOverrideCacheLengthSeconds: 400, queryParams: {}, - }).expect(900) + }).expect(400) given({ cacheHeaderConfig, serviceOverrideCacheLengthSeconds: 400, queryParams: {}, - }).expect(777) + }).expect(400) given({ cacheHeaderConfig, serviceOverrideCacheLengthSeconds: 900, @@ -99,14 +99,11 @@ describe('Cache header functions', function () { }) describe('setHeadersForCacheLength', function () { - let sandbox beforeEach(function () { - sandbox = sinon.createSandbox() - sandbox.useFakeTimers() + sinon.useFakeTimers() }) afterEach(function () { - sandbox.restore() - sandbox = undefined + sinon.restore() }) it('should set the correct Date header', function () { @@ -128,7 +125,7 @@ describe('Cache header functions', function () { it('should set the expected Cache-Control header', function () { expect(res._headers['cache-control']).to.equal( - 'no-cache, no-store, must-revalidate' + 'no-cache, no-store, must-revalidate', ) }) @@ -144,7 +141,7 @@ describe('Cache header functions', function () { it('should set the expected Cache-Control header', function () { expect(res._headers['cache-control']).to.equal( - 'max-age=123, s-maxage=123' + 'max-age=123, s-maxage=123', ) }) @@ -159,7 +156,7 @@ describe('Cache header functions', function () { it('sets the expected fields', function () { const expectedFields = ['date', 'cache-control', 'expires'] expectedFields.forEach(field => - expect(res._headers[field]).to.equal(undefined) + expect(res._headers[field]).to.equal(undefined), ) setCacheHeaders({ @@ -172,7 +169,7 @@ describe('Cache header functions', function () { expectedFields.forEach(field => expect(res._headers[field]) .to.be.a('string') - .and.have.lengthOf.at.least(1) + .and.have.lengthOf.at.least(1), ) }) }) @@ -184,7 +181,7 @@ describe('Cache header functions', function () { it('should set the expected Cache-Control header', function () { expect(res._headers['cache-control']).to.equal( - `max-age=${24 * 3600}, s-maxage=${24 * 3600}` + `max-age=${24 * 3600}, s-maxage=${24 * 3600}`, ) }) @@ -193,7 +190,7 @@ describe('Cache header functions', function () { expect(new Date(lastModified)).to.be.withinTime( // Within the last 60 seconds. new Date(Date.now() - 60 * 1000), - new Date() + new Date(), ) }) }) @@ -224,7 +221,7 @@ describe('Cache header functions', function () { }) expect(serverHasBeenUpSinceResourceCached(req)).to.equal(false) }) - } + }, ) context( 'when the If-Modified-Since header is after the process started', @@ -236,7 +233,7 @@ describe('Cache header functions', function () { }) expect(serverHasBeenUpSinceResourceCached(req)).to.equal(true) }) - } + }, ) }) }) diff --git a/core/base-service/check-error-response.js b/core/base-service/check-error-response.js index ea21344f3639f..beceeecc7bfd8 100644 --- a/core/base-service/check-error-response.js +++ b/core/base-service/check-error-response.js @@ -1,22 +1,26 @@ +import log from '../server/log.js' import { NotFound, InvalidResponse, Inaccessible } from './errors.js' const defaultErrorMessages = { 404: 'not found', + 429: 'rate limited by upstream service', } -export default function checkErrorResponse(errorMessages = {}) { +const headersToInclude = ['cf-ray'] + +export default function checkErrorResponse(httpErrors = {}, logErrors = [429]) { return async function ({ buffer, res }) { let error - errorMessages = { ...defaultErrorMessages, ...errorMessages } + httpErrors = { ...defaultErrorMessages, ...httpErrors } if (res.statusCode === 404) { - error = new NotFound({ prettyMessage: errorMessages[404] }) + error = new NotFound({ prettyMessage: httpErrors[404] }) } else if (res.statusCode !== 200) { const underlying = Error( - `Got status code ${res.statusCode} (expected 200)` + `Got status code ${res.statusCode} (expected 200)`, ) const props = { underlyingError: underlying } - if (errorMessages[res.statusCode] !== undefined) { - props.prettyMessage = errorMessages[res.statusCode] + if (httpErrors[res.statusCode] !== undefined) { + props.prettyMessage = httpErrors[res.statusCode] } if (res.statusCode >= 500) { error = new Inaccessible(props) @@ -24,6 +28,21 @@ export default function checkErrorResponse(errorMessages = {}) { error = new InvalidResponse(props) } } + + if (logErrors.includes(res.statusCode)) { + const tags = {} + for (const headerKey of headersToInclude) { + const headerValue = res.headers[headerKey] + if (headerValue) { + tags[`header-${headerKey}`] = headerValue + } + } + log.error( + new Error(`${res.statusCode} calling ${res.requestUrl.origin}`), + tags, + ) + } + if (error) { error.response = res error.buffer = buffer diff --git a/core/base-service/check-error-response.spec.js b/core/base-service/check-error-response.spec.js index 82d239bec1cec..d5479d8b594ab 100644 --- a/core/base-service/check-error-response.spec.js +++ b/core/base-service/check-error-response.spec.js @@ -45,6 +45,46 @@ describe('async error handler', function () { }) }) + context('when status is 429', function () { + const buffer = Buffer.from('some stuff') + const res = { + statusCode: 429, + headers: { 'some-key': 'some-value' }, + requestUrl: new URL('https://example.com/'), + } + + it('throws InvalidResponse', async function () { + try { + await checkErrorResponse()({ res, buffer }) + expect.fail('Expected to throw') + } catch (e) { + expect(e).to.be.an.instanceof(InvalidResponse) + expect(e.message).to.equal( + 'Invalid Response: Got status code 429 (expected 200)', + ) + expect(e.prettyMessage).to.equal('rate limited by upstream service') + expect(e.response).to.equal(res) + expect(e.buffer).to.equal(buffer) + } + }) + + it('displays the custom too many requests', async function () { + const notFoundMessage = "terribly sorry but that's one too many requests" + try { + await checkErrorResponse({ 429: notFoundMessage })({ res, buffer }) + expect.fail('Expected to throw') + } catch (e) { + expect(e).to.be.an.instanceof(InvalidResponse) + expect(e.message).to.equal( + 'Invalid Response: Got status code 429 (expected 200)', + ) + expect(e.prettyMessage).to.equal( + "terribly sorry but that's one too many requests", + ) + } + }) + }) + context('when status is 4xx', function () { it('throws InvalidResponse', async function () { const res = { statusCode: 499 } @@ -54,7 +94,7 @@ describe('async error handler', function () { } catch (e) { expect(e).to.be.an.instanceof(InvalidResponse) expect(e.message).to.equal( - 'Invalid Response: Got status code 499 (expected 200)' + 'Invalid Response: Got status code 499 (expected 200)', ) expect(e.prettyMessage).to.equal('invalid') expect(e.response).to.equal(res) @@ -70,7 +110,7 @@ describe('async error handler', function () { } catch (e) { expect(e).to.be.an.instanceof(InvalidResponse) expect(e.message).to.equal( - 'Invalid Response: Got status code 403 (expected 200)' + 'Invalid Response: Got status code 403 (expected 200)', ) expect(e.prettyMessage).to.equal('access denied') } @@ -86,7 +126,7 @@ describe('async error handler', function () { } catch (e) { expect(e).to.be.an.instanceof(Inaccessible) expect(e.message).to.equal( - 'Inaccessible: Got status code 503 (expected 200)' + 'Inaccessible: Got status code 503 (expected 200)', ) expect(e.prettyMessage).to.equal('inaccessible') expect(e.response).to.equal(res) @@ -102,7 +142,7 @@ describe('async error handler', function () { } catch (e) { expect(e).to.be.an.instanceof(Inaccessible) expect(e.message).to.equal( - 'Inaccessible: Got status code 500 (expected 200)' + 'Inaccessible: Got status code 500 (expected 200)', ) expect(e.prettyMessage).to.equal('server overloaded') } diff --git a/core/base-service/coalesce-badge.js b/core/base-service/coalesce-badge.js index db171fee347b4..3380b7eb65766 100644 --- a/core/base-service/coalesce-badge.js +++ b/core/base-service/coalesce-badge.js @@ -2,42 +2,43 @@ import { decodeDataUrlFromQueryParam, prepareNamedLogo, } from '../../lib/logos.js' -import { svg2base64 } from '../../lib/svg-helpers.js' +import { svg2base64, getIconSize } from '../../lib/svg-helpers.js' +import { DEFAULT_LOGO_HEIGHT } from '../../badge-maker/lib/constants.js' import coalesce from './coalesce.js' import toArray from './to-array.js' -// Translate modern badge data to the legacy schema understood by the badge -// maker. Allow the user to override the label, color, logo, etc. through the -// query string. Provide support for most badge options via `serviceData` so -// the Endpoint badge can specify logos and colors, though allow that the -// user's logo or color to take precedence. A notable exception is the case of -// errors. When the service specifies that an error has occurred, the user's -// requested color does not override the error color. -// -// Logos are resolved in this manner: -// -// 1. When `?logo=` contains the name of one of the Shields logos, or contains -// base64-encoded SVG, that logo is used. In the case of a named logo, when -// a `&logoColor=` is specified, that color is used. Otherwise the default -// color is used. `logoColor` will not be applied to a custom -// (base64-encoded) logo; if a custom color is desired the logo should be -// recolored prior to making the request. The appearance of the logo can be -// customized using `logoWidth`, and in the case of the popout badge, -// `logoPosition`. When `?logo=` is specified, any logo-related parameters -// specified dynamically by the service, or by default in the service, are -// ignored. -// 2. The second precedence is the dynamic logo returned by a service. This is -// used only by the Endpoint badge. The `logoColor` can be overridden by the -// query string. -// 3. In the case of the `social` style only, the last precedence is the -// service's default logo. The `logoColor` can be overridden by the query -// string. +/** + * Translate modern badge data to the legacy schema understood by the badge + * maker. Allows the user to override label, color, logo, etc. through the + * query string. Provides support for most badge options via `serviceData` so + * the Endpoint badge can specify logos and colors, though the user's logo or + * color takes precedence. A notable exception: when the service specifies an + * error, the user's color override is disregarded. + * + * Logos are resolved in this precedence order: + * + * 1. `?logo=` query param (named simple-icons logo or base64-encoded SVG). + * 2. Dynamic logo returned by the service (Endpoint badge only). + * 3. Service's default logo (social style only). + * + * @param {object} overrides - Query-string override values (style, label, + * logo, color, etc.). + * @param {object} serviceData - Badge data from the service (message, color, + * logo, cache seconds, etc.). + * @param {object} defaultBadgeData - Default badge values (color, label, + * named logo). + * @param {object} [context] - Optional context including category and cache + * length. + * @param {string} [context.category] - Badge category for the default label. + * @param {number} [context._cacheLength] - Default cache duration in seconds. + * @returns {object} Normalized badge data object ready for rendering. + */ export default function coalesceBadge( overrides, serviceData, // These two parameters were kept separate to make tests clearer. defaultBadgeData, - { category, _cacheLength: defaultCacheSeconds } = {} + { category, _cacheLength: defaultCacheSeconds } = {}, ) { // The "overrideX" naming is based on services that provide badge // parameters themselves, which can be overridden by a query string @@ -50,16 +51,12 @@ export default function coalesceBadge( label: overrideLabel, logo: overrideLogo, logoColor: overrideLogoColor, + logoSize: overrideLogoSize, link: overrideLink, colorB: legacyOverrideColor, colorA: legacyOverrideLabelColor, } = overrides - let { - logoWidth: overrideLogoWidth, - logoPosition: overrideLogoPosition, - color: overrideColor, - labelColor: overrideLabelColor, - } = overrides + let { color: overrideColor, labelColor: overrideLabelColor } = overrides // Only use the legacy properties if the new ones are not provided if (typeof overrideColor === 'undefined') { @@ -76,8 +73,6 @@ export default function coalesceBadge( if (typeof overrideLabelColor === 'number') { overrideLabelColor = `${overrideLabelColor}` } - overrideLogoWidth = +overrideLogoWidth || undefined - overrideLogoPosition = +overrideLogoPosition || undefined const { isError, @@ -88,8 +83,7 @@ export default function coalesceBadge( logoSvg: serviceLogoSvg, namedLogo: serviceNamedLogo, logoColor: serviceLogoColor, - logoWidth: serviceLogoWidth, - logoPosition: serviceLogoPosition, + logoSize: serviceLogoSize, link: serviceLink, cacheSeconds: serviceCacheSeconds, style: serviceStyle, @@ -103,12 +97,6 @@ export default function coalesceBadge( } = defaultBadgeData let style = coalesce(overrideStyle, serviceStyle) - if (typeof style !== 'string') { - style = 'flat' - } - if (style.startsWith('popout')) { - style = style.replace('popout', 'flat') - } const styleValues = [ 'plastic', 'flat', @@ -120,7 +108,7 @@ export default function coalesceBadge( style = 'flat' } - let namedLogo, namedLogoColor, logoWidth, logoPosition, logoSvgBase64 + let namedLogo, namedLogoColor, logoSize, logoWidth, logoSvgBase64 if (overrideLogo) { // `?logo=` could be a named logo or encoded svg. const overrideLogoSvgBase64 = decodeDataUrlFromQueryParam(overrideLogo) @@ -134,30 +122,35 @@ export default function coalesceBadge( } // If the logo has been overridden it does not make sense to inherit the // original width or position. - logoWidth = overrideLogoWidth - logoPosition = overrideLogoPosition + logoSize = overrideLogoSize } else { if (serviceLogoSvg) { logoSvgBase64 = svg2base64(serviceLogoSvg) } else { namedLogo = coalesce( serviceNamedLogo, - style === 'social' ? defaultNamedLogo : undefined + style === 'social' ? defaultNamedLogo : undefined, ) namedLogoColor = coalesce(overrideLogoColor, serviceLogoColor) } - logoWidth = coalesce(overrideLogoWidth, serviceLogoWidth) - logoPosition = coalesce(overrideLogoPosition, serviceLogoPosition) + logoSize = coalesce(overrideLogoSize, serviceLogoSize) } if (namedLogo) { + const iconSize = getIconSize(String(namedLogo).toLowerCase()) + + if (iconSize && logoSize === 'auto') { + logoWidth = (iconSize.width / iconSize.height) * DEFAULT_LOGO_HEIGHT + } + logoSvgBase64 = prepareNamedLogo({ name: namedLogo, color: namedLogoColor, + size: logoSize, style, }) } - return { + const badgeData = { // Use `coalesce()` to support empty labels and messages, as in the static // badge. label: coalesce(overrideLabel, serviceLabel, defaultLabel, category), @@ -167,20 +160,29 @@ export default function coalesceBadge( isError ? undefined : overrideColor, serviceColor, defaultColor, - 'lightgrey' + 'lightgrey', ), labelColor: coalesce( // In case of an error, disregard user's color override. isError ? undefined : overrideLabelColor, serviceLabelColor, - defaultLabelColor + defaultLabelColor, ), style, namedLogo, logo: logoSvgBase64, logoWidth, - logoPosition, + logoSize, links: toArray(overrideLink || serviceLink), cacheLengthSeconds: coalesce(serviceCacheSeconds, defaultCacheSeconds), } + badgeData.label = + typeof badgeData.label === 'string' + ? badgeData.label.slice(0, 255) + : badgeData.label + badgeData.message = + typeof badgeData.message === 'string' + ? badgeData.message.slice(0, 255) + : badgeData.message + return badgeData } diff --git a/core/base-service/coalesce-badge.spec.js b/core/base-service/coalesce-badge.spec.js index 07ffedbe2010c..6f97c1ac9e62d 100644 --- a/core/base-service/coalesce-badge.spec.js +++ b/core/base-service/coalesce-badge.spec.js @@ -1,5 +1,5 @@ import { expect } from 'chai' -import { getShieldsIcon, getSimpleIcon } from '../../lib/logos.js' +import { getSimpleIcon } from '../../lib/logos.js' import coalesceBadge from './coalesce-badge.js' describe('coalesceBadge', function () { @@ -25,9 +25,25 @@ describe('coalesceBadge', function () { it('overrides the label', function () { expect( - coalesceBadge({ label: 'purr count' }, { label: 'purrs' }, {}) + coalesceBadge({ label: 'purr count' }, { label: 'purrs' }, {}), ).to.include({ label: 'purr count' }) }) + + it('truncates really long labels', function () { + expect( + coalesceBadge( + {}, + { + label: + 'This is really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really long', + }, + {}, + ), + ).to.include({ + label: + 'This is really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really lo', + }) + }) }) describe('Message', function () { @@ -45,6 +61,22 @@ describe('coalesceBadge', function () { message: 10, }) }) + + it('truncates really long messages', function () { + expect( + coalesceBadge( + {}, + { + message: + 'This is really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really long', + }, + {}, + ), + ).to.include({ + message: + 'This is really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really lo', + }) + }) }) describe('Right color', function () { @@ -54,11 +86,11 @@ describe('coalesceBadge', function () { it('overrides the color', function () { expect( - coalesceBadge({ color: '10ADED' }, { color: 'red' }, {}) + coalesceBadge({ color: '10ADED' }, { color: 'red' }, {}), ).to.include({ color: '10ADED' }) // also expected for legacy name expect( - coalesceBadge({ colorB: 'B0ADED' }, { color: 'red' }, {}) + coalesceBadge({ colorB: 'B0ADED' }, { color: 'red' }, {}), ).to.include({ color: 'B0ADED' }) }) @@ -68,16 +100,16 @@ describe('coalesceBadge', function () { coalesceBadge( { color: '10ADED' }, { isError: true, color: 'lightgray' }, - {} - ) + {}, + ), ).to.include({ color: 'lightgray' }) // also expected for legacy name expect( coalesceBadge( { colorB: 'B0ADED' }, { isError: true, color: 'lightgray' }, - {} - ) + {}, + ), ).to.include({ color: 'lightgray' }) }) }) @@ -102,11 +134,11 @@ describe('coalesceBadge', function () { it('overrides the label color', function () { expect( - coalesceBadge({ labelColor: '42f483' }, { color: 'green' }, {}) + coalesceBadge({ labelColor: '42f483' }, { color: 'green' }, {}), ).to.include({ labelColor: '42f483' }) // also expected for legacy name expect( - coalesceBadge({ colorA: 'B2f483' }, { color: 'green' }, {}) + coalesceBadge({ colorA: 'B2f483' }, { color: 'green' }, {}), ).to.include({ labelColor: 'B2f483' }) }) @@ -116,8 +148,8 @@ describe('coalesceBadge', function () { // Scoutcamp converts numeric query params to numbers. { color: 123 }, { color: 'green' }, - {} - ) + {}, + ), ).to.include({ color: '123' }) // also expected for legacy name expect( @@ -125,8 +157,8 @@ describe('coalesceBadge', function () { // Scoutcamp converts numeric query params to numbers. { colorB: 123 }, { color: 'green' }, - {} - ) + {}, + ), ).to.include({ color: '123' }) }) }) @@ -138,9 +170,9 @@ describe('coalesceBadge', function () { }) it('when a social badge, uses the default named logo', function () { - // .not.be.empty for confidence that nothing has changed with `getShieldsIcon()`. + // .not.be.empty for confidence that nothing has changed with `getSimpleIcon()`. expect( - coalesceBadge({ style: 'social' }, {}, { namedLogo: 'appveyor' }).logo + coalesceBadge({ style: 'social' }, {}, { namedLogo: 'appveyor' }).logo, ).to.equal(getSimpleIcon({ name: 'appveyor' })).and.not.be.empty }) @@ -149,65 +181,64 @@ describe('coalesceBadge', function () { namedLogo: 'npm', }) expect(coalesceBadge({}, { namedLogo: 'npm' }, {}).logo).to.equal( - getShieldsIcon({ name: 'npm' }) + getSimpleIcon({ name: 'npm' }), ).and.not.to.be.empty }) it('applies the named logo with color', function () { expect( - coalesceBadge({}, { namedLogo: 'npm', logoColor: 'blue' }, {}).logo - ).to.equal(getShieldsIcon({ name: 'npm', color: 'blue' })).and.not.to.be - .empty + coalesceBadge({}, { namedLogo: 'dependabot', logoColor: 'blue' }, {}) + .logo, + ).to.equal(getSimpleIcon({ name: 'dependabot', color: 'blue' })).and.not + .to.be.empty }) it('overrides the logo', function () { expect( - coalesceBadge({ logo: 'npm' }, { namedLogo: 'appveyor' }, {}).logo - ).to.equal(getShieldsIcon({ name: 'npm' })).and.not.be.empty + coalesceBadge({ logo: 'npm' }, { namedLogo: 'appveyor' }, {}).logo, + ).to.equal(getSimpleIcon({ name: 'npm' })).and.not.be.empty }) it('overrides the logo with a color', function () { expect( coalesceBadge( - { logo: 'npm', logoColor: 'blue' }, + { logo: 'dependabot', logoColor: 'blue' }, { namedLogo: 'appveyor' }, - {} - ).logo - ).to.equal(getShieldsIcon({ name: 'npm', color: 'blue' })).and.not.be - .empty + {}, + ).logo, + ).to.equal(getSimpleIcon({ name: 'dependabot', color: 'blue' })).and.not + .be.empty }) - it("when the logo is overridden, it ignores the service's logo color, position, and width", function () { + it("when the logo is overridden, it ignores the service's logo color and width", function () { expect( coalesceBadge( { logo: 'npm' }, { namedLogo: 'appveyor', logoColor: 'red', - logoPosition: -3, - logoWidth: 100, }, - {} - ).logo - ).to.equal(getShieldsIcon({ name: 'npm' })).and.not.be.empty + {}, + ).logo, + ).to.equal(getSimpleIcon({ name: 'npm' })).and.not.be.empty }) it("overrides the service logo's color", function () { expect( coalesceBadge( { logoColor: 'blue' }, - { namedLogo: 'npm', logoColor: 'red' }, - {} - ).logo - ).to.equal(getShieldsIcon({ name: 'npm', color: 'blue' })).and.not.be - .empty + { namedLogo: 'dependabot', logoColor: 'red' }, + {}, + ).logo, + ).to.equal(getSimpleIcon({ name: 'dependabot', color: 'blue' })).and.not + .be.empty }) // https://github.com/badges/shields/issues/2998 it('overrides logoSvg', function () { const logoSvg = 'data:image/svg+xml;base64,PHN2ZyB4bWxu' expect(coalesceBadge({ logo: 'npm' }, { logoSvg }, {}).logo).to.equal( - getShieldsIcon({ name: 'npm' }) + getSimpleIcon({ name: 'npm' }), ).and.not.be.empty }) }) @@ -216,7 +247,7 @@ 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' }, {}) + coalesceBadge({ logo: logoSvg }, { namedLogo: 'appveyor' }, {}), ).to.include({ logo: logoSvg }) }) @@ -226,37 +257,23 @@ describe('coalesceBadge', function () { coalesceBadge( { logo: logoSvg, logoColor: 'brightgreen' }, { namedLogo: 'appveyor' }, - {} - ) + {}, + ), ).to.include({ logo: logoSvg }) }) }) - describe('Logo width', function () { - it('overrides the logoWidth', function () { - expect(coalesceBadge({ logoWidth: 20 }, {}, {})).to.include({ - logoWidth: 20, + describe('Logo size', function () { + it('overrides the logoSize', function () { + expect(coalesceBadge({ logoSize: 'auto' }, {}, {})).to.include({ + logoSize: 'auto', }) }) - it('applies the logo width', function () { + it('applies the logo size', function () { expect( - coalesceBadge({}, { namedLogo: 'npm', logoWidth: 275 }, {}) - ).to.include({ logoWidth: 275 }) - }) - }) - - describe('Logo position', function () { - it('overrides the logoPosition', function () { - expect(coalesceBadge({ logoPosition: -10 }, {}, {})).to.include({ - logoPosition: -10, - }) - }) - - it('applies the logo position', function () { - expect( - coalesceBadge({}, { namedLogo: 'npm', logoPosition: -10 }, {}) - ).to.include({ logoPosition: -10 }) + coalesceBadge({}, { namedLogo: 'npm', logoSize: 'auto' }, {}), + ).to.include({ logoSize: 'auto' }) }) }) @@ -268,8 +285,8 @@ describe('coalesceBadge', function () { { link: 'https://circleci.com/workflow-run/184ef3de-4836-4805-a2e4-0ceba099f92d', }, - {} - ).links + {}, + ).links, ).to.deep.equal(['https://circleci.com/gh/badges/daily-tests']) }) }) @@ -286,21 +303,12 @@ describe('coalesceBadge', function () { style: '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', - }) - }) }) describe('Cache length', function () { it('overrides the cache length', function () { expect( - coalesceBadge({ style: 'pill' }, { cacheSeconds: 123 }, {}) + coalesceBadge({ style: 'pill' }, { cacheSeconds: 123 }, {}), ).to.include({ cacheLengthSeconds: 123 }) }) }) diff --git a/core/base-service/coalesce.js b/core/base-service/coalesce.js index 8a55ff4c89dd7..3a6fc456409da 100644 --- a/core/base-service/coalesce.js +++ b/core/base-service/coalesce.js @@ -1,3 +1,13 @@ +/** + * Returns the first non-null, non-undefined value from the provided candidates. + * + * Mirrors SQL's COALESCE: iterates the arguments in order and returns + * the first one that is neither `undefined` nor `null`. + * Returns `undefined` when all candidates are null or undefined. + * + * @param {...*} candidates - Values to evaluate in order. + * @returns {*} The first defined, non-null candidate, or `undefined`. + */ export default function coalesce(...candidates) { return candidates.find(c => c !== undefined && c !== null) } diff --git a/core/base-service/coalesce.spec.js b/core/base-service/coalesce.spec.js index 361f2bd8a7d32..caafc31490374 100644 --- a/core/base-service/coalesce.spec.js +++ b/core/base-service/coalesce.spec.js @@ -6,7 +6,7 @@ import coalesce from './coalesce.js' // https://github.com/royriojas/coalescy for these tests! describe('coalesce', function () { - test(coalesce, function () { + test(coalesce, () => { given().expect(undefined) given(null, []).expect([]) given(null, [], {}).expect([]) diff --git a/core/base-service/deprecated-service.js b/core/base-service/deprecated-service.js deleted file mode 100644 index 03ce5b2485e74..0000000000000 --- a/core/base-service/deprecated-service.js +++ /dev/null @@ -1,45 +0,0 @@ -import Joi from 'joi' -import camelcase from 'camelcase' -import BaseService from './base.js' -import { isValidCategory } from './categories.js' -import { Deprecated } from './errors.js' -import { isValidRoute } from './route.js' - -const attrSchema = Joi.object({ - route: isValidRoute, - name: Joi.string(), - label: Joi.string(), - category: isValidCategory, - // The content of examples is validated later, via `transformExamples()`. - examples: Joi.array().default([]), - message: Joi.string(), - dateAdded: Joi.date().required(), -}).required() - -function deprecatedService(attrs) { - const { route, name, label, category, examples, message } = Joi.attempt( - attrs, - attrSchema, - `Deprecated service for ${attrs.route.base}` - ) - - return class DeprecatedService extends BaseService { - static name = name - ? `Deprecated${name}` - : `Deprecated${camelcase(route.base.replace(/\//g, '_'), { - pascalCase: true, - })}` - - static category = category - static isDeprecated = true - static route = route - static examples = examples - static defaultBadgeData = { label } - - async handle() { - throw new Deprecated({ prettyMessage: message }) - } - } -} - -export default deprecatedService diff --git a/core/base-service/deprecated-service.spec.js b/core/base-service/deprecated-service.spec.js deleted file mode 100644 index 58ca62b6d9a12..0000000000000 --- a/core/base-service/deprecated-service.spec.js +++ /dev/null @@ -1,67 +0,0 @@ -import { expect } from 'chai' -import deprecatedService from './deprecated-service.js' - -describe('DeprecatedService', function () { - const route = { - base: 'service/that/no/longer/exists', - format: '(?:.+)', - } - const category = 'analysis' - const dateAdded = new Date() - const commonAttrs = { route, category, dateAdded } - - it('returns true on isDeprecated', function () { - const service = deprecatedService({ ...commonAttrs }) - expect(service.isDeprecated).to.be.true - }) - - it('has the expected name', function () { - const service = deprecatedService({ ...commonAttrs }) - expect(service.name).to.equal('DeprecatedServiceThatNoLongerExists') - }) - - it('sets specified route', function () { - const service = deprecatedService({ ...commonAttrs }) - expect(service.route).to.deep.equal(route) - }) - - it('sets specified label', function () { - const label = 'coverity' - const service = deprecatedService({ ...commonAttrs, label }) - expect(service.defaultBadgeData.label).to.equal(label) - }) - - it('sets specified category', function () { - const service = deprecatedService({ ...commonAttrs }) - expect(service.category).to.equal(category) - }) - - it('sets specified examples', function () { - const examples = [ - { - title: 'Not sure we would have examples', - }, - ] - const service = deprecatedService({ ...commonAttrs, examples }) - expect(service.examples).to.deep.equal(examples) - }) - - it('uses default deprecation message when no message specified', async function () { - const service = deprecatedService({ ...commonAttrs }) - expect(await service.invoke()).to.deep.equal({ - isError: true, - color: 'lightgray', - message: 'no longer available', - }) - }) - - it('uses custom deprecation message when specified', async function () { - const message = 'extended outage' - const service = deprecatedService({ ...commonAttrs, message }) - expect(await service.invoke()).to.deep.equal({ - isError: true, - color: 'lightgray', - message, - }) - }) -}) diff --git a/core/base-service/errors.js b/core/base-service/errors.js index a1c897ff6d0e9..e8f8b29053535 100644 --- a/core/base-service/errors.js +++ b/core/base-service/errors.js @@ -32,7 +32,7 @@ class ShieldsRuntimeError extends Error { } /** - * @param {module:core/base-service/errors~RuntimeErrorProps} props + * @param {RuntimeErrorProps} props * Refer to individual attrs * @param {string} message Exception message for debug purposes */ @@ -42,6 +42,7 @@ class ShieldsRuntimeError extends Error { if (props.underlyingError) { this.stack = props.underlyingError.stack } + this.cacheSeconds = props.cacheSeconds } } @@ -60,7 +61,7 @@ class NotFound extends ShieldsRuntimeError { } /** - * @param {module:core/base-service/errors~RuntimeErrorProps} props + * @param {RuntimeErrorProps} props * Refer to individual attrs */ constructor(props = {}) { @@ -87,7 +88,7 @@ class InvalidResponse extends ShieldsRuntimeError { } /** - * @param {module:core/base-service/errors~RuntimeErrorProps} props + * @param {RuntimeErrorProps} props * Refer to individual attrs */ constructor(props = {}) { @@ -113,7 +114,7 @@ class Inaccessible extends ShieldsRuntimeError { } /** - * @param {module:core/base-service/errors~RuntimeErrorProps} props + * @param {RuntimeErrorProps} props * Refer to individual attrs */ constructor(props = {}) { @@ -138,7 +139,7 @@ class ImproperlyConfigured extends ShieldsRuntimeError { } /** - * @param {module:core/base-service/errors~RuntimeErrorProps} props + * @param {RuntimeErrorProps} props * Refer to individual attrs */ constructor(props = {}) { @@ -164,7 +165,7 @@ class InvalidParameter extends ShieldsRuntimeError { } /** - * @param {module:core/base-service/errors~RuntimeErrorProps} props + * @param {RuntimeErrorProps} props * Refer to individual attrs */ constructor(props = {}) { @@ -176,28 +177,6 @@ class InvalidParameter extends ShieldsRuntimeError { } } -/** - * Throw this error to indicate that a service is deprecated or removed - */ -class Deprecated extends ShieldsRuntimeError { - get name() { - return 'Deprecated' - } - - get defaultPrettyMessage() { - return 'no longer available' - } - - /** - * @param {module:core/base-service/errors~RuntimeErrorProps} props - * Refer to individual attrs - */ - constructor(props) { - const message = 'Deprecated' - super(props, message) - } -} - /** * @typedef {object} RuntimeErrorProps * @property {Error} underlyingError Exception we are wrapping (Optional) @@ -206,6 +185,9 @@ class Deprecated extends ShieldsRuntimeError { * @property {string} prettyMessage User-facing error message to override the * value of `defaultPrettyMessage()`. This is the text that will appear on the * badge when we catch and render the exception (Optional) + * @property {number} cacheSeconds Length of time to cache this error response + * for. Defaults to the cacheLength of the service class throwing the error + * (Optional) */ export { @@ -215,5 +197,4 @@ export { InvalidResponse, Inaccessible, InvalidParameter, - Deprecated, } diff --git a/core/base-service/examples.js b/core/base-service/examples.js deleted file mode 100644 index 6aa9ff03ed891..0000000000000 --- a/core/base-service/examples.js +++ /dev/null @@ -1,156 +0,0 @@ -import Joi from 'joi' -import { pathToRegexp, compile } from 'path-to-regexp' -import categories from '../../services/categories.js' -import coalesceBadge from './coalesce-badge.js' -import { makeFullUrl } from './route.js' - -const optionalObjectOfKeyValues = Joi.object().pattern( - /./, - Joi.string().allow(null) -) - -const schema = Joi.object({ - // This should be: - // title: Joi.string().required(), - title: Joi.string(), - namedParams: optionalObjectOfKeyValues.required(), - queryParams: optionalObjectOfKeyValues.default({}), - pattern: Joi.string(), - staticPreview: Joi.object({ - label: Joi.string(), - message: Joi.alternatives() - .try(Joi.string().allow('').required(), Joi.number()) - .required(), - color: Joi.string(), - style: Joi.string(), - }).required(), - keywords: Joi.array().items(Joi.string()).default([]), - documentation: Joi.string(), // Valid HTML. -}).required() - -function validateExample(example, index, ServiceClass) { - const result = Joi.attempt( - example, - schema, - `Example for ${ServiceClass.name} at index ${index}` - ) - - const { pattern, namedParams } = result - - if (!pattern && !ServiceClass.route.pattern) { - throw new Error( - `Example for ${ServiceClass.name} at index ${index} does not declare a pattern` - ) - } - if (pattern === ServiceClass.route.pattern) { - throw new Error( - `Example for ${ServiceClass.name} at index ${index} declares a redundant pattern which should be removed` - ) - } - - // Make sure we can build the full URL using these patterns. - try { - compile(pattern || ServiceClass.route.pattern, { - encode: encodeURIComponent, - })(namedParams) - } catch (e) { - throw Error( - `In example for ${ - ServiceClass.name - } at index ${index}, ${e.message.toLowerCase()}` - ) - } - // Make sure there are no extra keys. - let keys = [] - pathToRegexp(pattern || ServiceClass.route.pattern, keys, { - strict: true, - sensitive: true, - }) - keys = keys.map(({ name }) => name) - const extraKeys = Object.keys(namedParams).filter(k => !keys.includes(k)) - if (extraKeys.length) { - throw Error( - `In example for ${ - ServiceClass.name - } at index ${index}, namedParams contains unknown keys: ${extraKeys.join( - ', ' - )}` - ) - } - - if (example.keywords) { - // Make sure the keywords are at least two characters long. - const tinyKeywords = example.keywords.filter(k => k.length < 2) - if (tinyKeywords.length) { - throw Error( - `In example for ${ - ServiceClass.name - } at index ${index}, keywords contains words that are less than two characters long: ${tinyKeywords.join( - ', ' - )}` - ) - } - // Make sure none of the keywords are already included in the title. - const title = (example.title || ServiceClass.name).toLowerCase() - const redundantKeywords = example.keywords.filter(k => - title.includes(k.toLowerCase()) - ) - if (redundantKeywords.length) { - throw Error( - `In example for ${ - ServiceClass.name - } at index ${index}, keywords contains words that are already in the title: ${redundantKeywords.join( - ', ' - )}` - ) - } - } - - return result -} - -function transformExample(inExample, index, ServiceClass) { - const { - // We should get rid of this transform, since the class name is never what - // we want to see. - title = ServiceClass.name, - namedParams, - queryParams, - pattern, - staticPreview, - keywords, - documentation, - } = validateExample(inExample, index, ServiceClass) - - const { label, message, color, style, namedLogo } = coalesceBadge( - {}, - staticPreview, - ServiceClass.defaultBadgeData, - ServiceClass - ) - - return { - title, - example: { - pattern: makeFullUrl( - ServiceClass.route.base, - pattern || ServiceClass.route.pattern - ), - namedParams, - queryParams, - }, - preview: { - label, - message: `${message}`, - color, - style: style === 'flat' ? undefined : style, - namedLogo, - }, - keywords: keywords.concat( - categories.find(c => c.id === ServiceClass.category).keywords - ), - documentation: documentation ? { __html: documentation } : undefined, - } -} - -export { validateExample, transformExample } diff --git a/core/base-service/examples.spec.js b/core/base-service/examples.spec.js deleted file mode 100644 index 770b257165999..0000000000000 --- a/core/base-service/examples.spec.js +++ /dev/null @@ -1,167 +0,0 @@ -import { expect } from 'chai' -import { test, given } from 'sazerac' -import { validateExample, transformExample } from './examples.js' - -describe('validateExample function', function () { - it('passes valid examples', function () { - const validExamples = [ - { - title: 'Package manager versioning badge', - staticPreview: { message: '123' }, - pattern: 'dt/:package', - namedParams: { package: 'mypackage' }, - keywords: ['semver', 'management'], - }, - ] - - validExamples.forEach(example => { - expect(() => - validateExample(example, 0, { route: {}, name: 'mockService' }) - ).not.to.throw(Error) - }) - }) - - it('rejects invalid examples', function () { - const invalidExamples = [ - {}, - { staticPreview: { message: '123' } }, - { - staticPreview: { message: '123' }, - pattern: 'dt/:package', - namedParams: { package: 'mypackage' }, - exampleUrl: 'dt/mypackage', - }, - { staticPreview: { message: '123' }, pattern: 'dt/:package' }, - { - staticPreview: { message: '123' }, - pattern: 'dt/:package', - previewUrl: 'dt/mypackage', - }, - { - staticPreview: { message: '123' }, - pattern: 'dt/:package', - exampleUrl: 'dt/mypackage', - }, - { previewUrl: 'dt/mypackage' }, - { - staticPreview: { message: '123' }, - pattern: 'dt/:package', - namedParams: { package: 'mypackage' }, - keywords: ['a'], // Keyword too short. - }, - { - staticPreview: { message: '123' }, - pattern: 'dt/:package', - namedParams: { package: 'mypackage' }, - keywords: ['mockService'], // No title and keyword matching the class name. - }, - { - title: 'Package manager versioning badge', - staticPreview: { message: '123' }, - pattern: 'dt/:package', - namedParams: { package: 'mypackage' }, - keywords: ['version'], // Keyword included in title. - }, - ] - - invalidExamples.forEach(example => { - expect(() => - validateExample(example, 0, { route: {}, name: 'mockService' }) - ).to.throw(Error) - }) - }) -}) - -test(transformExample, function () { - const ExampleService = { - name: 'ExampleService', - route: { - base: 'some-service', - pattern: ':interval/:packageName', - }, - defaultBadgeData: { - label: 'downloads', - }, - category: 'platform-support', - } - - given( - { - pattern: 'dt/:packageName', - namedParams: { packageName: 'express' }, - staticPreview: { message: '50k' }, - keywords: ['hello'], - }, - 0, - ExampleService - ).expect({ - title: 'ExampleService', - example: { - pattern: '/some-service/dt/:packageName', - namedParams: { packageName: 'express' }, - queryParams: {}, - }, - preview: { - label: 'downloads', - message: '50k', - color: 'lightgrey', - namedLogo: undefined, - style: undefined, - }, - keywords: ['hello', 'platform'], - documentation: undefined, - }) - - given( - { - namedParams: { interval: 'dt', packageName: 'express' }, - staticPreview: { message: '50k' }, - keywords: ['hello'], - }, - 0, - ExampleService - ).expect({ - title: 'ExampleService', - example: { - pattern: '/some-service/:interval/:packageName', - namedParams: { interval: 'dt', packageName: 'express' }, - queryParams: {}, - }, - preview: { - label: 'downloads', - message: '50k', - color: 'lightgrey', - namedLogo: undefined, - style: undefined, - }, - keywords: ['hello', 'platform'], - documentation: undefined, - }) - - given( - { - namedParams: { interval: 'dt', packageName: 'express' }, - queryParams: { registry_url: 'http://example.com/' }, - staticPreview: { message: '50k' }, - keywords: ['hello'], - }, - 0, - ExampleService - ).expect({ - title: 'ExampleService', - example: { - pattern: '/some-service/:interval/:packageName', - namedParams: { interval: 'dt', packageName: 'express' }, - queryParams: { registry_url: 'http://example.com/' }, - }, - preview: { - label: 'downloads', - message: '50k', - color: 'lightgrey', - namedLogo: undefined, - style: undefined, - }, - keywords: ['hello', 'platform'], - documentation: undefined, - }) -}) diff --git a/core/base-service/got-config.js b/core/base-service/got-config.js new file mode 100644 index 0000000000000..a78755fd836c3 --- /dev/null +++ b/core/base-service/got-config.js @@ -0,0 +1,34 @@ +import configModule from 'config' +import Joi from 'joi' +import { fileSizeBytes } from '../../services/validators.js' + +const schema = Joi.object({ + fetchLimitBytes: fileSizeBytes, + userAgentBase: Joi.string().required(), +}).required() +const config = configModule.util.toObject() +const publicConfig = Joi.attempt(config.public, schema, { allowUnknown: true }) + +const fetchLimitBytes = publicConfig.fetchLimitBytes + +/** + * Build the User-Agent string for outgoing HTTP requests. Incorporates the + * configured base name and, when available, a version from the deployment + * environment (Docker image version or Heroku slug commit). + * + * @param {string} [userAgentBase] - Base name for the user-agent. Defaults to + * the configured `public.userAgentBase`. + * @returns {string} Formatted user-agent string (e.g. `shields/abc1234`). + */ +function getUserAgent(userAgentBase = publicConfig.userAgentBase) { + let version = 'dev' + if (process.env.DOCKER_SHIELDS_VERSION) { + version = process.env.DOCKER_SHIELDS_VERSION + } + if (process.env.HEROKU_SLUG_COMMIT) { + version = process.env.HEROKU_SLUG_COMMIT.substring(0, 7) + } + return `${userAgentBase}/${version}` +} + +export { fetchLimitBytes, getUserAgent } diff --git a/core/base-service/got-config.spec.js b/core/base-service/got-config.spec.js new file mode 100644 index 0000000000000..670b76381905e --- /dev/null +++ b/core/base-service/got-config.spec.js @@ -0,0 +1,27 @@ +import { expect } from 'chai' +import { getUserAgent } from './got-config.js' + +describe('getUserAgent function', function () { + afterEach(function () { + delete process.env.HEROKU_SLUG_COMMIT + delete process.env.DOCKER_SHIELDS_VERSION + }) + + it('uses the default userAgentBase', function () { + expect(getUserAgent()).to.equal('shields (self-hosted)/dev') + }) + + it('applies custom userAgentBase', function () { + expect(getUserAgent('custom')).to.equal('custom/dev') + }) + + it('uses short commit SHA from HEROKU_SLUG_COMMIT if available', function () { + process.env.HEROKU_SLUG_COMMIT = '92090bd44742a5fac03bcb117002088fc7485834' + expect(getUserAgent('custom')).to.equal('custom/92090bd') + }) + + it('uses short commit SHA from DOCKER_SHIELDS_VERSION if available', function () { + process.env.DOCKER_SHIELDS_VERSION = 'server-2021-11-22' + expect(getUserAgent('custom')).to.equal('custom/server-2021-11-22') + }) +}) diff --git a/core/base-service/got.js b/core/base-service/got.js index 0120e216cd56d..dcd079aaff4a7 100644 --- a/core/base-service/got.js +++ b/core/base-service/got.js @@ -1,73 +1,59 @@ import got from 'got' import { Inaccessible, InvalidResponse } from './errors.js' +import { + fetchLimitBytes as fetchLimitBytesDefault, + getUserAgent, +} from './got-config.js' -const userAgent = 'Shields.io/2003a' +const userAgent = getUserAgent() -function requestOptions2GotOptions(options) { - const requestOptions = Object.assign({}, options) - const gotOptions = {} - const interchangableOptions = ['body', 'form', 'headers', 'method', 'url'] - - interchangableOptions.forEach(function (opt) { - if (opt in requestOptions) { - gotOptions[opt] = requestOptions[opt] - delete requestOptions[opt] - } - }) - - if ('qs' in requestOptions) { - gotOptions.searchParams = requestOptions.qs - delete requestOptions.qs - } - - if ('gzip' in requestOptions) { - gotOptions.decompress = requestOptions.gzip - delete requestOptions.gzip - } - - if ('strictSSL' in requestOptions) { - gotOptions.https = { - rejectUnauthorized: requestOptions.strictSSL, - } - delete requestOptions.strictSSL - } - - if ('auth' in requestOptions) { - gotOptions.username = requestOptions.auth.user - gotOptions.password = requestOptions.auth.pass - delete requestOptions.auth - } - - if (Object.keys(requestOptions).length > 0) { - throw new Error(`Found unrecognised options ${Object.keys(requestOptions)}`) - } - - return gotOptions -} - -async function sendRequest(gotWrapper, url, options) { - const gotOptions = requestOptions2GotOptions(options) +async function sendRequest(gotWrapper, url, options = {}, systemErrors = {}) { + const gotOptions = Object.assign({}, options) gotOptions.throwHttpErrors = false - gotOptions.retry = 0 + gotOptions.retry = { limit: 0 } gotOptions.headers = gotOptions.headers || {} gotOptions.headers['User-Agent'] = userAgent try { const resp = await gotWrapper(url, gotOptions) return { res: resp, buffer: resp.body } } catch (err) { - if (err instanceof got.CancelError) { + if (err.code === 'ERR_ABORTED') { throw new InvalidResponse({ underlyingError: new Error('Maximum response size exceeded'), }) } + if (err.code in systemErrors) { + throw new Inaccessible({ + ...systemErrors[err.code], + underlyingError: err, + }) + } throw new Inaccessible({ underlyingError: err }) } } -function fetchFactory(fetchLimitBytes) { +function _fetchFactory(fetchLimitBytes = fetchLimitBytesDefault) { const gotWithLimit = got.extend({ handlers: [ (options, next) => { + const abortController = new AbortController() + const originalSignal = options.signal + if (originalSignal) { + if (originalSignal.aborted) { + abortController.abort(originalSignal.reason) + } else if (typeof originalSignal.addEventListener === 'function') { + const onAbort = () => { + abortController.abort(originalSignal.reason) + if (typeof originalSignal.removeEventListener === 'function') { + originalSignal.removeEventListener('abort', onAbort) + } + } + originalSignal.addEventListener('abort', onAbort) + } + } + + options.signal = abortController.signal + const promiseOrStream = next(options) promiseOrStream.on('downloadProgress', progress => { if ( @@ -76,12 +62,7 @@ function fetchFactory(fetchLimitBytes) { // the entire file before we went over the limit progress.percent !== 1 ) { - /* - TODO: we should be able to pass cancel() a message - https://github.com/sindresorhus/got/blob/main/documentation/advanced-creation.md#examples - but by the time we catch it, err.message is just "Promise was canceled" - */ - promiseOrStream.cancel('Maximum response size exceeded') + abortController.abort('Maximum response size exceeded') } }) @@ -93,4 +74,6 @@ function fetchFactory(fetchLimitBytes) { return sendRequest.bind(sendRequest, gotWithLimit) } -export { requestOptions2GotOptions, fetchFactory } +const fetch = _fetchFactory() + +export { fetch, _fetchFactory } diff --git a/core/base-service/got.spec.js b/core/base-service/got.spec.js index 185052d16384e..2a7f1f494bdfc 100644 --- a/core/base-service/got.spec.js +++ b/core/base-service/got.spec.js @@ -1,50 +1,15 @@ import { expect } from 'chai' import nock from 'nock' -import { requestOptions2GotOptions, fetchFactory } from './got.js' +import { _fetchFactory } from './got.js' import { Inaccessible, InvalidResponse } from './errors.js' -describe('requestOptions2GotOptions function', function () { - it('translates valid options', function () { - expect( - requestOptions2GotOptions({ - body: 'body', - form: 'form', - headers: 'headers', - method: 'method', - url: 'url', - qs: 'qs', - gzip: 'gzip', - strictSSL: 'strictSSL', - auth: { user: 'user', pass: 'pass' }, - }) - ).to.deep.equal({ - body: 'body', - form: 'form', - headers: 'headers', - method: 'method', - url: 'url', - searchParams: 'qs', - decompress: 'gzip', - https: { rejectUnauthorized: 'strictSSL' }, - username: 'user', - password: 'pass', - }) - }) - - it('throws if unrecognised options are found', function () { - expect(() => - requestOptions2GotOptions({ body: 'body', foobar: 'foobar' }) - ).to.throw(Error, 'Found unrecognised options foobar') - }) -}) - describe('got wrapper', function () { it('should not throw an error if the response <= fetchLimitBytes', async function () { nock('https://www.google.com') .get('/foo/bar') .once() .reply(200, 'x'.repeat(100)) - const sendRequest = fetchFactory(100) + const sendRequest = _fetchFactory(100) const { res } = await sendRequest('https://www.google.com/foo/bar') expect(res.statusCode).to.equal(200) }) @@ -54,29 +19,59 @@ describe('got wrapper', function () { .get('/foo/bar') .once() .reply(200, 'x'.repeat(101)) - const sendRequest = fetchFactory(100) + const sendRequest = _fetchFactory(100) return expect( - sendRequest('https://www.google.com/foo/bar') + sendRequest('https://www.google.com/foo/bar'), ).to.be.rejectedWith(InvalidResponse, 'Maximum response size exceeded') }) it('should throw an Inaccessible error if the request throws a (non-HTTP) error', async function () { nock('https://www.google.com').get('/foo/bar').replyWithError('oh no') - const sendRequest = fetchFactory(1024) + const sendRequest = _fetchFactory(1024) return expect( - sendRequest('https://www.google.com/foo/bar') + sendRequest('https://www.google.com/foo/bar'), ).to.be.rejectedWith(Inaccessible, 'oh no') }) it('should throw an Inaccessible error if the host can not be accessed', async function () { this.timeout(5000) nock.disableNetConnect() - const sendRequest = fetchFactory(1024) + const sendRequest = _fetchFactory(1024) return expect( - sendRequest('https://www.google.com/foo/bar') + sendRequest('https://www.google.com/foo/bar'), ).to.be.rejectedWith( Inaccessible, - 'Nock: Disallowed net connect for "www.google.com:443/foo/bar"' + 'Nock: Disallowed net connect for "www.google.com:443/foo/bar"', + ) + }) + + it('should throw a custom error if provided', async function () { + const sendRequest = _fetchFactory(1024) + return ( + expect( + sendRequest( + 'https://www.google.com/foo/bar', + { timeout: { request: 1 } }, + { + ETIMEDOUT: { + prettyMessage: 'Oh no! A terrible thing has happened', + cacheSeconds: 10, + }, + }, + ), + ) + .to.be.rejectedWith( + Inaccessible, + "Inaccessible: Timeout awaiting 'request' for 1ms", + ) + // eslint-disable-next-line promise/prefer-await-to-then + .then(error => { + expect(error).to.have.property( + 'prettyMessage', + 'Oh no! A terrible thing has happened', + ) + expect(error).to.have.property('cacheSeconds', 10) + }) ) }) @@ -84,14 +79,14 @@ describe('got wrapper', function () { nock('https://www.google.com', { reqheaders: { 'user-agent': function (agent) { - return agent.startsWith('Shields.io') + return agent.startsWith('shields (self-hosted)') }, }, }) .get('/foo/bar') .once() .reply(200) - const sendRequest = fetchFactory(1024) + const sendRequest = _fetchFactory(1024) await sendRequest('https://www.google.com/foo/bar') }) diff --git a/core/base-service/graphql.spec.js b/core/base-service/graphql.spec.js index 2267ecf41b722..41dbd21d0eed3 100644 --- a/core/base-service/graphql.spec.js +++ b/core/base-service/graphql.spec.js @@ -9,18 +9,22 @@ describe('mergeQueries function', function () { it('merges valid gql queries', function () { expect( print( - mergeQueries( - gql` - query ($param: String!) { - foo(param: $param) { - bar - } + mergeQueries(gql` + query ($param: String!) { + foo(param: $param) { + bar } - ` - ) - ) - ).to.equalIgnoreSpaces( - 'query ($param: String!) { foo(param: $param) { bar } }' + } + `), + ), + ).to.equal( + print(gql` + query ($param: String!) { + foo(param: $param) { + bar + } + } + `), ) expect( @@ -37,11 +41,18 @@ describe('mergeQueries function', function () { query { baz } - ` - ) - ) - ).to.equalIgnoreSpaces( - 'query ($param: String!) { foo(param: $param) { bar } baz }' + `, + ), + ), + ).to.equal( + print(gql` + query ($param: String!) { + foo(param: $param) { + bar + } + baz + } + `), ) expect( @@ -61,10 +72,18 @@ describe('mergeQueries function', function () { query { baz } - ` - ) - ) - ).to.equalIgnoreSpaces('{ foo bar baz }') + `, + ), + ), + ).to.equal( + print(gql` + { + foo + bar + baz + } + `), + ) expect( print( @@ -78,10 +97,17 @@ describe('mergeQueries function', function () { { bar } - ` - ) - ) - ).to.equalIgnoreSpaces('{ foo bar }') + `, + ), + ), + ).to.equal( + print(gql` + { + foo + bar + } + `), + ) }) it('throws an error when passed invalid params', function () { diff --git a/core/base-service/index.js b/core/base-service/index.js index 502ede424bd31..b39f5259b95e2 100644 --- a/core/base-service/index.js +++ b/core/base-service/index.js @@ -1,33 +1,42 @@ import BaseService from './base.js' import BaseJsonService from './base-json.js' +import BaseJsonlService from './base-jsonl.js' import BaseGraphqlService from './base-graphql.js' import BaseStaticService from './base-static.js' import BaseSvgScrapingService from './base-svg-scraping.js' +import BaseTomlService from './base-toml.js' import BaseXmlService from './base-xml.js' import BaseYamlService from './base-yaml.js' -import deprecatedService from './deprecated-service.js' +import retiredService from './retired-service.js' import redirector from './redirector.js' import { NotFound, InvalidResponse, Inaccessible, InvalidParameter, - Deprecated, + ImproperlyConfigured, } from './errors.js' +import { pathParam, pathParams, queryParam, queryParams } from './openapi.js' export { BaseService, BaseJsonService, + BaseJsonlService, BaseGraphqlService, BaseStaticService, BaseSvgScrapingService, + BaseTomlService, BaseXmlService, BaseYamlService, - deprecatedService, + retiredService, redirector, NotFound, InvalidResponse, Inaccessible, InvalidParameter, - Deprecated, + ImproperlyConfigured, + pathParam, + pathParams, + queryParam, + queryParams, } diff --git a/core/base-service/json.js b/core/base-service/json.js index f7b549e1512eb..98e716b0604fe 100644 --- a/core/base-service/json.js +++ b/core/base-service/json.js @@ -3,6 +3,13 @@ import emojic from 'emojic' import { InvalidResponse } from './errors.js' import trace from './trace.js' +/** + * Parse a JSON response buffer. Throws an `InvalidResponse` error when the + * JSON is unparseable. + * + * @param {string|Buffer} buffer - The raw response body. + * @returns {object|Array} The parsed JSON value. + */ function parseJson(buffer) { const logTrace = (...args) => trace.logTrace('fetch', ...args) let json diff --git a/core/base-service/jsonl.js b/core/base-service/jsonl.js new file mode 100644 index 0000000000000..ddcc7c89070d5 --- /dev/null +++ b/core/base-service/jsonl.js @@ -0,0 +1,37 @@ +// See available emoji at http://emoji.muan.co/ +import emojic from 'emojic' +import { InvalidResponse } from './errors.js' +import trace from './trace.js' + +/** + * Parse a JSONL (newline-delimited JSON) response buffer. Splits the buffer + * by newlines, trims each line, filters empty lines, and parses each line + * as JSON. Throws an `InvalidResponse` error when any line is unparseable. + * + * @param {string|Buffer} buffer - The raw response body. + * @returns {Array} Array of parsed JSON values, one per line. + */ +function parseJsonl(buffer) { + const logTrace = (...args) => trace.logTrace('fetch', ...args) + let jsonl + try { + jsonl = buffer + .toString() + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean) + .map(line => JSON.parse(line)) + } catch (err) { + logTrace(emojic.dart, 'Response JSONL (unparseable)', buffer) + throw new InvalidResponse({ + prettyMessage: 'unparseable jsonl response', + underlyingError: err, + }) + } + logTrace(emojic.dart, 'Response JSONL (before validation)', jsonl, { + deep: true, + }) + return jsonl +} + +export { parseJsonl } diff --git a/core/base-service/legacy-request-handler.js b/core/base-service/legacy-request-handler.js index 725b1d66179e1..3a44642c1240c 100644 --- a/core/base-service/legacy-request-handler.js +++ b/core/base-service/legacy-request-handler.js @@ -1,12 +1,8 @@ -import request from 'request' import makeBadge from '../../badge-maker/lib/make-badge.js' import { setCacheHeaders } from './cache-headers.js' -import { Inaccessible, InvalidResponse, ShieldsRuntimeError } from './errors.js' import { makeSend } from './legacy-result-sender.js' import coalesceBadge from './coalesce-badge.js' -const userAgent = 'Shields.io/2003a' - // These query parameters are available to any badge. They are handled by // `coalesceBadge`. const globalQueryParams = new Set([ @@ -15,8 +11,7 @@ const globalQueryParams = new Set([ 'link', 'logo', 'logoColor', - 'logoPosition', - 'logoWidth', + 'logoSize', 'link', 'colorA', 'colorB', @@ -32,32 +27,12 @@ function flattenQueryParams(queryParams) { return Array.from(union).sort() } -function promisify(cachingRequest) { - return (uri, options) => - new Promise((resolve, reject) => { - cachingRequest(uri, options, (err, res, buffer) => { - if (err) { - if (err instanceof ShieldsRuntimeError) { - reject(err) - } else { - // Wrap the error in an Inaccessible so it can be identified - // by the BaseService handler. - reject(new Inaccessible({ underlyingError: err })) - } - } else { - resolve({ res, buffer }) - } - }) - }) -} - // handlerOptions can contain: // - handler: The service's request handler function // - queryParams: An array of the field names of any custom query parameters // the service uses // - cacheLength: An optional badge or category-specific cache length // (in number of seconds) to be used in preference to the default -// - fetchLimitBytes: A limit on the response size we're willing to parse // // For safety, the service must declare the query parameters it wants to use. // Only the declared parameters (and the global parameters) are provided to @@ -77,8 +52,7 @@ function handleRequest(cacheHeaderConfig, handlerOptions) { } const allowedKeys = flattenQueryParams(handlerOptions.queryParams) - const { cacheLength: serviceDefaultCacheLengthSeconds, fetchLimitBytes } = - handlerOptions + const { cacheLength: serviceDefaultCacheLengthSeconds } = handlerOptions return (queryParams, match, end, ask) => { /* @@ -90,7 +64,7 @@ function handleRequest(cacheHeaderConfig, handlerOptions) { */ if (match[0] === '/endpoint' && Object.keys(queryParams).length === 0) { ask.res.statusCode = 301 - ask.res.setHeader('Location', '/endpoint/') + ask.res.setHeader('Location', '/badges/endpoint-badge') ask.res.end() return } @@ -98,11 +72,9 @@ function handleRequest(cacheHeaderConfig, handlerOptions) { // `defaultCacheLengthSeconds` can be overridden by // `serviceDefaultCacheLengthSeconds` (either by category or on a badge- // by-badge basis). Then in turn that can be overridden by - // `serviceOverrideCacheLengthSeconds` (which we expect to be used only in - // the dynamic badge) but only if `serviceOverrideCacheLengthSeconds` is - // longer than `serviceDefaultCacheLengthSeconds` and then the `cacheSeconds` - // query param can also override both of those but again only if `cacheSeconds` - // is longer. + // `serviceOverrideCacheLengthSeconds`. + // Then the `cacheSeconds` query param can also override both of those + // but only if `cacheSeconds` is longer. // // When the legacy services have been rewritten, all the code in here // will go away, which should achieve this goal in a simpler way. @@ -131,7 +103,7 @@ function handleRequest(cacheHeaderConfig, handlerOptions) { const badgeData = coalesceBadge( filteredQueryParams, { label: 'vendor', message: 'unresponsive' }, - {} + {}, ) const svg = makeBadge(badgeData) const extension = (match.slice(-1)[0] || '.svg').replace(/^\./, '') @@ -139,44 +111,9 @@ function handleRequest(cacheHeaderConfig, handlerOptions) { makeSend(extension, ask.res, end)(svg) }, 25000) - function cachingRequest(uri, options, callback) { - if (typeof options === 'function' && !callback) { - callback = options - } - if (options && typeof options === 'object') { - options.uri = uri - } else if (typeof uri === 'string') { - options = { uri } - } else { - options = uri - } - options.headers = options.headers || {} - options.headers['User-Agent'] = userAgent - - let bufferLength = 0 - const r = request(options, callback) - r.on('data', chunk => { - bufferLength += chunk.length - if (bufferLength > fetchLimitBytes) { - r.abort() - r.emit( - 'error', - new InvalidResponse({ - prettyMessage: 'Maximum response size exceeded', - }) - ) - } - }) - } - - // Wrapper around `cachingRequest` that returns a promise rather than needing - // to pass a callback. - cachingRequest.asPromise = promisify(cachingRequest) - const result = handlerOptions.handler( filteredQueryParams, match, - // eslint-disable-next-line mocha/prefer-arrow-callback function sendBadge(format, badgeData) { if (serverUnresponsive) { return @@ -188,9 +125,7 @@ function handleRequest(cacheHeaderConfig, handlerOptions) { setCacheHeadersOnResponse(ask.res, badgeData.cacheLengthSeconds) makeSend(format, ask.res, end)(svg) }, - cachingRequest ) - // eslint-disable-next-line promise/prefer-await-to-then if (result && result.catch) { // eslint-disable-next-line promise/prefer-await-to-then result.catch(err => { @@ -200,4 +135,4 @@ function handleRequest(cacheHeaderConfig, handlerOptions) { } } -export { handleRequest, promisify, userAgent } +export { handleRequest } diff --git a/core/base-service/legacy-request-handler.spec.js b/core/base-service/legacy-request-handler.spec.js index 1ea498f72a2f2..7bdd8e897399e 100644 --- a/core/base-service/legacy-request-handler.spec.js +++ b/core/base-service/legacy-request-handler.spec.js @@ -1,5 +1,4 @@ import { expect } from 'chai' -import nock from 'nock' import portfinder from 'portfinder' import Camp from '@shields_io/camp' import got from '../got-test-client.js' @@ -19,7 +18,7 @@ function fakeHandler(queryParams, match, sendBadge, request) { label: 'testing', message: someValue, }, - {} + {}, ) sendBadge(format, badgeData) } @@ -36,32 +35,10 @@ function createFakeHandlerWithCacheLength(cacheLengthSeconds) { {}, { _cacheLength: cacheLengthSeconds, - } - ) - sendBadge(format, badgeData) - } -} - -function fakeHandlerWithNetworkIo(queryParams, match, sendBadge, request) { - const [, someValue, format] = match - request('https://www.google.com/foo/bar', (err, res, buffer) => { - let message - if (err) { - message = err.prettyMessage - } else { - message = someValue - } - const badgeData = coalesceBadge( - queryParams, - { - label: 'testing', - message, - format, }, - {} ) sendBadge(format, badgeData) - }) + } } describe('The request handler', function () { @@ -89,7 +66,7 @@ describe('The request handler', function () { beforeEach(function () { camp.route( /^\/testing\/([^/]+)\.(svg|png|gif|jpg|json)$/, - handleRequest(standardCacheHeaders, { handler: fakeHandler }) + handleRequest(standardCacheHeaders, { handler: fakeHandler }), ) }) @@ -113,7 +90,7 @@ describe('The request handler', function () { beforeEach(function () { camp.route( /^\/testing\/([^/]+)\.(svg|png|gif|jpg|json)$/, - handleRequest(standardCacheHeaders, fakeHandler) + handleRequest(standardCacheHeaders, fakeHandler), ) }) @@ -133,60 +110,6 @@ describe('The request handler', function () { }) }) - describe('the response size limit', function () { - beforeEach(function () { - camp.route( - /^\/testing\/([^/]+)\.(svg|png|gif|jpg|json)$/, - handleRequest(standardCacheHeaders, { - handler: fakeHandlerWithNetworkIo, - fetchLimitBytes: 100, - }) - ) - }) - - it('should not throw an error if the response <= fetchLimitBytes', async function () { - nock('https://www.google.com') - .get('/foo/bar') - .once() - .reply(200, 'x'.repeat(100)) - const { statusCode, body } = await got(`${baseUrl}/testing/123.json`, { - responseType: 'json', - }) - expect(statusCode).to.equal(200) - expect(body).to.deep.equal({ - name: 'testing', - value: '123', - label: 'testing', - message: '123', - color: 'lightgrey', - link: [], - }) - }) - - it('should throw an error if the response is > fetchLimitBytes', async function () { - nock('https://www.google.com') - .get('/foo/bar') - .once() - .reply(200, 'x'.repeat(101)) - const { statusCode, body } = await got(`${baseUrl}/testing/123.json`, { - responseType: 'json', - }) - expect(statusCode).to.equal(200) - expect(body).to.deep.equal({ - name: 'testing', - value: 'Maximum response size exceeded', - label: 'testing', - message: 'Maximum response size exceeded', - color: 'lightgrey', - link: [], - }) - }) - - afterEach(function () { - nock.cleanAll() - }) - }) - describe('caching', function () { describe('standard query parameters', function () { function register({ cacheHeaderConfig }) { @@ -196,8 +119,8 @@ describe('The request handler', function () { cacheHeaderConfig, (queryParams, match, sendBadge, request) => { fakeHandler(queryParams, match, sendBadge, request) - } - ) + }, + ), ) } @@ -205,7 +128,7 @@ describe('The request handler', function () { register({ cacheHeaderConfig: { defaultCacheLengthSeconds: 900 } }) const { headers } = await got(`${baseUrl}/testing/123.json`) const expectedExpiry = new Date( - +new Date(headers.date) + 900000 + +new Date(headers.date) + 900000, ).toGMTString() expect(headers.expires).to.equal(expectedExpiry) expect(headers['cache-control']).to.equal('max-age=900, s-maxage=900') @@ -219,13 +142,13 @@ describe('The request handler', function () { const { headers } = await got(`${baseUrl}/testing/123.json`) const expectedExpiry = new Date( - +new Date(headers.date) + 900000 + +new Date(headers.date) + 900000, ).toGMTString() expect(headers.expires).to.equal(expectedExpiry) expect(headers['cache-control']).to.equal('max-age=900, s-maxage=900') }) - it('should let live service data override the default cache headers with longer value', async function () { + it('should allow serviceData to override the default cache headers with longer value', async function () { camp.route( /^\/testing\/([^/]+)\.(svg|png|gif|jpg|json)$/, handleRequest( @@ -235,17 +158,17 @@ describe('The request handler', function () { queryParams, match, sendBadge, - request + request, ) - } - ) + }, + ), ) const { headers } = await got(`${baseUrl}/testing/123.json`) expect(headers['cache-control']).to.equal('max-age=400, s-maxage=400') }) - it('should not let live service data override the default cache headers with shorter value', async function () { + it('should allow serviceData to override the default cache headers with shorter value', async function () { camp.route( /^\/testing\/([^/]+)\.(svg|png|gif|jpg|json)$/, handleRequest( @@ -255,23 +178,23 @@ describe('The request handler', function () { queryParams, match, sendBadge, - request + request, ) - } - ) + }, + ), ) const { headers } = await got(`${baseUrl}/testing/123.json`) - expect(headers['cache-control']).to.equal('max-age=300, s-maxage=300') + expect(headers['cache-control']).to.equal('max-age=200, s-maxage=200') }) it('should set the expires header to current time + cacheSeconds', async function () { register({ cacheHeaderConfig: { defaultCacheLengthSeconds: 0 } }) const { headers } = await got( - `${baseUrl}/testing/123.json?cacheSeconds=3600` + `${baseUrl}/testing/123.json?cacheSeconds=3600`, ) const expectedExpiry = new Date( - +new Date(headers.date) + 3600000 + +new Date(headers.date) + 3600000, ).toGMTString() expect(headers.expires).to.equal(expectedExpiry) expect(headers['cache-control']).to.equal('max-age=3600, s-maxage=3600') @@ -280,10 +203,10 @@ describe('The request handler', function () { it('should ignore cacheSeconds when shorter than defaultCacheLengthSeconds', async function () { register({ cacheHeaderConfig: { defaultCacheLengthSeconds: 600 } }) const { headers } = await got( - `${baseUrl}/testing/123.json?cacheSeconds=300` + `${baseUrl}/testing/123.json?cacheSeconds=300`, ) const expectedExpiry = new Date( - +new Date(headers.date) + 600000 + +new Date(headers.date) + 600000, ).toGMTString() expect(headers.expires).to.equal(expectedExpiry) expect(headers['cache-control']).to.equal('max-age=600, s-maxage=600') @@ -294,7 +217,7 @@ describe('The request handler', function () { const { headers } = await got(`${baseUrl}/testing/123.json`) expect(headers.expires).to.equal(headers.date) expect(headers['cache-control']).to.equal( - 'no-cache, no-store, must-revalidate' + 'no-cache, no-store, must-revalidate', ) }) }) @@ -311,7 +234,7 @@ describe('The request handler', function () { ++handlerCallCount fakeHandler(queryParams, match, sendBadge, request) }, - }) + }), ) }) @@ -319,7 +242,7 @@ describe('The request handler', function () { await performTwoRequests( baseUrl, '/testing/123.svg?foo=1', - '/testing/123.svg?foo=2' + '/testing/123.svg?foo=2', ) expect(handlerCallCount).to.equal(2) }) diff --git a/core/base-service/legacy-result-sender.js b/core/base-service/legacy-result-sender.js index 90ee870e4b9ca..6d2d030323e53 100644 --- a/core/base-service/legacy-result-sender.js +++ b/core/base-service/legacy-result-sender.js @@ -11,20 +11,28 @@ function streamFromString(str) { function sendSVG(res, askres, end) { askres.setHeader('Content-Type', 'image/svg+xml;charset=utf-8') + askres.setHeader('Content-Security-Policy', "script-src 'none';") + askres.setHeader('Content-Length', Buffer.byteLength(res, 'utf8')) end(null, { template: streamFromString(res) }) } function sendJSON(res, askres, end) { askres.setHeader('Content-Type', 'application/json') - askres.setHeader('Access-Control-Allow-Origin', '*') + askres.setHeader('Content-Length', Buffer.byteLength(res, 'utf8')) end(null, { template: streamFromString(res) }) } +function sendEmpty(end) { + end(null, { template: streamFromString('') }) +} + function makeSend(format, askres, end) { if (format === 'svg') { return res => sendSVG(res, askres, end) } else if (format === 'json') { return res => sendJSON(res, askres, end) + } else if (format === 'empty') { + return () => sendEmpty(end) } else { throw Error(`Unrecognized format: ${format}`) } diff --git a/core/base-service/loader-test-fixtures/invalid-mixed.fixture.js b/core/base-service/loader-test-fixtures/invalid-mixed.fixture.js index 40096a3065399..c8205270e91ad 100644 --- a/core/base-service/loader-test-fixtures/invalid-mixed.fixture.js +++ b/core/base-service/loader-test-fixtures/invalid-mixed.fixture.js @@ -1,10 +1,10 @@ import BaseJsonService from '../base-json.js' class BadBaseService {} -class GoodService extends BaseJsonService { +class GoodMixedService extends BaseJsonService { static category = 'build' static route = { base: 'it/is', pattern: 'good' } } -class BadService extends BadBaseService {} +class BadMixedService extends BadBaseService {} -export default [GoodService, BadService] +export default [GoodMixedService, BadMixedService] diff --git a/core/base-service/loader-test-fixtures/invalid-no-base.fixture.js b/core/base-service/loader-test-fixtures/invalid-no-base.fixture.js index 4651b17bfef86..f968a66a02179 100644 --- a/core/base-service/loader-test-fixtures/invalid-no-base.fixture.js +++ b/core/base-service/loader-test-fixtures/invalid-no-base.fixture.js @@ -1,3 +1,3 @@ -class BadService {} +class BadNoBaseService {} -export default BadService +export default BadNoBaseService diff --git a/core/base-service/loader-test-fixtures/invalid-wrong-base.fixture.js b/core/base-service/loader-test-fixtures/invalid-wrong-base.fixture.js index 9ca5e17cb45c9..d8e31c612a3da 100644 --- a/core/base-service/loader-test-fixtures/invalid-wrong-base.fixture.js +++ b/core/base-service/loader-test-fixtures/invalid-wrong-base.fixture.js @@ -1,4 +1,4 @@ class BadBaseService {} -class BadService extends BadBaseService {} +class BadChildService extends BadBaseService {} -export default BadService +export default BadChildService diff --git a/core/base-service/loader-test-fixtures/valid-array.fixture.js b/core/base-service/loader-test-fixtures/valid-array.fixture.js index c820022a8a9af..63f5d3c4ef874 100644 --- a/core/base-service/loader-test-fixtures/valid-array.fixture.js +++ b/core/base-service/loader-test-fixtures/valid-array.fixture.js @@ -1,12 +1,12 @@ import BaseJsonService from '../base-json.js' -class GoodServiceOne extends BaseJsonService { +class GoodServiceArrayOne extends BaseJsonService { static category = 'build' static route = { base: 'good', pattern: 'one' } } -class GoodServiceTwo extends BaseJsonService { +class GoodServiceArrayTwo extends BaseJsonService { static category = 'build' static route = { base: 'good', pattern: 'two' } } -export default [GoodServiceOne, GoodServiceTwo] +export default [GoodServiceArrayOne, GoodServiceArrayTwo] diff --git a/core/base-service/loader-test-fixtures/valid-object.fixture.js b/core/base-service/loader-test-fixtures/valid-object.fixture.js index 5a312db98f48c..900ea480f7423 100644 --- a/core/base-service/loader-test-fixtures/valid-object.fixture.js +++ b/core/base-service/loader-test-fixtures/valid-object.fixture.js @@ -1,12 +1,12 @@ import BaseJsonService from '../base-json.js' -class GoodServiceOne extends BaseJsonService { +class GoodServiceObjectOne extends BaseJsonService { static category = 'build' static route = { base: 'good', pattern: 'one' } } -class GoodServiceTwo extends BaseJsonService { +class GoodServiceObjectTwo extends BaseJsonService { static category = 'build' static route = { base: 'good', pattern: 'two' } } -export { GoodServiceOne, GoodServiceTwo } +export { GoodServiceObjectOne, GoodServiceObjectTwo } diff --git a/core/base-service/loader.js b/core/base-service/loader.js index cf499eca1560f..9b72355436299 100644 --- a/core/base-service/loader.js +++ b/core/base-service/loader.js @@ -1,6 +1,6 @@ import path from 'path' import { fileURLToPath } from 'url' -import glob from 'glob' +import { globSync } from 'glob' import countBy from 'lodash.countby' import categories from '../../services/categories.js' import BaseService from './base.js' @@ -10,9 +10,16 @@ const serviceDir = path.join( path.dirname(fileURLToPath(import.meta.url)), '..', '..', - 'services' + 'services', ) +function toUnixPath(path) { + // glob does not allow \ as a path separator + // see https://github.com/isaacs/node-glob/blob/main/changelog.md#80 + // so we need to convert to use / for use with glob + return path.replace(/\\/g, '/') +} + class InvalidService extends Error { constructor(message) { super(message) @@ -20,22 +27,38 @@ class InvalidService extends Error { } } +function getServicePaths(pattern) { + return globSync(toUnixPath(path.join(serviceDir, '**', pattern))).sort() +} + +function assertNamesUnique(names, { message }) { + const duplicates = {} + Object.entries(countBy(names)) + .filter(([name, count]) => count > 1) + .forEach(([name, count]) => { + duplicates[name] = count + }) + if (Object.keys(duplicates).length) { + throw new Error(`${message}: ${JSON.stringify(duplicates, undefined, 2)}`) + } +} + async function loadServiceClasses(servicePaths) { if (!servicePaths) { - servicePaths = glob.sync(path.join(serviceDir, '**', '*.service.js')) + servicePaths = getServicePaths('*.service.js') } const serviceClasses = [] for await (const servicePath of servicePaths) { const currentServiceClasses = Object.values( - await import(`file://${servicePath}`) + await import(`file://${servicePath}`), ).flatMap(element => - typeof element === 'object' ? Object.values(element) : element + typeof element === 'object' ? Object.values(element) : element, ) if (currentServiceClasses.length === 0) { throw new InvalidService( - `Expected ${servicePath} to export a service or a collection of services` + `Expected ${servicePath} to export a service or a collection of services`, ) } currentServiceClasses.forEach(serviceClass => { @@ -48,34 +71,31 @@ async function loadServiceClasses(servicePaths) { return serviceClasses.push(serviceClass) } throw new InvalidService( - `Expected ${servicePath} to export a service or a collection of services; one of them was ${serviceClass}` + `Expected ${servicePath} to export a service or a collection of services; one of them was ${serviceClass}`, ) }) } - return serviceClasses -} - -function assertNamesUnique(names, { message }) { - const duplicates = {} - Object.entries(countBy(names)) - .filter(([name, count]) => count > 1) - .forEach(([name, count]) => { - duplicates[name] = count - }) - if (Object.keys(duplicates).length) { - throw new Error(`${message}: ${JSON.stringify(duplicates, undefined, 2)}`) - } -} - -async function checkNames() { - const services = await loadServiceClasses() assertNamesUnique( - services.map(({ name }) => name), + serviceClasses.map(({ name }) => name), { message: 'Duplicate service names found', - } + }, ) + + const routeSummaries = [] + serviceClasses.forEach(function (serviceClass) { + if (serviceClass.openApi) { + for (const route of Object.values(serviceClass.openApi)) { + routeSummaries.push(route.get.summary) + } + } + }) + assertNamesUnique(routeSummaries, { + message: 'Duplicate route summary found', + }) + + return serviceClasses } async function collectDefinitions() { @@ -93,16 +113,16 @@ async function collectDefinitions() { async function loadTesters() { return Promise.all( - glob - .sync(path.join(serviceDir, '**', '*.tester.js')) - .map(async path => await import(`file://${path}`)) + getServicePaths('*.tester.js').map( + async path => await import(`file://${path}`), + ), ) } export { InvalidService, loadServiceClasses, - checkNames, + getServicePaths, collectDefinitions, loadTesters, } diff --git a/core/base-service/loader.spec.js b/core/base-service/loader.spec.js index e26c28fc1e0ec..5973acf4d766b 100644 --- a/core/base-service/loader.spec.js +++ b/core/base-service/loader.spec.js @@ -1,57 +1,66 @@ import path from 'path' import { fileURLToPath } from 'url' -import chai from 'chai' +import { expect, use } from 'chai' import chaiAsPromised from 'chai-as-promised' -import { loadServiceClasses, InvalidService } from './loader.js' -chai.use(chaiAsPromised) +import { + loadServiceClasses, + getServicePaths, + InvalidService, +} from './loader.js' +use(chaiAsPromised) -const { expect } = chai const fixturesDir = path.join( path.dirname(fileURLToPath(import.meta.url)), - 'loader-test-fixtures' + 'loader-test-fixtures', ) describe('loadServiceClasses function', function () { it('throws if module exports empty', async function () { await expect( - loadServiceClasses([path.join(fixturesDir, 'empty-undefined.fixture.js')]) + loadServiceClasses([ + path.join(fixturesDir, 'empty-undefined.fixture.js'), + ]), ).to.be.rejectedWith(InvalidService) await expect( - loadServiceClasses([path.join(fixturesDir, 'empty-array.fixture.js')]) + loadServiceClasses([path.join(fixturesDir, 'empty-array.fixture.js')]), ).to.be.rejectedWith(InvalidService) await expect( - loadServiceClasses([path.join(fixturesDir, 'empty-object.fixture.js')]) + loadServiceClasses([path.join(fixturesDir, 'empty-object.fixture.js')]), ).to.be.rejectedWith(InvalidService) await expect( - loadServiceClasses([path.join(fixturesDir, 'empty-no-export.fixture.js')]) + loadServiceClasses([ + path.join(fixturesDir, 'empty-no-export.fixture.js'), + ]), ).to.be.rejectedWith(InvalidService) await expect( loadServiceClasses([ path.join(fixturesDir, 'valid-array.fixture.js'), path.join(fixturesDir, 'valid-class.fixture.js'), path.join(fixturesDir, 'empty-array.fixture.js'), - ]) + ]), ).to.be.rejectedWith(InvalidService) }) it('throws if module exports invalid', async function () { await expect( - loadServiceClasses([path.join(fixturesDir, 'invalid-no-base.fixture.js')]) + loadServiceClasses([ + path.join(fixturesDir, 'invalid-no-base.fixture.js'), + ]), ).to.be.rejectedWith(InvalidService) await expect( loadServiceClasses([ path.join(fixturesDir, 'invalid-wrong-base.fixture.js'), - ]) + ]), ).to.be.rejectedWith(InvalidService) await expect( - loadServiceClasses([path.join(fixturesDir, 'invalid-mixed.fixture.js')]) + loadServiceClasses([path.join(fixturesDir, 'invalid-mixed.fixture.js')]), ).to.be.rejectedWith(InvalidService) await expect( loadServiceClasses([ path.join(fixturesDir, 'valid-array.fixture.js'), path.join(fixturesDir, 'valid-class.fixture.js'), path.join(fixturesDir, 'invalid-no-base.fixture.js'), - ]) + ]), ).to.be.rejectedWith(InvalidService) }) @@ -61,7 +70,19 @@ describe('loadServiceClasses function', function () { path.join(fixturesDir, 'valid-array.fixture.js'), path.join(fixturesDir, 'valid-object.fixture.js'), path.join(fixturesDir, 'valid-class.fixture.js'), - ]) + ]), ).to.eventually.have.length(5) }) }) + +describe('getServicePaths', function () { + // these tests just make sure we discover a + // plausibly large number of .service and .tester files + it('finds a non-zero number of services in the project', function () { + expect(getServicePaths('*.service.js')).to.have.length.above(400) + }) + + it('finds a non-zero number of testers in the project', function () { + expect(getServicePaths('*.tester.js')).to.have.length.above(400) + }) +}) diff --git a/core/base-service/openapi.js b/core/base-service/openapi.js new file mode 100644 index 0000000000000..8fb7bab4fee58 --- /dev/null +++ b/core/base-service/openapi.js @@ -0,0 +1,358 @@ +/** + * Functions for publishing the shields.io URL schema as an OpenAPI Document + * + * @module + */ + +const baseUrl = process.env.BASE_URL +const globalParamRefs = [ + { $ref: '#/components/parameters/style' }, + { $ref: '#/components/parameters/logo' }, + { $ref: '#/components/parameters/logoColor' }, + { $ref: '#/components/parameters/logoSize' }, + { $ref: '#/components/parameters/label' }, + { $ref: '#/components/parameters/labelColor' }, + { $ref: '#/components/parameters/color' }, + { $ref: '#/components/parameters/cacheSeconds' }, + { $ref: '#/components/parameters/link' }, +] + +function getCodeSamples(altText) { + return [ + { + lang: 'URL', + label: 'URL', + source: '$url', + }, + { + lang: 'Markdown', + label: 'Markdown', + source: `![${altText}]($url)`, + }, + { + lang: 'reStructuredText', + label: 'rSt', + source: `.. image:: $url\n :alt: ${altText}`, + }, + { + lang: 'AsciiDoc', + label: 'AsciiDoc', + source: `image:$url[${altText}]`, + }, + { + lang: 'HTML', + label: 'HTML', + source: `${altText}`, + }, + ] +} + +function getEnum(pattern, paramName) { + const re = new RegExp(`${paramName}\\(([A-Za-z0-9_\\-|]+)\\)`) + const match = pattern.match(re) + if (match === null) { + return undefined + } + if (!match[1].includes('|')) { + return undefined + } + return match[1].split('|') +} + +function addGlobalProperties(endpoints) { + const paths = {} + for (const key of Object.keys(endpoints)) { + paths[key] = endpoints[key] + paths[key].get.parameters = [ + ...paths[key].get.parameters, + ...globalParamRefs, + ] + paths[key].get['x-code-samples'] = getCodeSamples(paths[key].get.summary) + } + return paths +} + +function sortPaths(obj) { + const entries = Object.entries(obj) + entries.sort((a, b) => a[1].get.summary.localeCompare(b[1].get.summary)) + return Object.fromEntries(entries) +} + +function services2openapi(services, sort) { + const paths = {} + for (const service of services) { + for (const [key, value] of Object.entries( + addGlobalProperties(service.openApi), + )) { + if (key in paths) { + throw new Error(`Conflicting route: ${key}`) + } + paths[key] = value + } + } + return sort ? sortPaths(paths) : paths +} + +function category2openapi({ category, services, sort = false }) { + const spec = { + openapi: '3.0.0', + info: { + version: '1.0.0', + title: category.name, + license: { + name: 'CC0', + }, + }, + servers: baseUrl ? [{ url: baseUrl }] : undefined, + components: { + parameters: { + style: { + name: 'style', + in: 'query', + required: false, + description: `If not specified, the default style for this badge is "${ + category.name.toLowerCase() === 'social' ? 'social' : 'flat' + }".`, + schema: { + type: 'string', + enum: ['flat', 'flat-square', 'plastic', 'for-the-badge', 'social'], + }, + example: 'flat', + }, + logo: { + name: 'logo', + in: 'query', + required: false, + description: + 'Icon slug from simple-icons. You can click the icon title on simple-icons to copy the slug or they can be found in the slugs.md file in the simple-icons repository. Further info.', + schema: { + type: 'string', + }, + example: 'appveyor', + }, + logoColor: { + name: 'logoColor', + in: 'query', + required: false, + description: + 'The color of the logo (hex, rgb, rgba, hsl, hsla and css named colors supported). Supported for simple-icons logos but not for custom logos.', + schema: { + type: 'string', + }, + example: 'violet', + }, + logoSize: { + name: 'logoSize', + in: 'query', + required: false, + description: + 'Make icons adaptively resize by setting `auto`. Useful for some wider logos like `amd` and `amg`. Supported for simple-icons logos but not for custom logos.', + schema: { + type: 'string', + }, + example: 'auto', + }, + label: { + name: 'label', + in: 'query', + required: false, + description: + 'Override the default left-hand-side text (URL-Encoding needed for spaces or special characters!)', + schema: { + type: 'string', + }, + example: 'healthiness', + }, + labelColor: { + name: 'labelColor', + in: 'query', + required: false, + description: + 'Background color of the left part (hex, rgb, rgba, hsl, hsla and css named colors supported).', + schema: { + type: 'string', + }, + example: 'abcdef', + }, + color: { + name: 'color', + in: 'query', + required: false, + description: + 'Background color of the right part (hex, rgb, rgba, hsl, hsla and css named colors supported).', + schema: { + type: 'string', + }, + example: 'fedcba', + }, + cacheSeconds: { + name: 'cacheSeconds', + in: 'query', + required: false, + description: + 'HTTP cache lifetime (rules are applied to infer a default value on a per-badge basis, any values specified below the default will be ignored).', + schema: { + type: 'string', + }, + example: '3600', + }, + link: { + name: 'link', + in: 'query', + required: false, + description: + 'Specify what clicking on the left/right of a badge should do. Note that this only works when integrating your badge in an `` HTML tag, but not an `` tag or a markup language.', + style: 'form', + explode: true, + schema: { + type: 'array', + maxItems: 2, + items: { + type: 'string', + }, + }, + }, + }, + }, + paths: services2openapi(services, sort), + } + + return spec +} + +/** + * Helper function for assembling an OpenAPI path parameter object + * + * @param {PathParamInput} param Input param + * @returns {OpenApiParam} OpenAPI Parameter Object + * @see https://swagger.io/specification/#parameter-object + */ +function pathParam({ + name, + example, + schema = { type: 'string' }, + description, +}) { + return { name, in: 'path', required: true, schema, example, description } +} + +/** + * Helper function for assembling an array of OpenAPI path parameter objects + * The code + * ``` + * const params = pathParams( + * { name: 'name1', example: 'example1' }, + * { name: 'name2', example: 'example2' }, + * ) + * ``` + * is equivalent to + * ``` + * const params = [ + * pathParam({ name: 'name1', example: 'example1' }), + * pathParam({ name: 'name2', example: 'example2' }), + * ] + * ``` + * + * @param {...PathParamInput} params Input params + * @returns {Array.} Array of OpenAPI Parameter Objects + * @see {@link pathParam} + */ +function pathParams(...params) { + return params.map(param => pathParam(param)) +} + +/** + * Helper function for assembling an OpenAPI query parameter object + * + * @param {QueryParamInput} param Input param + * @returns {OpenApiParam} OpenAPI Parameter Object + * @see https://swagger.io/specification/#parameter-object + */ +function queryParam({ + name, + example, + schema = { type: 'string' }, + required = false, + description, +}) { + const param = { name, in: 'query', required, schema, example, description } + if (example === null && schema.type === 'boolean') { + param.allowEmptyValue = true + } + return param +} + +/** + * Helper function for assembling an array of OpenAPI query parameter objects + * The code + * ``` + * const params = queryParams( + * { name: 'name1', example: 'example1' }, + * { name: 'name2', example: 'example2' }, + * ) + * ``` + * is equivalent to + * ``` + * const params = [ + * queryParam({ name: 'name1', example: 'example1' }), + * queryParam({ name: 'name2', example: 'example2' }), + * ] + * ``` + * + * @param {...QueryParamInput} params Input params + * @returns {Array.} Array of OpenAPI Parameter Objects + * @see {@link queryParam} + */ +function queryParams(...params) { + return params.map(param => queryParam(param)) +} + +/** + * @typedef {object} PathParamInput + * @property {string} name The name of the parameter. Parameter names are case sensitive + * @property {string} example Example of a valid value for this parameter + * @property {object} [schema={ type: 'string' }] Parameter schema. + * An [OpenAPI Schema object](https://swagger.io/specification/#schema-object) + * specifying the parameter type. + * Normally this should be omitted as all path parameters are strings. + * Use this when we also want to pass an enum of valid parameters + * to be presented as a drop-down in the frontend. e.g: + * `{'type': 'string', 'enum': ['github', 'bitbucket'}` (Optional) + * @property {string} description A brief description of the parameter (Optional) + */ + +/** + * @typedef {object} QueryParamInput + * @property {string} name The name of the parameter. Parameter names are case sensitive + * @property {string|null} example Example of a valid value for this parameter + * @property {object} [schema={ type: 'string' }] Parameter schema. + * An [OpenAPI Schema object](https://swagger.io/specification/#schema-object) + * specifying the parameter type. This can normally be omitted. + * Query params are usually strings. (Optional) + * @property {boolean} [required=false] Determines whether this parameter is mandatory (Optional) + * @property {string} description A brief description of the parameter (Optional) + */ + +/** + * OpenAPI Parameter Object + * + * @typedef {object} OpenApiParam + * @property {string} name The name of the parameter + * @property {string|null} example Example of a valid value for this parameter + * @property {('path'|'query')} in The location of the parameter + * @property {object} schema Parameter schema. + * An [OpenAPI Schema object](https://swagger.io/specification/#schema-object) + * specifying the parameter type. + * @property {boolean} required Determines whether this parameter is mandatory + * @property {string} description A brief description of the parameter + * @property {boolean} allowEmptyValue If true, allows the ability to pass an empty value to this parameter + */ + +export { + category2openapi, + getEnum, + pathParam, + pathParams, + queryParam, + queryParams, +} diff --git a/core/base-service/openapi.spec.js b/core/base-service/openapi.spec.js new file mode 100644 index 0000000000000..7700125ba975b --- /dev/null +++ b/core/base-service/openapi.spec.js @@ -0,0 +1,424 @@ +import { expect } from 'chai' +import { + category2openapi, + pathParam, + pathParams, + queryParam, + queryParams, +} from './openapi.js' +import BaseJsonService from './base-json.js' + +class OpenApiService extends BaseJsonService { + static category = 'build' + static route = { base: 'openapi/service', pattern: ':packageName/:distTag*' } + + // this service defines its own API Paths Object + static openApi = { + '/openapi/service/{packageName}': { + get: { + summary: 'OpenApiService Summary', + description: 'OpenApiService Description', + parameters: [ + { + name: 'packageName', + description: 'packageName description', + in: 'path', + required: true, + schema: { type: 'string' }, + example: 'badge-maker', + }, + ], + }, + }, + '/openapi/service/{packageName}/{distTag}': { + get: { + summary: 'OpenApiService Summary (with Tag)', + description: 'OpenApiService Description (with Tag)', + parameters: [ + { + name: 'packageName', + description: 'packageName description', + in: 'path', + required: true, + schema: { type: 'string' }, + example: 'badge-maker', + }, + { + name: 'distTag', + description: 'distTag description', + in: 'path', + required: true, + schema: { type: 'string' }, + example: 'latest', + }, + ], + }, + }, + } +} + +const expected = { + openapi: '3.0.0', + info: { version: '1.0.0', title: 'build', license: { name: 'CC0' } }, + components: { + parameters: { + style: { + name: 'style', + in: 'query', + required: false, + description: + 'If not specified, the default style for this badge is "flat".', + schema: { + enum: ['flat', 'flat-square', 'plastic', 'for-the-badge', 'social'], + type: 'string', + }, + example: 'flat', + }, + logo: { + name: 'logo', + in: 'query', + required: false, + description: + 'Icon slug from simple-icons. You can click the icon title on simple-icons to copy the slug or they can be found in the slugs.md file in the simple-icons repository. Further info.', + schema: { type: 'string' }, + example: 'appveyor', + }, + logoColor: { + name: 'logoColor', + in: 'query', + required: false, + description: + 'The color of the logo (hex, rgb, rgba, hsl, hsla and css named colors supported). Supported for simple-icons logos but not for custom logos.', + schema: { type: 'string' }, + example: 'violet', + }, + logoSize: { + name: 'logoSize', + in: 'query', + required: false, + description: + 'Make icons adaptively resize by setting `auto`. Useful for some wider logos like `amd` and `amg`. Supported for simple-icons logos but not for custom logos.', + schema: { + type: 'string', + }, + example: 'auto', + }, + label: { + name: 'label', + in: 'query', + required: false, + description: + 'Override the default left-hand-side text (URL-Encoding needed for spaces or special characters!)', + schema: { type: 'string' }, + example: 'healthiness', + }, + labelColor: { + name: 'labelColor', + in: 'query', + required: false, + description: + 'Background color of the left part (hex, rgb, rgba, hsl, hsla and css named colors supported).', + schema: { type: 'string' }, + example: 'abcdef', + }, + color: { + name: 'color', + in: 'query', + required: false, + description: + 'Background color of the right part (hex, rgb, rgba, hsl, hsla and css named colors supported).', + schema: { type: 'string' }, + example: 'fedcba', + }, + cacheSeconds: { + name: 'cacheSeconds', + in: 'query', + required: false, + description: + 'HTTP cache lifetime (rules are applied to infer a default value on a per-badge basis, any values specified below the default will be ignored).', + schema: { type: 'string' }, + example: '3600', + }, + link: { + name: 'link', + in: 'query', + required: false, + description: + 'Specify what clicking on the left/right of a badge should do. Note that this only works when integrating your badge in an `` HTML tag, but not an `` tag or a markup language.', + style: 'form', + explode: true, + schema: { type: 'array', maxItems: 2, items: { type: 'string' } }, + }, + }, + }, + paths: { + '/openapi/service/{packageName}': { + get: { + summary: 'OpenApiService Summary', + description: 'OpenApiService Description', + parameters: [ + { + name: 'packageName', + description: 'packageName description', + in: 'path', + required: true, + schema: { type: 'string' }, + example: 'badge-maker', + }, + { $ref: '#/components/parameters/style' }, + { $ref: '#/components/parameters/logo' }, + { $ref: '#/components/parameters/logoColor' }, + { $ref: '#/components/parameters/logoSize' }, + { $ref: '#/components/parameters/label' }, + { $ref: '#/components/parameters/labelColor' }, + { $ref: '#/components/parameters/color' }, + { $ref: '#/components/parameters/cacheSeconds' }, + { $ref: '#/components/parameters/link' }, + ], + 'x-code-samples': [ + { lang: 'URL', label: 'URL', source: '$url' }, + { + lang: 'Markdown', + label: 'Markdown', + source: '![OpenApiService Summary]($url)', + }, + { + lang: 'reStructuredText', + label: 'rSt', + source: '.. image:: $url\n :alt: OpenApiService Summary', + }, + { + lang: 'AsciiDoc', + label: 'AsciiDoc', + source: 'image:$url[OpenApiService Summary]', + }, + { + lang: 'HTML', + label: 'HTML', + source: 'OpenApiService Summary', + }, + ], + }, + }, + '/openapi/service/{packageName}/{distTag}': { + get: { + summary: 'OpenApiService Summary (with Tag)', + description: 'OpenApiService Description (with Tag)', + parameters: [ + { + name: 'packageName', + description: 'packageName description', + in: 'path', + required: true, + schema: { type: 'string' }, + example: 'badge-maker', + }, + { + name: 'distTag', + description: 'distTag description', + in: 'path', + required: true, + schema: { type: 'string' }, + example: 'latest', + }, + { $ref: '#/components/parameters/style' }, + { $ref: '#/components/parameters/logo' }, + { $ref: '#/components/parameters/logoColor' }, + { $ref: '#/components/parameters/logoSize' }, + { $ref: '#/components/parameters/label' }, + { $ref: '#/components/parameters/labelColor' }, + { $ref: '#/components/parameters/color' }, + { $ref: '#/components/parameters/cacheSeconds' }, + { $ref: '#/components/parameters/link' }, + ], + 'x-code-samples': [ + { lang: 'URL', label: 'URL', source: '$url' }, + { + lang: 'Markdown', + label: 'Markdown', + source: '![OpenApiService Summary (with Tag)]($url)', + }, + { + lang: 'reStructuredText', + label: 'rSt', + source: + '.. image:: $url\n :alt: OpenApiService Summary (with Tag)', + }, + { + lang: 'AsciiDoc', + label: 'AsciiDoc', + source: 'image:$url[OpenApiService Summary (with Tag)]', + }, + { + lang: 'HTML', + label: 'HTML', + source: 'OpenApiService Summary (with Tag)', + }, + ], + }, + }, + }, +} + +function clean(obj) { + // remove any undefined values in the object + return JSON.parse(JSON.stringify(obj)) +} + +describe('category2openapi', function () { + it('generates an Open API spec', function () { + expect( + clean( + category2openapi({ + category: { name: 'build' }, + services: [OpenApiService.getDefinition()], + }), + ), + ).to.deep.equal(expected) + }) +}) + +describe('pathParam, pathParams', function () { + it('generates a pathParam with defaults', function () { + const input = { name: 'name', example: 'example' } + const expected = { + name: 'name', + in: 'path', + required: true, + schema: { + type: 'string', + }, + example: 'example', + description: undefined, + } + expect(pathParam(input)).to.deep.equal(expected) + expect(pathParams(input)[0]).to.deep.equal(expected) + }) + + it('generates a pathParam with custom args', function () { + const input = { + name: 'name', + example: true, + schema: { type: 'boolean' }, + description: 'long desc', + } + const expected = { + name: 'name', + in: 'path', + required: true, + schema: { + type: 'boolean', + }, + example: true, + description: 'long desc', + } + expect(pathParam(input)).to.deep.equal(expected) + expect(pathParams(input)[0]).to.deep.equal(expected) + }) + + it('generates multiple pathParams', function () { + expect( + pathParams( + { name: 'name1', example: 'example1' }, + { name: 'name2', example: 'example2' }, + ), + ).to.deep.equal([ + { + name: 'name1', + in: 'path', + required: true, + schema: { + type: 'string', + }, + example: 'example1', + description: undefined, + }, + { + name: 'name2', + in: 'path', + required: true, + schema: { + type: 'string', + }, + example: 'example2', + description: undefined, + }, + ]) + }) +}) + +describe('queryParam, queryParams', function () { + it('generates a queryParam with defaults', function () { + const input = { name: 'name', example: 'example' } + const expected = { + name: 'name', + in: 'query', + required: false, + schema: { type: 'string' }, + example: 'example', + description: undefined, + } + expect(queryParam(input)).to.deep.equal(expected) + expect(queryParams(input)[0]).to.deep.equal(expected) + }) + + it('generates queryParam with custom args', function () { + const input = { + name: 'name', + example: 'example', + required: true, + description: 'long desc', + } + const expected = { + name: 'name', + in: 'query', + required: true, + schema: { type: 'string' }, + example: 'example', + description: 'long desc', + } + expect(queryParam(input)).to.deep.equal(expected) + expect(queryParams(input)[0]).to.deep.equal(expected) + }) + + it('generates a queryParam with boolean/null example', function () { + const input = { name: 'name', example: null, schema: { type: 'boolean' } } + const expected = { + name: 'name', + in: 'query', + required: false, + schema: { type: 'boolean' }, + allowEmptyValue: true, + example: null, + description: undefined, + } + expect(queryParam(input)).to.deep.equal(expected) + expect(queryParams(input)[0]).to.deep.equal(expected) + }) + + it('generates multiple queryParams', function () { + expect( + queryParams( + { name: 'name1', example: 'example1' }, + { name: 'name2', example: 'example2' }, + ), + ).to.deep.equal([ + { + name: 'name1', + in: 'query', + required: false, + schema: { type: 'string' }, + example: 'example1', + description: undefined, + }, + { + name: 'name2', + in: 'query', + required: false, + schema: { type: 'string' }, + example: 'example2', + description: undefined, + }, + ]) + }) +}) diff --git a/core/base-service/redirector.js b/core/base-service/redirector.js index 094e3af07f485..8bcc4f12d7d81 100644 --- a/core/base-service/redirector.js +++ b/core/base-service/redirector.js @@ -1,7 +1,7 @@ import camelcase from 'camelcase' import emojic from 'emojic' import Joi from 'joi' -import queryString from 'query-string' +import qs from 'qs' import BaseService from './base.js' import { serverHasBeenUpSinceResourceCached, @@ -10,20 +10,21 @@ import { import { isValidCategory } from './categories.js' import { MetricHelper } from './metric-helper.js' import { isValidRoute, prepareRoute, namedParamsForMatch } from './route.js' +import { openApiSchema } from './service-definitions.js' import trace from './trace.js' const attrSchema = Joi.object({ name: Joi.string().min(3), category: isValidCategory, - isDeprecated: Joi.boolean().default(true), + isRetired: Joi.boolean().default(true), route: isValidRoute, - examples: Joi.array().has(Joi.object()).default([]), + openApi: openApiSchema, transformPath: Joi.func() .maxArity(1) .required() .error( () => - '"transformPath" must be a function that transforms named params to a new path' + '"transformPath" must be a function that transforms named params to a new path', ), transformQueryParams: Joi.func().arity(1), dateAdded: Joi.date().required(), @@ -34,9 +35,9 @@ export default function redirector(attrs) { const { name, category, - isDeprecated, + isRetired, route, - examples, + openApi, transformPath, transformQueryParams, overrideTransformedQueryParams, @@ -50,9 +51,9 @@ export default function redirector(attrs) { })}Redirect` static category = category - static isDeprecated = isDeprecated + static isRetired = isRetired static route = route - static examples = examples + static openApi = openApi static register({ camp, metricInstance }, { rasterUrl }) { const { regex, captureNames } = prepareRoute({ @@ -80,7 +81,7 @@ export default function redirector(attrs) { 'inbound', emojic.arrowHeadingUp, 'Redirector', - route.base + route.base, ) trace.logTrace('inbound', emojic.ticket, 'Named params', namedParams) trace.logTrace('inbound', emojic.crayon, 'Query params', queryParams) @@ -91,12 +92,16 @@ export default function redirector(attrs) { let urlSuffix = ask.uri.search || '' if (transformQueryParams) { - const specifiedParams = queryString.parse(urlSuffix) + const specifiedParams = qs.parse(urlSuffix, { + ignoreQueryPrefix: true, + }) const transformedParams = transformQueryParams(namedParams) const redirectParams = overrideTransformedQueryParams ? Object.assign(transformedParams, specifiedParams) : Object.assign(specifiedParams, transformedParams) - const outQueryString = queryString.stringify(redirectParams) + const outQueryString = qs.stringify(redirectParams, { + strictNullHandling: true, + }) urlSuffix = `?${outQueryString}` } @@ -110,8 +115,12 @@ export default function redirector(attrs) { ask.res.statusCode = 301 ask.res.setHeader('Location', redirectUrl) - // To avoid caching mistakes for a long time, and to make this simpler - // to reason about, use the same cache semantics as the static badge. + /* To avoid caching mistakes forever + (in the absence of cache control directives that specify otherwise, + 301 redirects are cached without any expiry date) + and to make this simpler to reason about, + use the same cache semantics as the static badge. + */ setCacheHeadersForStaticResource(ask.res) ask.res.end() diff --git a/core/base-service/redirector.spec.js b/core/base-service/redirector.spec.js index 4b1912d2305e4..0273d85ee3f30 100644 --- a/core/base-service/redirector.spec.js +++ b/core/base-service/redirector.spec.js @@ -14,8 +14,8 @@ describe('Redirector', function () { const dateAdded = new Date() const attrs = { category, route, transformPath, dateAdded } - it('returns true on isDeprecated', function () { - expect(redirector(attrs).isDeprecated).to.be.true + it('returns true on isRetired', function () { + expect(redirector(attrs).isRetired).to.be.true }) it('has the expected name', function () { @@ -27,7 +27,7 @@ describe('Redirector', function () { redirector({ ...attrs, name: 'ShinyRedirect', - }).name + }).name, ).to.equal('ShinyRedirect') }) @@ -41,28 +41,10 @@ describe('Redirector', function () { it('throws the expected error when dateAdded is missing', function () { expect(() => - redirector({ route, category, transformPath }).validateDefinition() + redirector({ route, category, transformPath }).validateDefinition(), ).to.throw('"dateAdded" is required') }) - it('sets specified example', function () { - const examples = [ - { - title: 'very old service', - pattern: ':namedParamA', - namedParams: { - namedParamA: 'namedParamAValue', - }, - staticPreview: { - label: 'service', - message: 'v0.14.0', - color: 'blue', - }, - }, - ] - expect(redirector({ ...attrs, examples }).examples).to.equal(examples) - }) - describe('ScoutCamp integration', function () { let port, baseUrl beforeEach(async function () { @@ -93,7 +75,7 @@ describe('Redirector', function () { }) ServiceClass.register( { camp }, - { rasterUrl: 'http://raster.example.test' } + { rasterUrl: 'http://raster.example.test' }, ) }) @@ -102,7 +84,7 @@ describe('Redirector', function () { `${baseUrl}/very/old/service/hello-world.svg`, { followRedirect: false, - } + }, ) expect(statusCode).to.equal(301) @@ -114,12 +96,12 @@ describe('Redirector', function () { `${baseUrl}/very/old/service/hello-world.png`, { followRedirect: false, - } + }, ) expect(statusCode).to.equal(301) expect(headers.location).to.equal( - 'http://raster.example.test/new/service/hello-world.png' + 'http://raster.example.test/new/service/hello-world.png', ) }) @@ -128,12 +110,12 @@ describe('Redirector', function () { `${baseUrl}/very/old/service/hello-world.svg?color=123&style=flat-square`, { followRedirect: false, - } + }, ) expect(statusCode).to.equal(301) expect(headers.location).to.equal( - '/new/service/hello-world.svg?color=123&style=flat-square' + '/new/service/hello-world.svg?color=123&style=flat-square', ) }) @@ -142,12 +124,12 @@ describe('Redirector', function () { `${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' + '/new/service/hello%0Dworld.svg?foobar=a%0Db', ) }) @@ -174,12 +156,12 @@ describe('Redirector', function () { `${baseUrl}/another/old/service/token/abc123/hello-world.svg`, { followRedirect: false, - } + }, ) expect(statusCode).to.equal(301) expect(headers.location).to.equal( - '/new/service/hello-world.svg?token=abc123' + '/new/service/hello-world.svg?token=abc123', ) }) @@ -188,12 +170,12 @@ describe('Redirector', function () { `${baseUrl}/another/old/service/token/abc123/hello-world.svg?color=123&style=flat-square`, { followRedirect: false, - } + }, ) expect(statusCode).to.equal(301) expect(headers.location).to.equal( - '/new/service/hello-world.svg?color=123&style=flat-square&token=abc123' + '/new/service/hello-world.svg?color=123&style=flat-square&token=abc123', ) }) @@ -202,12 +184,12 @@ describe('Redirector', function () { `${baseUrl}/another/old/service/token/abc123/hello-world.svg?color=123&style=flat-square&token=def456`, { followRedirect: false, - } + }, ) expect(statusCode).to.equal(301) expect(headers.location).to.equal( - '/new/service/hello-world.svg?color=123&style=flat-square&token=abc123' + '/new/service/hello-world.svg?color=123&style=flat-square&token=abc123', ) }) @@ -229,12 +211,12 @@ describe('Redirector', function () { `${baseUrl}/override/service/token/abc123/hello-world.svg?style=flat-square&token=def456`, { followRedirect: false, - } + }, ) expect(statusCode).to.equal(301) expect(headers.location).to.equal( - '/new/service/hello-world.svg?style=flat-square&token=def456' + '/new/service/hello-world.svg?token=def456&style=flat-square', ) }) }) diff --git a/core/base-service/resource-cache.js b/core/base-service/resource-cache.js new file mode 100644 index 0000000000000..3e78935e74cf6 --- /dev/null +++ b/core/base-service/resource-cache.js @@ -0,0 +1,69 @@ +/** + * @module + */ + +import { InvalidResponse } from './errors.js' +import { fetch } from './got.js' +import checkErrorResponse from './check-error-response.js' + +const oneDay = 24 * 3600 * 1000 // 1 day in milliseconds + +// Map from URL to { timestamp: last fetch time, data: data }. +let resourceCache = Object.create(null) + +/** + * Make a HTTP request using an in-memory cache + * + * @async + * @param {object} attrs - Refer to individual attrs + * @param {string} attrs.url - URL to request + * @param {number} attrs.ttl - Number of milliseconds to keep cached value for + * @param {boolean} [attrs.json=true] - True if we expect to parse the response as JSON + * @param {Function} [attrs.scraper=buffer => buffer] - Function to extract value from the response + * @param {object} [attrs.options={}] - Options to pass to got + * @param {Function} [attrs.requestFetcher=fetch] - Custom fetch function + * @throws {InvalidResponse} - Error if unable to parse response + * @returns {Promise<*>} Promise that resolves to parsed response + */ +async function getCachedResource({ + url, + ttl = oneDay, + json = true, + scraper = buffer => buffer, + options = {}, + requestFetcher = fetch, +}) { + const timestamp = Date.now() + const cached = resourceCache[url] + if (cached != null && timestamp - cached.timestamp < ttl) { + return cached.data + } + + const { buffer } = await checkErrorResponse({})( + await requestFetcher(url, options), + ) + + let reqData + if (json) { + try { + reqData = JSON.parse(buffer) + } catch (e) { + throw new InvalidResponse({ + prettyMessage: 'unparseable intermediate json response', + underlyingError: e, + }) + } + } else { + reqData = buffer + } + + const data = scraper(reqData) + resourceCache[url] = { timestamp, data } + return data +} + +function clearResourceCache() { + resourceCache = Object.create(null) +} + +export { getCachedResource, clearResourceCache } diff --git a/core/base-service/resource-cache.spec.js b/core/base-service/resource-cache.spec.js new file mode 100644 index 0000000000000..4344c458af33a --- /dev/null +++ b/core/base-service/resource-cache.spec.js @@ -0,0 +1,47 @@ +import { expect } from 'chai' +import nock from 'nock' +import { getCachedResource, clearResourceCache } from './resource-cache.js' + +describe('Resource Cache', function () { + beforeEach(function () { + clearResourceCache() + }) + + it('should use cached response if valid', async function () { + let resp + + nock('https://www.foobar.com').get('/baz').reply(200, { value: 1 }) + resp = await getCachedResource({ url: 'https://www.foobar.com/baz' }) + expect(resp).to.deep.equal({ value: 1 }) + expect(nock.isDone()).to.equal(true) + nock.cleanAll() + + nock('https://www.foobar.com').get('/baz').reply(200, { value: 2 }) + resp = await getCachedResource({ url: 'https://www.foobar.com/baz' }) + expect(resp).to.deep.equal({ value: 1 }) + expect(nock.isDone()).to.equal(false) + nock.cleanAll() + }) + + it('should not use cached response if expired', async function () { + let resp + + nock('https://www.foobar.com').get('/baz').reply(200, { value: 1 }) + resp = await getCachedResource({ + url: 'https://www.foobar.com/baz', + ttl: 1, + }) + expect(resp).to.deep.equal({ value: 1 }) + expect(nock.isDone()).to.equal(true) + nock.cleanAll() + + nock('https://www.foobar.com').get('/baz').reply(200, { value: 2 }) + resp = await getCachedResource({ + url: 'https://www.foobar.com/baz', + ttl: 1, + }) + expect(resp).to.deep.equal({ value: 2 }) + expect(nock.isDone()).to.equal(true) + nock.cleanAll() + }) +}) diff --git a/core/base-service/retired-service.js b/core/base-service/retired-service.js new file mode 100644 index 0000000000000..667afe1ef4048 --- /dev/null +++ b/core/base-service/retired-service.js @@ -0,0 +1,54 @@ +import Joi from 'joi' +import camelcase from 'camelcase' +import BaseService from './base.js' +import { isValidCategory } from './categories.js' +import { isValidRoute } from './route.js' + +const attrSchema = Joi.object({ + route: isValidRoute, + name: Joi.string(), + label: Joi.string(), + category: isValidCategory, + dateAdded: Joi.date().required(), + issueUrl: Joi.string().uri(), +}).required() + +function retiredService(attrs) { + const { route, name, label, category, issueUrl } = Joi.attempt( + attrs, + attrSchema, + `Retired service for ${attrs.route.base}`, + ) + + return class RetiredService extends BaseService { + static name = name + ? `Retired${name}` + : `Retired${camelcase(route.base.replace(/\//g, '_'), { + pascalCase: true, + })}` + + static category = category + static isRetired = true + static route = route + static _cacheLength = 86400 + static defaultBadgeData = { + label, + // When an issue URL is provided, render the badge in red to alert the user that an upgrade action is required. + color: issueUrl ? 'red' : 'lightgray', + } + + async handle() { + if (issueUrl) { + return { + message: issueUrl, + link: [issueUrl], + } + } + return { + message: 'retired badge', + } + } + } +} + +export default retiredService diff --git a/core/base-service/retired-service.spec.js b/core/base-service/retired-service.spec.js new file mode 100644 index 0000000000000..c48d26c20b546 --- /dev/null +++ b/core/base-service/retired-service.spec.js @@ -0,0 +1,62 @@ +import { expect } from 'chai' +import retiredService from './retired-service.js' + +describe('RetiredService', function () { + const route = { + base: 'service/that/no/longer/exists', + pattern: ':various+', + } + const category = 'analysis' + const dateAdded = new Date() + const commonAttrs = { route, category, dateAdded } + + it('returns true on isRetired', function () { + const service = retiredService({ ...commonAttrs }) + expect(service.isRetired).to.be.true + }) + + it('has the expected name', function () { + const service = retiredService({ ...commonAttrs }) + expect(service.name).to.equal('RetiredServiceThatNoLongerExists') + }) + + it('sets specified route', function () { + const service = retiredService({ ...commonAttrs }) + expect(service.route).to.deep.equal(route) + }) + + it('sets specified label', function () { + const label = 'coverity' + const service = retiredService({ ...commonAttrs, label }) + expect(service.defaultBadgeData.label).to.equal(label) + }) + + it('sets specified category', function () { + const service = retiredService({ ...commonAttrs }) + expect(service.category).to.equal(category) + }) + + it('sets default deprecation message', async function () { + const service = retiredService({ ...commonAttrs }) + expect(await service.invoke()).to.deep.equal({ + message: 'retired badge', + }) + }) + + it('sets default deprecation color', async function () { + const service = retiredService({ ...commonAttrs }) + expect(service.defaultBadgeData.color).to.equal('lightgray') + }) + + it('sets specified issue URL and sets red color', async function () { + const service = retiredService({ + ...commonAttrs, + issueUrl: 'https://github.com/badges/shields/issues/8671', + }) + expect(service.defaultBadgeData.color).to.equal('red') + expect(await service.invoke()).to.deep.equal({ + message: 'https://github.com/badges/shields/issues/8671', + link: ['https://github.com/badges/shields/issues/8671'], + }) + }) +}) diff --git a/core/base-service/route.js b/core/base-service/route.js index 24a7801ed1479..7c497fc872eac 100644 --- a/core/base-service/route.js +++ b/core/base-service/route.js @@ -52,7 +52,7 @@ function namedParamsForMatch(captureNames = [], match, ServiceClass) { if (captureNames.length !== captures.length) { throw new Error( `Service ${ServiceClass.name} declares incorrect number of named params ` + - `(expected ${captures.length}, got ${captureNames.length})` + `(expected ${captures.length}, got ${captureNames.length})`, ) } diff --git a/core/base-service/route.spec.js b/core/base-service/route.spec.js index 42326d8745d8b..932a9f1da25ae 100644 --- a/core/base-service/route.spec.js +++ b/core/base-service/route.spec.js @@ -86,9 +86,9 @@ describe('Route helpers', function () { expect(() => namedParamsForMatch(captureNames, regex.exec('/foo/bar/baz.svg'), { name: 'MyService', - }) + }), ).to.throw( - 'Service MyService declares incorrect number of named params (expected 2, got 1)' + 'Service MyService declares incorrect number of named params (expected 2, got 1)', ) }) @@ -96,14 +96,14 @@ describe('Route helpers', function () { expect( getQueryParamNames({ queryParamSchema: Joi.object({ foo: Joi.string() }).required(), - }) + }), ).to.deep.equal(['foo']) expect( getQueryParamNames({ queryParamSchema: Joi.object({ foo: Joi.string() }) .rename('bar', 'foo', { ignoreUndefined: true, override: true }) .required(), - }) + }), ).to.deep.equal(['foo', 'bar']) }) }) diff --git a/core/base-service/service-definitions.js b/core/base-service/service-definitions.js index 1d66f4e91446c..c5730ec2a66d8 100644 --- a/core/base-service/service-definitions.js +++ b/core/base-service/service-definitions.js @@ -1,18 +1,49 @@ +/** + * @module + */ import Joi from 'joi' -// This should be kept in sync with the schema in -// `frontend/lib/service-definitions/index.ts`. - const arrayOfStrings = Joi.array().items(Joi.string()).min(0).required() -const objectOfKeyValues = Joi.object() - .pattern(/./, Joi.string().allow(null)) - .required() +/** + * Joi schema describing the subset of OpenAPI paths we use in this application + * + * @typedef {object} openApiSchema + * @see https://swagger.io/specification/#paths-object + */ +const openApiSchema = Joi.object() + .pattern( + /./, + Joi.object({ + get: Joi.object({ + summary: Joi.string().required(), + description: Joi.string(), + parameters: Joi.array() + .items( + Joi.object({ + name: Joi.string().required(), + description: Joi.string(), + in: Joi.string().valid('query', 'path').required(), + required: Joi.boolean().required(), + schema: Joi.object({ + type: Joi.string().required(), + enum: Joi.array(), + }).required(), + allowEmptyValue: Joi.boolean(), + example: Joi.string().allow(null), + }), + ) + .min(1) + .required(), + }).required(), + }).required(), + ) + .default({}) const serviceDefinition = Joi.object({ category: Joi.string().required(), name: Joi.string().required(), - isDeprecated: Joi.boolean().required(), + isRetired: Joi.boolean().required(), route: Joi.alternatives().try( Joi.object({ pattern: Joi.string().required(), @@ -21,35 +52,13 @@ const serviceDefinition = Joi.object({ Joi.object({ format: Joi.string().required(), queryParams: arrayOfStrings, - }) + }), ), - examples: Joi.array() - .items( - Joi.object({ - title: Joi.string().required(), - example: Joi.object({ - pattern: Joi.string(), - namedParams: objectOfKeyValues, - queryParams: objectOfKeyValues, - }).required(), - preview: Joi.object({ - label: Joi.string(), - message: Joi.string().allow('').required(), - color: Joi.string().required(), - style: Joi.string(), - namedLogo: Joi.string(), - }).required(), - keywords: arrayOfStrings, - documentation: Joi.object({ - __html: Joi.string().required(), // Valid HTML. - }), - }) - ) - .default([]), + openApi: openApiSchema, }).required() -function assertValidServiceDefinition(example, message = undefined) { - Joi.assert(example, serviceDefinition, message) +function assertValidServiceDefinition(service, message = undefined) { + Joi.assert(service, serviceDefinition, message) } const serviceDefinitionExport = Joi.object({ @@ -59,20 +68,18 @@ const serviceDefinitionExport = Joi.object({ Joi.object({ id: Joi.string().required(), name: Joi.string().required(), - keywords: arrayOfStrings, - }) + }), ) .required(), services: Joi.array().items(serviceDefinition).required(), }).required() -function assertValidServiceDefinitionExport(examples, message = undefined) { - Joi.assert(examples, serviceDefinitionExport, message) +function assertValidServiceDefinitionExport(openApiSpec, message = undefined) { + Joi.assert(openApiSpec, serviceDefinitionExport, message) } export { - serviceDefinition, assertValidServiceDefinition, - serviceDefinitionExport, assertValidServiceDefinitionExport, + openApiSchema, } diff --git a/core/base-service/to-array.js b/core/base-service/to-array.js index f2131490cf6a6..79669ed6fc530 100644 --- a/core/base-service/to-array.js +++ b/core/base-service/to-array.js @@ -1,3 +1,13 @@ +/** + * Coerces a value to an array. + * + * - Returns an empty array when `val` is `undefined`. + * - Returns `val` unchanged when it is already an array. + * - Otherwise wraps `val` in a single-element array. + * + * @param {*} val - The value to coerce. + * @returns {Array} The resulting array. + */ export default function toArray(val) { if (val === undefined) { return [] diff --git a/core/base-service/trace.js b/core/base-service/trace.js index 7eda5881c52c8..025259cf9e37d 100644 --- a/core/base-service/trace.js +++ b/core/base-service/trace.js @@ -11,6 +11,13 @@ const { services: { trace: enableTraceLogging }, } = objectConfig.public +/** + * Formats a stage label with a coloured background for console output. + * + * @param {'inbound'|'fetch'|'validate'|'unhandledError'|'outbound'} stage - Pipeline stage name. + * @param {string} label - Text to display inside the coloured badge. + * @returns {string} ANSI-coloured label string. + */ function _formatLabelForStage(stage, label) { const colorFn = { inbound: chalk.black.bgBlue, @@ -22,6 +29,17 @@ function _formatLabelForStage(stage, label) { return colorFn(` ${label} `) } +/** + * Logs a trace message to the console when trace logging is enabled in config. + * + * @param {'inbound'|'fetch'|'validate'|'unhandledError'|'outbound'} stage - Pipeline stage used to colour the label. + * @param {string} symbol - Short symbol or emoji prefixed to the log line. + * @param {string} label - Descriptive label shown alongside the symbol. + * @param {*} content - Data to print; logged with `console.dir` when `deep` is true. + * @param {object} [options={}] - Optional logging configuration. + * @param {boolean} [options.deep=false] - When `true`, uses `console.dir` for full-depth output. + * @returns {boolean} `true` if the message was logged, `false` when trace logging is disabled. + */ function logTrace(stage, symbol, label, content, { deep = false } = {}) { if (enableTraceLogging) { if (deep) { diff --git a/core/base-service/validate.js b/core/base-service/validate.js index 23990b15dd58e..7e0de76920a45 100644 --- a/core/base-service/validate.js +++ b/core/base-service/validate.js @@ -12,7 +12,7 @@ function validate( allowAndStripUnknownKeys = true, }, data, - schema + schema, ) { if (!schema || !Joi.isSchema(schema)) { throw Error('A Joi schema is required') @@ -28,7 +28,7 @@ function validate( 'validate', emojic.womanShrugging, traceErrorMessage, - error.message + error.message, ) let prettyMessage = prettyErrorMessage diff --git a/core/base-service/validate.spec.js b/core/base-service/validate.spec.js index 78f6c46f5ceeb..14fc9cf1fa7be 100644 --- a/core/base-service/validate.spec.js +++ b/core/base-service/validate.spec.js @@ -10,15 +10,11 @@ describe('validate', function () { requiredString: Joi.string().required(), }).required() - let sandbox beforeEach(function () { - sandbox = sinon.createSandbox() + sinon.stub(trace, 'logTrace') }) afterEach(function () { - sandbox.restore() - }) - beforeEach(function () { - sandbox.stub(trace, 'logTrace') + sinon.restore() }) const ErrorClass = InvalidParameter @@ -53,7 +49,7 @@ describe('validate', function () { sinon.match.string, traceSuccessMessage, { requiredString: 'bar' }, - { deep: true } + { deep: true }, ) }) }) @@ -64,13 +60,13 @@ describe('validate', function () { validate( options, { requiredString: ['this', "shouldn't", 'work'] }, - schema + schema, ) expect.fail('Expected to throw') } catch (e) { expect(e).to.be.an.instanceof(InvalidParameter) expect(e.message).to.equal( - 'Invalid Parameter: "requiredString" must be a string' + 'Invalid Parameter: "requiredString" must be a string', ) expect(e.prettyMessage).to.equal(prettyErrorMessage) } @@ -78,7 +74,7 @@ describe('validate', function () { 'validate', sinon.match.string, traceErrorMessage, - '"requiredString" must be a string' + '"requiredString" must be a string', ) }) @@ -90,16 +86,16 @@ describe('validate', function () { { requiredString: ['this', "shouldn't", 'work'], }, - schema + schema, ) expect.fail('Expected to throw') } catch (e) { expect(e).to.be.an.instanceof(InvalidParameter) expect(e.message).to.equal( - 'Invalid Parameter: "requiredString" must be a string' + 'Invalid Parameter: "requiredString" must be a string', ) expect(e.prettyMessage).to.equal( - `${prettyErrorMessage}: requiredString` + `${prettyErrorMessage}: requiredString`, ) } }) @@ -111,13 +107,13 @@ describe('validate', function () { validate( { ...options, allowAndStripUnknownKeys: false, includeKeys: true }, { requiredString: 'bar', extra: 'nonsense', more: 'bogus' }, - schema + schema, ) expect.fail('Expected to throw') } catch (e) { expect(e).to.be.an.instanceof(InvalidParameter) expect(e.message).to.equal( - 'Invalid Parameter: "extra" is not allowed. "more" is not allowed' + 'Invalid Parameter: "extra" is not allowed. "more" is not allowed', ) expect(e.prettyMessage).to.equal(`${prettyErrorMessage}: extra, more`) } diff --git a/core/got-test-client.js b/core/got-test-client.js index d8800f46173c1..f6349308c4601 100644 --- a/core/got-test-client.js +++ b/core/got-test-client.js @@ -1,4 +1,4 @@ import got from 'got' // https://github.com/nock/nock/issues/1523 -export default got.extend({ retry: 0 }) +export default got.extend({ retry: { limit: 0 } }) diff --git a/core/legacy/regular-update.js b/core/legacy/regular-update.js deleted file mode 100644 index d60d89112f7e4..0000000000000 --- a/core/legacy/regular-update.js +++ /dev/null @@ -1,97 +0,0 @@ -import requestModule from 'request' -import { Inaccessible, InvalidResponse } from '../base-service/errors.js' - -// Map from URL to { timestamp: last fetch time, data: data }. -let regularUpdateCache = Object.create(null) - -// url: a string, scraper: a function that takes string data at that URL. -// interval: number in milliseconds. -// cb: a callback function that takes an error and data returned by the scraper. -// -// To use this from a service: -// -// import { promisify } from 'util' -// import { regularUpdate } from '../../core/legacy/regular-update.js' -// -// function getThing() { -// return promisify(regularUpdate)({ -// url: ..., -// ... -// }) -// } -// -// in handle(): -// -// const thing = await getThing() - -function regularUpdate( - { - url, - intervalMillis, - json = true, - scraper = buffer => buffer, - options = {}, - request = requestModule, - }, - cb -) { - const timestamp = Date.now() - const cached = regularUpdateCache[url] - if (cached != null && timestamp - cached.timestamp < intervalMillis) { - cb(null, cached.data) - return - } - request(url, options, (err, res, buffer) => { - if (err != null) { - cb( - new Inaccessible({ - prettyMessage: 'intermediate resource inaccessible', - underlyingError: err, - }) - ) - return - } - - if (res.statusCode < 200 || res.statusCode >= 300) { - cb( - new InvalidResponse({ - prettyMessage: 'intermediate resource inaccessible', - }) - ) - } - - let reqData - if (json) { - try { - reqData = JSON.parse(buffer) - } catch (e) { - cb( - new InvalidResponse({ - prettyMessage: 'unparseable intermediate json response', - underlyingError: e, - }) - ) - return - } - } else { - reqData = buffer - } - - let data - try { - data = scraper(reqData) - } catch (e) { - cb(e) - return - } - - regularUpdateCache[url] = { timestamp, data } - cb(null, data) - }) -} - -function clearRegularUpdateCache() { - regularUpdateCache = Object.create(null) -} - -export { regularUpdate, clearRegularUpdateCache } diff --git a/core/register-chai-plugins.spec.js b/core/register-chai-plugins.spec.js index 7b38803d7b8d0..8ca325781b6fa 100644 --- a/core/register-chai-plugins.spec.js +++ b/core/register-chai-plugins.spec.js @@ -1,5 +1,3 @@ import { use } from 'chai' -import chaiString from 'chai-string' import sinonChai from 'sinon-chai' -use(chaiString) use(sinonChai) diff --git a/core/server/error-pages/500.html b/core/server/error-pages/500.html index 25b4ff148601a..a87dfd2518a19 100644 --- a/core/server/error-pages/500.html +++ b/core/server/error-pages/500.html @@ -1,6 +1,5 @@ -A server error occurred +A server error occurred -

I have nothing to offer for this request

-

… but blood, toil, tears and sweat. -

+

I have nothing to offer for this request

+

… but blood, toil, tears and sweat.

And trying again later.

diff --git a/core/server/influx-metrics.js b/core/server/influx-metrics.js index eae365f9e0cc6..ad61b8ab26d17 100644 --- a/core/server/influx-metrics.js +++ b/core/server/influx-metrics.js @@ -13,10 +13,9 @@ export default class InfluxMetrics { async sendMetrics() { const request = { - url: this._config.url, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: await this.metrics(), - timeout: this._config.timeoutMillseconds, + timeout: { request: this._config.timeoutMillseconds }, username: this._config.username, password: this._config.password, throwHttpErrors: false, @@ -24,17 +23,19 @@ export default class InfluxMetrics { let response try { - response = await got.post(request) + response = await got.post(this._config.url, request) } catch (error) { log.error( - new Error(`Cannot push metrics. Cause: ${error.name}: ${error.message}`) + new Error( + `Cannot push metrics. Cause: ${error.name}: ${error.message}`, + ), ) } if (response && response.statusCode >= 300) { log.error( new Error( - `Cannot push metrics. ${request.url} responded with status code ${response.statusCode}` - ) + `Cannot push metrics. ${this._config.url} responded with status code ${response.statusCode}`, + ), ) } } @@ -42,7 +43,7 @@ export default class InfluxMetrics { startPushingMetrics() { this._intervalId = setInterval( () => this.sendMetrics(), - this._config.intervalSeconds * 1000 + this._config.intervalSeconds * 1000, ) } diff --git a/core/server/influx-metrics.spec.js b/core/server/influx-metrics.spec.js index f7d7cba7d0733..8862142749ffd 100644 --- a/core/server/influx-metrics.spec.js +++ b/core/server/influx-metrics.spec.js @@ -5,6 +5,7 @@ import { expect } from 'chai' import log from './log.js' import InfluxMetrics from './influx-metrics.js' import '../register-chai-plugins.spec.js' + describe('Influx metrics', function () { const metricInstance = { metrics() { @@ -46,7 +47,7 @@ describe('Influx metrics', function () { const influxMetrics = new InfluxMetrics(metricInstance, customConfig) expect(await influxMetrics.metrics()).to.be.contain( - 'instance=test-hostname' + 'instance=test-hostname', ) }) @@ -68,7 +69,7 @@ describe('Influx metrics', function () { const influxMetrics = new InfluxMetrics(metricInstance, customConfig) expect(await influxMetrics.metrics()).to.be.contain( - 'instance=test-hostname-alias' + 'instance=test-hostname-alias', ) }) }) @@ -95,7 +96,7 @@ describe('Influx metrics', function () { .persist() .post( '/metrics', - 'prometheus,application=shields,env=test-env,instance=instance2 counter1=11' + 'prometheus,application=shields,env=test-env,instance=instance2 counter1=11', ) .basicAuth({ user: 'metrics-username', pass: 'metrics-password' }) .reply(200) @@ -116,7 +117,7 @@ describe('Influx metrics', function () { await clock.tickAsync(10) expect(scope.isDone()).to.be.equal( true, - `pending mocks: ${scope.pendingMocks()}` + `pending mocks: ${scope.pendingMocks()}`, ) }) }) @@ -149,9 +150,9 @@ describe('Influx metrics', function () { .and( sinon.match.has( 'message', - 'Cannot push metrics. Cause: RequestError: Nock: Disallowed net connect for "shields-metrics.io:443/metrics"' - ) - ) + 'Cannot push metrics. Cause: RequestError: Nock: Disallowed net connect for "shields-metrics.io:443/metrics"', + ), + ), ) }) @@ -166,9 +167,9 @@ describe('Influx metrics', function () { .and( sinon.match.has( 'message', - 'Cannot push metrics. https://shields-metrics.io/metrics responded with status code 400' - ) - ) + 'Cannot push metrics. https://shields-metrics.io/metrics responded with status code 400', + ), + ), ) }) }) diff --git a/core/server/instance-id-generator.js b/core/server/instance-id-generator.js index 63ad06d09a387..4a1dbfa153482 100644 --- a/core/server/instance-id-generator.js +++ b/core/server/instance-id-generator.js @@ -1,3 +1,8 @@ +/** + * Generate a random 9-character alphanumeric instance identifier. + * + * @returns {string} Random identifier (e.g. `a1b2c3d4e`). + */ function generateInstanceId() { // from https://gist.github.com/gordonbrander/2230317 return Math.random().toString(36).substr(2, 9) diff --git a/core/server/log.js b/core/server/log.js index c89aae419a606..d8ed46eec5c28 100644 --- a/core/server/log.js +++ b/core/server/log.js @@ -1,4 +1,4 @@ -import Sentry from '@sentry/node' +import * as Sentry from '@sentry/node-core/light' const listeners = [] @@ -28,10 +28,12 @@ const log = (...msg) => { console.log(d, ...msg) } -const error = err => { +const error = (err, tags) => { const d = date() listeners.forEach(f => f(d, err)) - Sentry.captureException(err) + Sentry.captureException(err, { + tags, + }) console.error(d, err) } diff --git a/core/server/metrics/format-converters.js b/core/server/metrics/format-converters.js index 53a60b0f54630..4e363845e7308 100644 --- a/core/server/metrics/format-converters.js +++ b/core/server/metrics/format-converters.js @@ -1,11 +1,14 @@ -import groupBy from 'lodash.groupby' - function promClientJsonToInfluxV2(metrics, extraLabels = {}) { return metrics .flatMap(metric => { - const valuesByLabels = groupBy(metric.values, value => - JSON.stringify(Object.entries(value.labels).sort()) - ) + const valuesByLabels = metric.values.reduce((acc, value) => { + const key = JSON.stringify(Object.entries(value.labels).sort()) + if (!acc[key]) { + acc[key] = [] + } + acc[key].push(value) + return acc + }, {}) return Object.values(valuesByLabels).map(metricsWithSameLabel => { const labels = Object.entries(metricsWithSameLabel[0].labels) .concat(Object.entries(extraLabels)) diff --git a/core/server/metrics/format-converters.spec.js b/core/server/metrics/format-converters.spec.js index 3ea4a51028cdc..0b5fde9c4d59c 100644 --- a/core/server/metrics/format-converters.spec.js +++ b/core/server/metrics/format-converters.spec.js @@ -95,7 +95,7 @@ describe('Metric format converters', function () { prometheus,le=50 histogram1_bucket=2 prometheus,le=15 histogram1_bucket=2 prometheus,le=5 histogram1_bucket=1 -prometheus histogram1_count=3,histogram1_sum=111`) +prometheus histogram1_count=3,histogram1_sum=111`), ) }) @@ -118,7 +118,7 @@ prometheus histogram1_count=3,histogram1_sum=111`) prometheus,le=50 histogram1_bucket=2 prometheus,le=15 histogram1_bucket=2 prometheus,le=5 histogram1_bucket=1 -prometheus histogram1_count=3,histogram1_sum=111`) +prometheus histogram1_count=3,histogram1_sum=111`), ) }) @@ -145,7 +145,7 @@ prometheus histogram1_count=3,histogram1_sum=111`) sortLines(`prometheus,quantile=0.99 summary1=100 prometheus,quantile=0.9 summary1=100 prometheus,quantile=0.1 summary1=1 -prometheus summary1_count=3,summary1_sum=111`) +prometheus summary1_count=3,summary1_sum=111`), ) }) @@ -167,7 +167,7 @@ prometheus summary1_count=3,summary1_sum=111`) sortLines(`prometheus,quantile=0.99 summary1=100 prometheus,quantile=0.9 summary1=100 prometheus,quantile=0.1 summary1=1 -prometheus summary1_count=3,summary1_sum=111`) +prometheus summary1_count=3,summary1_sum=111`), ) }) @@ -204,7 +204,7 @@ prometheus summary1_count=3,summary1_sum=111`) }) expect(influx).to.be.equal( - 'prometheus,env=production,instance=instance1 counter1=11' + 'prometheus,env=production,instance=instance1 counter1=11', ) }) }) diff --git a/core/server/prometheus-metrics.js b/core/server/prometheus-metrics.js index 072a497c1bafb..6b8cc71ca4912 100644 --- a/core/server/prometheus-metrics.js +++ b/core/server/prometheus-metrics.js @@ -31,18 +31,61 @@ export default class PrometheusMetrics { buckets: prometheus.exponentialBuckets(64 * 1024, 2, 8), registers: [this.register], }), + githubTokenInvalidations: new prometheus.Counter({ + name: 'github_token_invalidations_total', + help: 'Total GitHub tokens invalidated and removed from the pool', + labelNames: ['reason'], + registers: [this.register], + }), + } + this.gauges = { + githubTokenPoolStandard: new prometheus.Gauge({ + name: 'github_token_pool_standard_requests_remaining', + help: 'Total GitHub API requests remaining in standard token pool', + registers: [this.register], + }), + githubTokenPoolSearch: new prometheus.Gauge({ + name: 'github_token_pool_search_requests_remaining', + help: 'Total GitHub API requests remaining in search token pool', + registers: [this.register], + }), + githubTokenPoolGraphql: new prometheus.Gauge({ + name: 'github_token_pool_graphql_requests_remaining', + help: 'Total GitHub API requests remaining in GraphQL token pool', + registers: [this.register], + }), + githubTokenPoolStandardCount: new prometheus.Gauge({ + name: 'github_token_pool_standard_count', + help: 'Number of tokens in standard token pool', + registers: [this.register], + }), + githubTokenPoolSearchCount: new prometheus.Gauge({ + name: 'github_token_pool_search_count', + help: 'Number of tokens in search token pool', + registers: [this.register], + }), + githubTokenPoolGraphqlCount: new prometheus.Gauge({ + name: 'github_token_pool_graphql_count', + help: 'Number of tokens in GraphQL token pool', + registers: [this.register], + }), } this.interval = prometheus.collectDefaultMetrics({ register: this.register, }) } - async registerMetricsEndpoint(server) { + async registerMetricsEndpoint(server, enabled = true) { const { register } = this server.route(/^\/metrics$/, async (data, match, end, ask) => { - ask.res.setHeader('Content-Type', register.contentType) - ask.res.end(await register.metrics()) + if (enabled) { + ask.res.setHeader('Content-Type', register.contentType) + ask.res.end(await register.metrics()) + } else { + ask.res.statusCode = 404 + ask.res.end() + } }) } @@ -79,7 +122,37 @@ export default class PrometheusMetrics { return this.counters.serviceResponseSize.labels( category, serviceFamily, - service + service, + ) + } + + /** + * Record that a GitHub token was invalidated and removed from the pool. + * + * @param {object} attrs Attributes + * @param {string} attrs.reason Why the token was invalidated, + * e.g: 'http_401' or 'account_suspended' + */ + noteGithubTokenInvalidation({ reason }) { + this.counters.githubTokenInvalidations.labels(reason).inc() + } + + noteGithubTokenPoolMetrics(tokenDebugInfo) { + const { standardTokens, searchTokens, graphqlTokens } = tokenDebugInfo + + this.gauges.githubTokenPoolStandard.set(standardTokens.totalUsesRemaining) + this.gauges.githubTokenPoolStandardCount.set( + standardTokens.allTokenDebugInfo.length, + ) + + this.gauges.githubTokenPoolSearch.set(searchTokens.totalUsesRemaining) + this.gauges.githubTokenPoolSearchCount.set( + searchTokens.allTokenDebugInfo.length, + ) + + this.gauges.githubTokenPoolGraphql.set(graphqlTokens.totalUsesRemaining) + this.gauges.githubTokenPoolGraphqlCount.set( + graphqlTokens.allTokenDebugInfo.length, ) } } diff --git a/core/server/public/monitor.html b/core/server/public/monitor.html deleted file mode 100644 index 4475bcc82de53..0000000000000 --- a/core/server/public/monitor.html +++ /dev/null @@ -1,66 +0,0 @@ - - Shields.io Admin Monitoring Interface - - -
-

Please enter your admin secret here: - -

-
-
- - diff --git a/core/server/secret-is-valid.js b/core/server/secret-is-valid.js deleted file mode 100644 index c8b87ffda123f..0000000000000 --- a/core/server/secret-is-valid.js +++ /dev/null @@ -1,18 +0,0 @@ -function constEq(a, b) { - if (a.length !== b.length) { - return false - } - let zero = 0 - for (let i = 0; i < a.length; i++) { - zero |= a.charCodeAt(i) ^ b.charCodeAt(i) - } - return zero === 0 -} - -function makeSecretIsValid(shieldsSecret) { - return function secretIsValid(secret = '') { - return shieldsSecret && constEq(secret, shieldsSecret) - } -} - -export { makeSecretIsValid } diff --git a/core/server/server.js b/core/server/server.js index 8b5b166b35719..e627d3f3340d2 100644 --- a/core/server/server.js +++ b/core/server/server.js @@ -6,18 +6,22 @@ import path from 'path' import url, { fileURLToPath } from 'url' import { bootstrap } from 'global-agent' import cloudflareMiddleware from 'cloudflare-middleware' -import bytes from 'bytes' import Camp from '@shields_io/camp' import originalJoi from 'joi' import makeBadge from '../../badge-maker/lib/make-badge.js' import GithubConstellation from '../../services/github/github-constellation.js' -import { setRoutes } from '../../services/suggest.js' +import LibrariesIoConstellation from '../../services/librariesio/librariesio-constellation.js' import { loadServiceClasses } from '../base-service/loader.js' import { makeSend } from '../base-service/legacy-result-sender.js' import { handleRequest } from '../base-service/legacy-request-handler.js' -import { clearRegularUpdateCache } from '../legacy/regular-update.js' +import { clearResourceCache } from '../base-service/resource-cache.js' import { rasterRedirectUrl } from '../badge-urls/make-badge-url.js' -import { nonNegativeInteger } from '../../services/validators.js' +import { + fileSizeBytes, + nonNegativeInteger, + optionalUrl, + url as requiredUrl, +} from '../../services/validators.js' import log from './log.js' import PrometheusMetrics from './prometheus-metrics.js' import InfluxMetrics from './influx-metrics.js' @@ -55,8 +59,6 @@ const Joi = originalJoi }, })) -const optionalUrl = Joi.string().uri({ scheme: ['http', 'https'] }) -const requiredUrl = optionalUrl.required() const origins = Joi.arrayFromString().items(Joi.string().origin()) const defaultService = Joi.object({ authorizedOrigins: origins }).default({ authorizedOrigins: [], @@ -66,11 +68,11 @@ const publicConfigSchema = Joi.object({ bind: { port: Joi.alternatives().try( Joi.number().port(), - Joi.string().pattern(/^\\\\\.\\pipe\\.+$/) + Joi.string().pattern(/^\\\\\.\\pipe\\.+$/), ), address: Joi.alternatives().try( Joi.string().ip().required(), - Joi.string().hostname().required() + Joi.string().hostname().required(), ), }, metrics: { @@ -112,10 +114,8 @@ const publicConfigSchema = Joi.object({ }, redirectUrl: optionalUrl, rasterUrl: optionalUrl, - cors: { - allowedOrigin: Joi.array().items(optionalUrl).required(), - }, services: Joi.object({ + bitbucket: defaultService, bitbucketServer: defaultService, drone: defaultService, github: { @@ -124,7 +124,9 @@ const publicConfigSchema = Joi.object({ enabled: Joi.boolean().required(), intervalSeconds: Joi.number().integer().min(1).required(), }, + restApiVersion: Joi.date().raw().required(), }, + gitea: defaultService, gitlab: defaultService, jira: defaultService, jenkins: Joi.object({ @@ -135,6 +137,9 @@ const publicConfigSchema = Joi.object({ nexus: defaultService, npm: defaultService, obs: defaultService, + pypi: { + baseUri: requiredUrl, + }, sonar: defaultService, teamcity: defaultService, weblate: defaultService, @@ -142,7 +147,8 @@ const publicConfigSchema = Joi.object({ }).required(), cacheHeaders: { defaultCacheLengthSeconds: nonNegativeInteger }, handleInternalErrors: Joi.boolean().required(), - fetchLimit: Joi.string().regex(/^[0-9]+(b|kb|mb|gb|tb)$/i), + fetchLimitBytes: fileSizeBytes, + userAgentBase: Joi.string().required(), requestTimeoutSeconds: nonNegativeInteger, requestTimeoutMaxAgeSeconds: nonNegativeInteger, documentRoot: Joi.string().default( @@ -150,42 +156,53 @@ const publicConfigSchema = Joi.object({ path.dirname(fileURLToPath(import.meta.url)), '..', '..', - 'public' - ) + 'public', + ), ), + allowUnsecuredEndpointRequests: Joi.boolean().required(), requireCloudflare: Joi.boolean().required(), }).required() const privateConfigSchema = Joi.object({ azure_devops_token: Joi.string(), + curseforge_api_key: Joi.string(), discord_bot_token: Joi.string(), + dockerhub_username: Joi.string(), + dockerhub_pat: Joi.string(), drone_token: Joi.string(), gh_client_id: Joi.string(), gh_client_secret: Joi.string(), gh_token: Joi.string(), + gitea_token: Joi.string(), gitlab_token: Joi.string(), jenkins_user: Joi.string(), jenkins_pass: Joi.string(), jira_user: Joi.string(), jira_pass: Joi.string(), + bitbucket_username: Joi.string(), + bitbucket_password: Joi.string(), bitbucket_server_username: Joi.string(), bitbucket_server_password: Joi.string(), + librariesio_tokens: Joi.arrayFromString().items(Joi.string()), nexus_user: Joi.string(), nexus_pass: Joi.string(), npm_token: Joi.string(), obs_user: Joi.string(), obs_pass: Joi.string(), - redis_url: Joi.string().uri({ scheme: ['redis', 'rediss'] }), + opencollective_token: Joi.string(), + pepy_key: Joi.string(), + postgres_url: Joi.string().uri({ scheme: 'postgresql' }), + reddit_client_id: Joi.string(), + reddit_client_secret: Joi.string(), sentry_dsn: Joi.string(), - shields_secret: Joi.string(), sl_insight_userUuid: Joi.string(), sl_insight_apiToken: Joi.string(), sonarqube_token: Joi.string(), + stackapps_api_key: Joi.string(), teamcity_user: Joi.string(), teamcity_pass: Joi.string(), twitch_client_id: Joi.string(), twitch_client_secret: Joi.string(), - wheelmap_token: Joi.string(), influx_username: Joi.string(), influx_password: Joi.string(), weblate_api_key: Joi.string(), @@ -200,6 +217,14 @@ function addHandlerAtIndex(camp, index, handlerFn) { camp.stack.splice(index, 0, handlerFn) } +function isOnHeroku() { + return !!process.env.DYNO +} + +function isOnFly() { + return !!process.env.FLY_APP_NAME +} + /** * The Server is based on the web framework Scoutcamp. It creates * an http server, sets up helpers for token persistence and monitoring. @@ -220,7 +245,7 @@ class Server { const publicConfig = Joi.attempt(config.public, publicConfigSchema) const privateConfig = this.validatePrivateConfig( config.private, - privateConfigSchema + privateConfigSchema, ) // We want to require an username and a password for the influx metrics // only if the influx metrics are enabled. The private config schema @@ -229,7 +254,7 @@ class Server { if (publicConfig.metrics.influx && publicConfig.metrics.influx.enabled) { this.validatePrivateConfig( config.private, - privateMetricsInfluxConfigSchema + privateMetricsInfluxConfigSchema, ) } this.config = { @@ -239,6 +264,11 @@ class Server { this.githubConstellation = new GithubConstellation({ service: publicConfig.services.github, + metricsIntervalSeconds: publicConfig.metrics.influx.intervalSeconds, + private: privateConfig, + }) + + this.librariesioConstellation = new LibrariesIoConstellation({ private: privateConfig, }) @@ -250,7 +280,7 @@ class Server { Object.assign({}, publicConfig.metrics.influx, { username: privateConfig.influx_username, password: privateConfig.influx_password, - }) + }), ) } } @@ -263,8 +293,8 @@ class Server { const badPaths = e.details.map(({ path }) => path) throw Error( `Private configuration is invalid. Check these paths: ${badPaths.join( - ',' - )}` + ',', + )}`, ) } } @@ -296,13 +326,21 @@ class Server { // 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 + if (isOnHeroku()) { + // On Heroku, `req.socket.remoteAddress` is the Heroku router. However, + // the router ensures that the last item in the `X-Forwarded-For` header + // is the real origin. + // https://stackoverflow.com/a/18517550/893113 + req.ip = req.headers['x-forwarded-for'].split(', ').pop() + } else if (isOnFly()) { + // On Fly we can use the Fly-Client-IP header + // https://fly.io/docs/reference/runtime-environment/#request-headers + req.ip = req.headers['fly-client-ip'] + ? req.headers['fly-client-ip'] + : req.socket.remoteAddress + } else { + req.ip = req.socket.remoteAddress + } next() }) addHandlerAtIndex(this.camp, 1, cloudflareMiddleware()) @@ -317,54 +355,68 @@ class Server { public: { rasterUrl }, } = config + camp.route(/^\/favicon\.ico$/, (query, match, end, request) => { + request.res.statusCode = 404 + request.res.setHeader( + 'Cache-Control', + 'public, max-age=31536000, s-maxage=31536000, immutable', + ) + makeSend('empty', request.res, end)() + }) + camp.route(/\.(gif|jpg)$/, (query, match, end, request) => { const [, format] = match makeSend( 'svg', request.res, - end + end, )( makeBadge({ label: '410', message: `${format} no longer available`, color: 'lightgray', format: 'svg', - }) + }), ) }) if (!rasterUrl) { - camp.route(/\.png$/, (query, match, end, request) => { - makeSend( - 'svg', - request.res, - end - )( - makeBadge({ - label: '404', - message: 'raster badges not available', - color: 'lightgray', - format: 'svg', - }) - ) - }) + camp.route( + /^\/((?!img|assets\/)).*\.png$/, + (query, match, end, request) => { + makeSend( + 'svg', + request.res, + end, + )( + makeBadge({ + label: '404', + message: 'raster badges not available', + color: 'lightgray', + format: 'svg', + }), + ) + }, + ) } camp.notfound(/(\.svg|\.json|)$/, (query, match, end, request) => { const [, extension] = match const format = (extension || '.svg').replace(/^\./, '') + request.res.statusCode = 200 + makeSend( format, request.res, - end + end, )( makeBadge({ label: '404', message: 'badge not found', color: 'red', format, - }) + }), ) }) } @@ -384,18 +436,21 @@ class Server { if (rasterUrl) { // Redirect to the raster server for raster versions of modern badges. - camp.route(/\.png$/, (queryParams, match, end, ask) => { - ask.res.statusCode = 301 - ask.res.setHeader( - 'Location', - rasterRedirectUrl({ rasterUrl }, ask.req.url) - ) - - const cacheDuration = (30 * 24 * 3600) | 0 // 30 days. - ask.res.setHeader('Cache-Control', `max-age=${cacheDuration}`) - - ask.res.end() - }) + camp.route( + /^\/((?!img|assets\/)).*\.png$/, + (queryParams, match, end, ask) => { + ask.res.statusCode = 301 + ask.res.setHeader( + 'Location', + rasterRedirectUrl({ rasterUrl }, ask.req.url), + ) + + const cacheDuration = (30 * 24 * 3600) | 0 // 30 days. + ask.res.setHeader('Cache-Control', `max-age=${cacheDuration}`) + + ask.res.end() + }, + ) } if (redirectUrl) { @@ -414,19 +469,25 @@ class Server { async registerServices() { const { config, camp, metricInstance } = this const { apiProvider: githubApiProvider } = this.githubConstellation - + const { apiProvider: librariesIoApiProvider } = + this.librariesioConstellation ;(await loadServiceClasses()).forEach(serviceClass => serviceClass.register( - { camp, handleRequest, githubApiProvider, metricInstance }, + { + camp, + handleRequest, + githubApiProvider, + librariesIoApiProvider, + metricInstance, + }, { handleInternalErrors: config.public.handleInternalErrors, cacheHeaders: config.public.cacheHeaders, - fetchLimitBytes: bytes(config.public.fetchLimit), rasterUrl: config.public.rasterUrl, private: config.private, public: config.public, - } - ) + }, + ), ) } @@ -459,7 +520,6 @@ class Server { const { bind: { port, address: hostname }, ssl: { isSecure: secure, cert, key }, - cors: { allowedOrigin }, requireCloudflare, } = this.config.public @@ -482,18 +542,25 @@ class Server { } const { githubConstellation, metricInstance } = this - await githubConstellation.initialize(camp) + await githubConstellation.initialize(camp, metricInstance) if (metricInstance) { - if (this.config.public.metrics.prometheus.endpointEnabled) { - metricInstance.registerMetricsEndpoint(camp) - } + metricInstance.registerMetricsEndpoint( + camp, + this.config.public.metrics.prometheus.endpointEnabled, + ) if (this.influxMetrics) { this.influxMetrics.startPushingMetrics() } } - const { apiProvider: githubApiProvider } = this.githubConstellation - setRoutes(allowedOrigin, githubApiProvider, camp) + camp.handle((req, res, next) => { + // https://github.com/badges/shields/issues/3273 + res.setHeader('Access-Control-Allow-Origin', '*') + // https://github.com/badges/shields/issues/10419 + res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin') + + next() + }) this.registerErrorHandlers() this.registerRedirects() @@ -520,7 +587,7 @@ class Server { static resetGlobalState() { // This state should be migrated to instance state. When possible, do not add new // global state. - clearRegularUpdateCache() + clearResourceCache() } reset() { diff --git a/core/server/server.spec.js b/core/server/server.spec.js index dc43db3f5607d..d53b8aea328e0 100644 --- a/core/server/server.spec.js +++ b/core/server/server.spec.js @@ -19,7 +19,7 @@ describe('The server', function () { public: { documentRoot: path.resolve( path.dirname(fileURLToPath(import.meta.url)), - 'test-public' + 'test-public', ), }, }) @@ -59,9 +59,27 @@ describe('The server', function () { 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 serve static badges without logo with maxAge=432000', async function () { + const { headers } = await got(`${baseUrl}badge/foo-bar-blue`) + expect(headers['cache-control']).to.equal( + 'max-age=432000, s-maxage=432000', + ) + }) + + it('should serve badges with with logo with maxAge=86400', async function () { + const { headers } = await got( + `${baseUrl}badge/foo-bar-blue?logo=javascript`, + ) + expect(headers['cache-control']).to.equal('max-age=86400, s-maxage=86400') + }) + + it('should return cors header for the request', async function () { + const { statusCode, headers } = await got( + `${baseUrl}badge/foo-bar-blue.svg`, + ) + expect(statusCode).to.equal(200) + expect(headers['access-control-allow-origin']).to.equal('*') + expect(headers['cross-origin-resource-policy']).to.equal('cross-origin') }) it('should redirect colorscheme PNG badges as configured', async function () { @@ -69,33 +87,79 @@ describe('The server', function () { `${baseUrl}:fruit-apple-green.png`, { followRedirect: false, - } + }, ) expect(statusCode).to.equal(301) expect(headers.location).to.equal( - 'http://raster.example.test/:fruit-apple-green.png' + 'http://raster.example.test/:fruit-apple-green.png', ) }) it('should redirect modern PNG badges as configured', async function () { - const { statusCode, headers } = await got(`${baseUrl}npm/v/express.png`, { - followRedirect: false, - }) + const { statusCode, headers } = await got( + `${baseUrl}badge/foo-bar-blue.png`, + { + followRedirect: false, + }, + ) expect(statusCode).to.equal(301) expect(headers.location).to.equal( - 'http://raster.example.test/npm/v/express.png' + 'http://raster.example.test/badge/foo-bar-blue.png', ) }) - it('should produce json badges', async function () { + it('should not redirect for PNG requests in /img', async function () { + const { statusCode } = await got(`${baseUrl}img/frontend-image.png`) + expect(statusCode).to.equal(200) + }) + + it('should produce SVG badges with expected headers', async function () { + const { statusCode, headers } = await got( + `${baseUrl}:fruit-apple-green.svg`, + ) + expect(statusCode).to.equal(200) + expect(headers['content-type']).to.equal('image/svg+xml;charset=utf-8') + expect(headers['content-length']).to.equal('1275') + }) + + it('correctly calculates the content-length header for multi-byte unicode characters', async function () { + const { headers } = await got(`${baseUrl}:fruit-apple🍏-green.json`) + expect(headers['content-length']).to.equal('100') + }) + + it('should produce JSON badges with expected headers', async function () { const { statusCode, body, headers } = await got( - `${baseUrl}twitter/follow/_Pyves.json` + `${baseUrl}:fruit-apple-green.json`, ) expect(statusCode).to.equal(200) expect(headers['content-type']).to.equal('application/json') + expect(headers['access-control-allow-origin']).to.equal('*') + expect(headers['cross-origin-resource-policy']).to.equal('cross-origin') + expect(headers['content-length']).to.equal('92') expect(() => JSON.parse(body)).not.to.throw() }) + describe('Content Security Policy', function () { + it('should disable javascript when serving SVG content (no extension)', async function () { + const { headers } = await got(`${baseUrl}:fruit-apple-green`) + expect(headers['content-security-policy']).to.equal( + "script-src 'none';", + ) + }) + + it('should disable javascript when serving SVG content (with extension)', async function () { + const { headers } = await got(`${baseUrl}:fruit-apple-green.svg`) + expect(headers['content-security-policy']).to.equal( + "script-src 'none';", + ) + }) + + it('should not send content security headers when serving JSON content', async function () { + const { headers } = await got(`${baseUrl}:fruit-apple-green.json`) + expect(headers).not.to.have.property('content-security-policy') + }) + }) + it('should preserve label case', async function () { const { statusCode, body } = await got(`${baseUrl}:fRuiT-apple-green.svg`) expect(statusCode).to.equal(200) @@ -105,7 +169,7 @@ describe('The server', function () { // https://github.com/badges/shields/pull/1319 it('should not crash with a numeric logo', async function () { const { statusCode, body } = await got( - `${baseUrl}:fruit-apple-green.svg?logo=1` + `${baseUrl}:fruit-apple-green.svg?logo=1`, ) expect(statusCode).to.equal(200) expect(body) @@ -116,7 +180,7 @@ describe('The server', function () { it('should not crash with a numeric link', async function () { const { statusCode, body } = await got( - `${baseUrl}:fruit-apple-green.svg?link=1` + `${baseUrl}:fruit-apple-green.svg?link=1`, ) expect(statusCode).to.equal(200) expect(body) @@ -127,7 +191,7 @@ describe('The server', function () { it('should not crash with a boolean link', async function () { const { statusCode, body } = await got( - `${baseUrl}:fruit-apple-green.svg?link=true` + `${baseUrl}:fruit-apple-green.svg?link=true`, ) expect(statusCode).to.equal(200) expect(body) @@ -141,23 +205,23 @@ describe('The server', function () { `${baseUrl}this/is/not/a/badge.svg`, { throwHttpErrors: false, - } + }, ) - expect(statusCode).to.equal(404) + expect(statusCode).to.equal(200) expect(body) .to.satisfy(isSvg) .and.to.include('404') .and.to.include('badge not found') }) - it('should return the 404 badge page for rando links', async function () { + it('should return the 404 badge page for random links', async function () { const { statusCode, body } = await got( `${baseUrl}this/is/most/definitely/not/a/badge.js`, { throwHttpErrors: false, - } + }, ) - expect(statusCode).to.equal(404) + expect(statusCode).to.equal(200) expect(body) .to.satisfy(isSvg) .and.to.include('404') @@ -174,11 +238,24 @@ describe('The server', function () { expect(headers.location).to.equal('http://frontend.example.test') }) - it('should return the 410 badge for obsolete formats', async function () { - const { statusCode, body } = await got(`${baseUrl}npm/v/express.jpg`, { + it('should return the 404 page with empty response for favicon.icon', async function () { + const { statusCode, body, headers } = await got(`${baseUrl}favicon.ico`, { throwHttpErrors: false, }) - // TODO It would be nice if this were 404 or 410. + expect(statusCode).to.equal(404) + expect(body).to.equal('') + expect(headers['cache-control']).to.equal( + 'public, max-age=31536000, s-maxage=31536000, immutable', + ) + }) + + it('should return the 410 badge for obsolete formats', async function () { + const { statusCode, body } = await got( + `${baseUrl}badge/foo-bar-blue.jpg`, + { + throwHttpErrors: false, + }, + ) expect(statusCode).to.equal(200) expect(body) .to.satisfy(isSvg) @@ -201,7 +278,7 @@ describe('The server', function () { await server.start() const { statusCode, body } = await got( - `${server.baseUrl}badge/foo-bar-blue.svg` + `${server.baseUrl}badge/foo-bar-blue.svg`, ) expect(statusCode).to.be.equal(200) @@ -330,7 +407,7 @@ describe('The server', function () { it('should require url when influx configuration is enabled', function () { delete customConfig.public.metrics.influx.url expect(() => new Server(customConfig)).to.throw( - '"metrics.influx.url" is required' + '"metrics.influx.url" is required', ) }) @@ -343,21 +420,21 @@ describe('The server', function () { it('should require timeoutMilliseconds when influx configuration is enabled', function () { delete customConfig.public.metrics.influx.timeoutMilliseconds expect(() => new Server(customConfig)).to.throw( - '"metrics.influx.timeoutMilliseconds" is required' + '"metrics.influx.timeoutMilliseconds" is required', ) }) it('should require intervalSeconds when influx configuration is enabled', function () { delete customConfig.public.metrics.influx.intervalSeconds expect(() => new Server(customConfig)).to.throw( - '"metrics.influx.intervalSeconds" is required' + '"metrics.influx.intervalSeconds" is required', ) }) it('should require instanceIdFrom when influx configuration is enabled', function () { delete customConfig.public.metrics.influx.instanceIdFrom expect(() => new Server(customConfig)).to.throw( - '"metrics.influx.instanceIdFrom" is required' + '"metrics.influx.instanceIdFrom" is required', ) }) @@ -365,7 +442,7 @@ describe('The server', function () { customConfig.public.metrics.influx.instanceIdFrom = 'env-var' delete customConfig.public.metrics.influx.instanceIdEnvVarName expect(() => new Server(customConfig)).to.throw( - '"metrics.influx.instanceIdEnvVarName" is required' + '"metrics.influx.instanceIdEnvVarName" is required', ) }) @@ -387,7 +464,7 @@ describe('The server', function () { it('should require envLabel when influx configuration is enabled', function () { delete customConfig.public.metrics.influx.envLabel expect(() => new Server(customConfig)).to.throw( - '"metrics.influx.envLabel" is required' + '"metrics.influx.envLabel" is required', ) }) @@ -404,14 +481,14 @@ describe('The server', function () { it('should require username when influx configuration is enabled', function () { delete customConfig.private.influx_username expect(() => new Server(customConfig)).to.throw( - 'Private configuration is invalid. Check these paths: influx_username' + 'Private configuration is invalid. Check these paths: influx_username', ) }) it('should require password when influx configuration is enabled', function () { delete customConfig.private.influx_password expect(() => new Server(customConfig)).to.throw( - 'Private configuration is invalid. Check these paths: influx_password' + 'Private configuration is invalid. Check these paths: influx_password', ) }) @@ -448,7 +525,7 @@ describe('The server', function () { influx_password: 'influx-password', }, }) - clock = sinon.useFakeTimers() + clock = sinon.useFakeTimers({ toFake: ['setInterval'] }) baseUrl = server.baseUrl await server.start() }) @@ -463,6 +540,7 @@ describe('The server', function () { }) it('should push custom metrics', async function () { + const { promise: sentReq, resolve: markSentReq } = Promise.withResolvers() scope = nock('http://localhost:1112', { reqheaders: { 'Content-Type': 'application/x-www-form-urlencoded', @@ -470,17 +548,21 @@ describe('The server', function () { }) .post( '/metrics', - /prometheus,application=shields,category=static,env=localhost-env,family=static-badge,instance=test-instance,service=static_badge service_requests_total=1\n/ + /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`) + .reply(200, () => { + markSentReq() + return '' + }) - await clock.tickAsync(1000 * metricsPushIntervalSeconds + 500) + await got(`${baseUrl}badge/fruit-apple-green.svg`) + await clock.tickAsync(1000 * metricsPushIntervalSeconds) + await sentReq expect(scope.isDone()).to.be.equal( true, - `pending mocks: ${scope.pendingMocks()}` + `pending mocks: ${scope.pendingMocks()}`, ) }) }) diff --git a/core/server/test-public/img/frontend-image.png b/core/server/test-public/img/frontend-image.png new file mode 100644 index 0000000000000..311cb687cb4cf Binary files /dev/null and b/core/server/test-public/img/frontend-image.png differ diff --git a/core/server/test-public/index.html b/core/server/test-public/index.html index 706f8467b06f5..74b953fd05e3c 100644 --- a/core/server/test-public/index.html +++ b/core/server/test-public/index.html @@ -1,7 +1,7 @@ - + - + shields.io diff --git a/core/service-test-runner/cli.js b/core/service-test-runner/cli.js index 917c1f859b499..674f79de79730 100644 --- a/core/service-test-runner/cli.js +++ b/core/service-test-runner/cli.js @@ -10,10 +10,10 @@ // echo "service1\nservice2\nservice3" | npm run test:services -- --stdin // // Run tests but skip tests which intercept requests: -// SKIP_INTERCEPTED=TRUE npm run test:services -- +// SKIP_INTERCEPTED=true npm run test:services -- // // Run tests on a given instance: -// SKIP_INTERCEPTED=TRUE TESTED_SERVER_URL=https://test.shields.io npm run test:services -- +// SKIP_INTERCEPTED=true TESTED_SERVER_URL=https://test.shields.io npm run test:services -- // // Run tests with given number of retries and backoff (in milliseconds): // RETRY_COUNT=3 RETRY_BACKOFF=100 npm run test:services -- @@ -54,9 +54,8 @@ // Relying on npm scripts is safer. Using "pre" makes it impossible to run // the second step without the first. -import minimist from 'minimist' -import envFlag from 'node-env-flag' -import readAllStdinSync from 'read-all-stdin-sync' +import fs from 'fs' +import { parseArgs } from 'util' import { createTestServer } from '../server/in-process-server-test-helpers.js' import Runner from './runner.js' @@ -66,14 +65,16 @@ const retry = {} retry.count = parseInt(process.env.RETRY_COUNT) || 0 retry.backoff = parseInt(process.env.RETRY_BACKOFF) || 0 -const args = minimist(process.argv.slice(3)) -const stdinOption = args.stdin -const onlyOption = args.only +const { stdin: stdinOption, only: onlyOption } = parseArgs({ + args: process.argv.slice(3), + options: { stdin: { type: 'boolean' }, only: { type: 'string' } }, + strict: false, +}).values let onlyServices if (stdinOption && onlyOption) { console.error('Do not use --only with --stdin') } else if (stdinOption) { - const allStdin = readAllStdinSync().trim() + const allStdin = fs.readFileSync(0, 'utf-8').trim() onlyServices = allStdin ? allStdin.split('\n') : [] } else if (onlyOption) { onlyServices = onlyOption.split(',') @@ -102,7 +103,7 @@ if (process.env.TESTED_SERVER_URL) { }) } -const skipIntercepted = envFlag(process.env.SKIP_INTERCEPTED, false) +const skipIntercepted = process.env.SKIP_INTERCEPTED === 'true' const runner = new Runner({ baseUrl, skipIntercepted, retry }) await runner.prepare() @@ -121,8 +122,8 @@ if (typeof onlyServices === 'undefined' || onlyServices.includes('*****')) { } else { console.info( `Running tests for ${onlyServices.length} services: ${onlyServices.join( - ', ' - )}.\n` + ', ', + )}.\n`, ) runner.only(onlyServices) } diff --git a/core/service-test-runner/create-service-tester.js b/core/service-test-runner/create-service-tester.js index 17246b6693374..ed03ef1505201 100644 --- a/core/service-test-runner/create-service-tester.js +++ b/core/service-test-runner/create-service-tester.js @@ -15,7 +15,7 @@ import ServiceTester from './service-tester.js' * This can't be used for `.service.js` files which export more than one * service. * - * @returns {module:core/service-test-runner/service-tester~ServiceTester} + * @returns {ServiceTester} * ServiceTester instance */ async function createServiceTester() { @@ -23,7 +23,7 @@ async function createServiceTester() { const ServiceClass = Object.values(await import(servicePath))[0] if (!(ServiceClass.prototype instanceof BaseService)) { throw Error( - `${servicePath} does not export a single service. Invoke new ServiceTester() directly.` + `${servicePath} does not export a single service. Invoke new ServiceTester() directly.`, ) } return ServiceTester.forServiceClass(ServiceClass) diff --git a/core/service-test-runner/icedfrisby-shields.js b/core/service-test-runner/icedfrisby-shields.js index b6ab1dff0ebb7..982911e90d824 100644 --- a/core/service-test-runner/icedfrisby-shields.js +++ b/core/service-test-runner/icedfrisby-shields.js @@ -49,14 +49,29 @@ const factory = superclass => return this } - expectBadge({ label, message, logoWidth, labelColor, color, link }) { + expectBadge(badge) { + const expectedKeys = [ + 'label', + 'message', + 'logoWidth', + 'labelColor', + 'color', + 'link', + ] + + for (const key of Object.keys(badge)) { + if (!expectedKeys.includes(key)) { + throw new Error(`Found unexpected object key '${key}'`) + } + } + return this.afterJSON(json => { - this.constructor._expectField(json, 'label', label) - this.constructor._expectField(json, 'message', message) - this.constructor._expectField(json, 'logoWidth', logoWidth) - this.constructor._expectField(json, 'labelColor', labelColor) - this.constructor._expectField(json, 'color', color) - this.constructor._expectField(json, 'link', link) + this.constructor._expectField(json, 'label', badge.label) + this.constructor._expectField(json, 'message', badge.message) + this.constructor._expectField(json, 'logoWidth', badge.logoWidth) + this.constructor._expectField(json, 'labelColor', badge.labelColor) + this.constructor._expectField(json, 'color', badge.color) + this.constructor._expectField(json, 'link', badge.link) }) } @@ -76,11 +91,11 @@ const factory = superclass => Joi.attempt( json[name], Joi.string().regex(expected), - `${name} mismatch:` + `${name} mismatch:`, ) } else { throw new Error( - "'expected' must be a string, a number, a regex, an array or a Joi schema" + "'expected' must be a string, a number, a regex, an array or a Joi schema", ) } } diff --git a/core/service-test-runner/infer-pull-request.js b/core/service-test-runner/infer-pull-request.js deleted file mode 100644 index 91a46cadb857d..0000000000000 --- a/core/service-test-runner/infer-pull-request.js +++ /dev/null @@ -1,100 +0,0 @@ -/** - * @module - */ - -import { URL, format as urlFormat } from 'url' - -function formatSlug(owner, repo, pullRequest) { - return `${owner}/${repo}#${pullRequest}` -} - -function parseGithubPullRequestUrl(url, options = {}) { - const { verifyBaseUrl } = options - - const parsed = new URL(url) - const components = parsed.pathname.substr(1).split('/') - if (components[2] !== 'pull' || components.length !== 4) { - throw Error(`Invalid GitHub pull request URL: ${url}`) - } - const [owner, repo, , pullRequest] = components - - parsed.pathname = '' - const baseUrl = urlFormat(parsed, { - auth: false, - fragment: false, - search: false, - }).replace(/\/$/, '') - - if (verifyBaseUrl && baseUrl !== verifyBaseUrl) { - throw Error(`Expected base URL to be ${verifyBaseUrl} but got ${baseUrl}`) - } - - return { - baseUrl, - owner, - repo, - pullRequest: +pullRequest, - slug: formatSlug(owner, repo, pullRequest), - } -} - -function parseGithubRepoSlug(slug) { - const components = slug.split('/') - if (components.length !== 2) { - throw Error(`Invalid GitHub repo slug: ${slug}`) - } - const [owner, repo] = components - return { owner, repo } -} - -function _inferPullRequestFromTravisEnv(env) { - const { owner, repo } = parseGithubRepoSlug(env.TRAVIS_REPO_SLUG) - const pullRequest = +env.TRAVIS_PULL_REQUEST - return { - owner, - repo, - pullRequest, - slug: formatSlug(owner, repo, pullRequest), - } -} - -function _inferPullRequestFromCircleEnv(env) { - return parseGithubPullRequestUrl(env.CI_PULL_REQUEST) -} - -/** - * When called inside a CI build, infer the details - * of a pull request from the environment variables. - * - * @param {object} [env=process.env] Environment variables - * @returns {module:core/service-test-runner/infer-pull-request~PullRequest} - * Pull Request - */ -function inferPullRequest(env = process.env) { - if (env.TRAVIS) { - return _inferPullRequestFromTravisEnv(env) - } else if (env.CIRCLECI) { - return _inferPullRequestFromCircleEnv(env) - } else if (env.CI) { - throw Error( - 'Unsupported CI system. Unable to obtain pull request information from the environment.' - ) - } else { - throw Error( - 'Unable to obtain pull request information from the environment. Is this running in CI?' - ) - } -} - -/** - * Pull Request - * - * @typedef PullRequest - * @property {string} pr.baseUrl (returned for travis CI only) - * @property {string} owner - * @property {string} repo - * @property {string} pullRequest PR/issue number - * @property {string} slug owner/repo/#pullRequest - */ - -export { parseGithubPullRequestUrl, parseGithubRepoSlug, inferPullRequest } diff --git a/core/service-test-runner/infer-pull-request.spec.js b/core/service-test-runner/infer-pull-request.spec.js deleted file mode 100644 index c2050d533e406..0000000000000 --- a/core/service-test-runner/infer-pull-request.spec.js +++ /dev/null @@ -1,48 +0,0 @@ -import { test, given, forCases } from 'sazerac' -import { - parseGithubPullRequestUrl, - inferPullRequest, -} from './infer-pull-request.js' - -describe('Pull request inference', function () { - test(parseGithubPullRequestUrl, () => { - forCases([ - given('https://github.com/badges/shields/pull/1234'), - given('https://github.com/badges/shields/pull/1234', { - verifyBaseUrl: 'https://github.com', - }), - ]).expect({ - baseUrl: 'https://github.com', - owner: 'badges', - repo: 'shields', - pullRequest: 1234, - slug: 'badges/shields#1234', - }) - - given('https://github.com/badges/shields/pull/1234', { - verifyBaseUrl: 'https://example.com', - }).expectError( - 'Expected base URL to be https://example.com but got https://github.com' - ) - }) - - test(inferPullRequest, () => { - const expected = { - owner: 'badges', - repo: 'shields', - pullRequest: 1234, - slug: 'badges/shields#1234', - } - - given({ - CIRCLECI: '1', - CI_PULL_REQUEST: 'https://github.com/badges/shields/pull/1234', - }).expect(Object.assign({ baseUrl: 'https://github.com' }, expected)) - - given({ - TRAVIS: '1', - TRAVIS_REPO_SLUG: 'badges/shields', - TRAVIS_PULL_REQUEST: '1234', - }).expect(expected) - }) -}) diff --git a/core/service-test-runner/pull-request-services-cli.js b/core/service-test-runner/pull-request-services-cli.js index 34d8d20993c78..a0c307c20fd5b 100644 --- a/core/service-test-runner/pull-request-services-cli.js +++ b/core/service-test-runner/pull-request-services-cli.js @@ -1,5 +1,5 @@ -// Infer the current PR from the Travis environment, and look for bracketed, -// space-separated service names in the pull request title. +// Derive a list of service tests to run based on +// space-separated service names in the PR title. // // Output the list of services. // @@ -8,54 +8,26 @@ // Output: // travis // sonar -// -// Example: -// -// TRAVIS=1 TRAVIS_REPO_SLUG=badges/shields TRAVIS_PULL_REQUEST=1108 npm run test:services:pr:prepare -import got from 'got' -import { inferPullRequest } from './infer-pull-request.js' import servicesForTitle from './services-for-title.js' -async function getTitle(owner, repo, pullRequest) { - const { - body: { title }, - } = await got( - `https://api.github.com/repos/${owner}/${repo}/pulls/${pullRequest}`, - { - headers: { - 'User-Agent': 'badges/shields', - Authorization: `token ${process.env.GITHUB_TOKEN}`, - }, - responseType: 'json', - } - ) - return title -} - -async function main() { - const { owner, repo, pullRequest, slug } = inferPullRequest() - console.error(`PR: ${slug}`) +let title - const title = await getTitle(owner, repo, pullRequest) - - console.error(`Title: ${title}\n`) - const services = servicesForTitle(title) - if (services.length === 0) { - console.error('No services found. Nothing to do.') - } else { - console.error( - `Services: (${services.length} found) ${services.join(', ')}\n` - ) - console.log(services.join('\n')) +try { + if (process.argv.length < 3) { + throw new Error() } + title = process.argv[2] +} catch (e) { + console.error('Error processing arguments') + process.exit(1) } -;(async () => { - try { - await main() - } catch (e) { - console.error(e) - process.exit(1) - } -})() +console.error(`Title: ${title}\n`) +const services = servicesForTitle(title) +if (services.length === 0) { + console.error('No services found. Nothing to do.') +} else { + console.error(`Services: (${services.length} found) ${services.join(', ')}\n`) + console.log(services.join('\n')) +} diff --git a/core/service-test-runner/runner.js b/core/service-test-runner/runner.js index 4b6cf47f89863..4f37bee3cae37 100644 --- a/core/service-test-runner/runner.js +++ b/core/service-test-runner/runner.js @@ -25,7 +25,7 @@ class Runner { */ async prepare() { this.testers = (await loadTesters()).flatMap(testerModule => - Object.values(testerModule) + Object.values(testerModule), ) this.testers.forEach(tester => { tester.beforeEach = () => { diff --git a/core/service-test-runner/service-tester.js b/core/service-test-runner/service-tester.js index 3cc94c5d55f3b..35f2fbf8c0751 100644 --- a/core/service-test-runner/service-tester.js +++ b/core/service-test-runner/service-tester.js @@ -46,7 +46,7 @@ class ServiceTester { * * @param {Function} ServiceClass * A class that extends base-service/base.BaseService - * @returns {module:core/service-test-runner/service-tester~ServiceTester} + * @returns {ServiceTester} * ServiceTester for ServiceClass */ static forServiceClass(ServiceClass) { @@ -83,7 +83,7 @@ class ServiceTester { .before(() => { this.beforeEach() }) - // eslint-disable-next-line mocha/prefer-arrow-callback, promise/prefer-await-to-then + // eslint-disable-next-line promise/prefer-await-to-then .finally(function () { // `this` is the IcedFrisby instance. let responseBody diff --git a/core/service-test-runner/services-for-title.js b/core/service-test-runner/services-for-title.js index 862198fcea013..473afef8d46e0 100644 --- a/core/service-test-runner/services-for-title.js +++ b/core/service-test-runner/services-for-title.js @@ -2,8 +2,6 @@ * @module */ -import difference from 'lodash.difference' - /** * Given a pull request title like * '[Travis Sonar] Support user token authentication' @@ -27,7 +25,7 @@ function servicesForTitle(title) { services = services.filter(Boolean).map(service => service.toLowerCase()) const ignored = ['wip', 'rfc', 'security'] - return difference(services, ignored) + return services.filter(service => !ignored.includes(service)) } export default servicesForTitle diff --git a/core/service-test-runner/services-for-title.spec.js b/core/service-test-runner/services-for-title.spec.js index f89f23d8f5af2..3d24af8170894 100644 --- a/core/service-test-runner/services-for-title.spec.js +++ b/core/service-test-runner/services-for-title.spec.js @@ -10,7 +10,7 @@ describe('Services from PR title', function () { ]) given('[CRAN CPAN CTAN] Add test coverage').expect(['cran', 'cpan', 'ctan']) given( - '[RFC] Add Joi-based request validation to BaseJsonService and rewrite [NPM] badges' + '[RFC] Add Joi-based request validation to BaseJsonService and rewrite [NPM] badges', ).expect(['npm']) given('make changes to [CRAN] and [CPAN]').expect(['cran', 'cpan']) given('[github appveyor ]').expect(['github', 'appveyor']) diff --git a/core/token-pooling/redis-token-persistence.integration.js b/core/token-pooling/redis-token-persistence.integration.js deleted file mode 100644 index ca1d65cd9b4a4..0000000000000 --- a/core/token-pooling/redis-token-persistence.integration.js +++ /dev/null @@ -1,95 +0,0 @@ -import RedisServer from 'redis-server' -import Redis from 'ioredis' -import { expect } from 'chai' -import RedisTokenPersistence from './redis-token-persistence.js' - -describe('Redis token persistence', function () { - let server - // In CI, expect redis already to be running. - if (!process.env.CI) { - beforeEach(async function () { - server = new RedisServer({ config: { host: 'localhost' } }) - await server.open() - }) - } - - const key = 'tokenPersistenceIntegrationTest' - - let redis - beforeEach(async function () { - redis = new Redis() - await redis.del(key) - }) - afterEach(async function () { - if (redis) { - await redis.quit() - redis = undefined - } - }) - - if (!process.env.CI) { - afterEach(async function () { - await server.close() - server = undefined - }) - } - - let persistence - beforeEach(function () { - persistence = new RedisTokenPersistence({ key }) - }) - afterEach(async function () { - if (persistence) { - await persistence.stop() - persistence = undefined - } - }) - - context('when the key does not exist', function () { - it('does nothing', async function () { - const tokens = await persistence.initialize() - expect(tokens).to.deep.equal([]) - }) - }) - - context('when the key exists', function () { - const initialTokens = ['a', 'b', 'c'].map(char => char.repeat(40)) - - beforeEach(async function () { - await redis.sadd(key, initialTokens) - }) - - it('loads the contents', async function () { - const tokens = await persistence.initialize() - expect(tokens.sort()).to.deep.equal(initialTokens) - }) - - context('when tokens are added', function () { - it('saves the change', async function () { - const newToken = 'e'.repeat(40) - const expected = initialTokens.slice() - expected.push(newToken) - - await persistence.initialize() - await persistence.noteTokenAdded(newToken) - - const savedTokens = await redis.smembers(key) - expect(savedTokens.sort()).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 = await redis.smembers(key) - expect(savedTokens.sort()).to.deep.equal(expected) - }) - }) - }) -}) diff --git a/core/token-pooling/redis-token-persistence.js b/core/token-pooling/redis-token-persistence.js deleted file mode 100644 index dd0b6fb306702..0000000000000 --- a/core/token-pooling/redis-token-persistence.js +++ /dev/null @@ -1,57 +0,0 @@ -import { URL } from 'url' -import Redis from 'ioredis' -import log from '../server/log.js' - -export default class RedisTokenPersistence { - constructor({ url, key }) { - this.url = url - this.key = key - this.noteTokenAdded = this.noteTokenAdded.bind(this) - this.noteTokenRemoved = this.noteTokenRemoved.bind(this) - } - - async initialize() { - const options = - this.url && this.url.startsWith('rediss:') - ? { - // https://www.compose.com/articles/ssl-connections-arrive-for-redis-on-compose/ - tls: { servername: new URL(this.url).hostname }, - } - : undefined - this.redis = new Redis(this.url, options) - this.redis.on('error', e => { - log.error(e) - }) - - const tokens = await this.redis.smembers(this.key) - return tokens - } - - async stop() { - await this.redis.quit() - } - - async onTokenAdded(token) { - await this.redis.sadd(this.key, token) - } - - 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) - } - } -} diff --git a/core/token-pooling/sql-token-persistence.integration.js b/core/token-pooling/sql-token-persistence.integration.js new file mode 100644 index 0000000000000..9658d9d2b0b7f --- /dev/null +++ b/core/token-pooling/sql-token-persistence.integration.js @@ -0,0 +1,105 @@ +import pg from 'pg' +import { expect } from 'chai' +import configModule from 'config' +import SqlTokenPersistence from './sql-token-persistence.js' + +const config = configModule.util.toObject() +const postgresUrl = config?.private?.postgres_url +const tableName = 'token_persistence_integration_test' + +describe('SQL token persistence', function () { + let pool + let persistence + + before('Mock db connection and load app', async function () { + // Create a new pool with a connection limit of 1 + pool = new pg.Pool({ + connectionString: postgresUrl, + + // Reuse the connection to make sure we always hit the same pg_temp schema + max: 1, + + // Disable auto-disconnection of idle clients to make sure we always hit the same pg_temp schema + idleTimeoutMillis: 0, + }) + persistence = new SqlTokenPersistence({ + url: postgresUrl, + table: tableName, + }) + }) + after(async function () { + if (persistence) { + await persistence.stop() + persistence = undefined + } + }) + + beforeEach('Create temporary table', async function () { + await pool.query( + `CREATE TEMPORARY TABLE ${tableName} (LIKE github_user_tokens INCLUDING ALL);`, + ) + }) + afterEach('Drop temporary table', async function () { + await pool.query(`DROP TABLE IF EXISTS pg_temp.${tableName};`) + }) + + context('when the key does not exist', function () { + it('does nothing', async function () { + const tokens = await persistence.initialize(pool) + expect(tokens).to.deep.equal([]) + }) + }) + + context('when the key exists', function () { + const initialTokens = ['a', 'b', 'c'].map(char => char.repeat(40)) + + beforeEach(async function () { + await Promise.all( + initialTokens.map(token => + pool.query( + `INSERT INTO pg_temp.${tableName} (token) VALUES ($1::text);`, + [token], + ), + ), + ) + }) + + it('loads the contents', async function () { + const tokens = await persistence.initialize(pool) + expect(tokens.sort()).to.deep.equal(initialTokens) + }) + + context('when tokens are added', function () { + it('saves the change', async function () { + const newToken = 'e'.repeat(40) + const expected = initialTokens.slice() + expected.push(newToken) + + await persistence.initialize(pool) + await persistence.noteTokenAdded(newToken) + + const result = await pool.query( + `SELECT token FROM pg_temp.${tableName};`, + ) + const savedTokens = result.rows.map(row => row.token) + expect(savedTokens.sort()).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(pool) + await persistence.noteTokenRemoved(toRemove) + + const result = await pool.query( + `SELECT token FROM pg_temp.${tableName};`, + ) + const savedTokens = result.rows.map(row => row.token) + expect(savedTokens.sort()).to.deep.equal(expected) + }) + }) + }) +}) diff --git a/core/token-pooling/sql-token-persistence.js b/core/token-pooling/sql-token-persistence.js new file mode 100644 index 0000000000000..610df1b973c09 --- /dev/null +++ b/core/token-pooling/sql-token-persistence.js @@ -0,0 +1,59 @@ +import pg from 'pg' +import log from '../server/log.js' + +export default class SqlTokenPersistence { + constructor({ url, table }) { + this.url = url + this.table = table + this.noteTokenAdded = this.noteTokenAdded.bind(this) + this.noteTokenRemoved = this.noteTokenRemoved.bind(this) + } + + async initialize(pool) { + if (pool) { + this.pool = pool + } else { + this.pool = new pg.Pool({ connectionString: this.url }) + } + const result = await this.pool.query( + `SELECT token FROM ${this.table} ORDER BY RANDOM();`, + ) + return result.rows.map(row => row.token) + } + + async stop() { + if (this.pool) { + await this.pool.end() + } + } + + async onTokenAdded(token) { + return await this.pool.query( + `INSERT INTO ${this.table} (token) VALUES ($1::text) ON CONFLICT (token) DO NOTHING;`, + [token], + ) + } + + async onTokenRemoved(token) { + return await this.pool.query( + `DELETE FROM ${this.table} WHERE token=$1::text;`, + [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) + } + } +} diff --git a/core/token-pooling/token-pool.js b/core/token-pooling/token-pool.js index 7dcf64ad5abdf..c650e3e938f56 100644 --- a/core/token-pooling/token-pool.js +++ b/core/token-pooling/token-pool.js @@ -45,6 +45,7 @@ class Token { _nextReset: nextReset, _isValid: true, _isFrozen: false, + _failedAttempts: 0, }) } @@ -72,6 +73,10 @@ class Token { return this._isFrozen } + get failedAttempts() { + return this._failedAttempts + } + get hasReset() { return getUtcEpochSeconds() >= this.nextReset } @@ -80,6 +85,10 @@ class Token { return this.usesRemaining <= 0 && !this.hasReset } + get decrementedUsesRemaining() { + return this._usesRemaining - 1 + } + /** * Update the uses remaining and next reset time for a token. * @@ -124,6 +133,23 @@ class Token { this._isValid = false } + /** + * Record a failed authentication attempt (HTTP 401) for this token. + * + * @returns {number} The number of consecutive failed attempts so far. + */ + recordFailedAttempt() { + this._failedAttempts += 1 + return this._failedAttempts + } + + /** + * Reset the failed-attempt counter, e.g. after a successful response. + */ + resetFailedAttempts() { + this._failedAttempts = 0 + } + /** * Freeze the uses remaining and next reset values. Helpful for keeping * stable ordering for a valid priority queue. @@ -191,9 +217,9 @@ class TokenPool { /** * compareTokens * - * @param {module:core/token-pooling/token-pool~Token} first first token to compare - * @param {module:core/token-pooling/token-pool~Token} second second token to compare - * @returns {module:core/token-pooling/token-pool~Token} The token whose current rate allotment is expiring soonest. + * @param {Token} first first token to compare + * @param {Token} second second token to compare + * @returns {Token} The token whose current rate allotment is expiring soonest. */ static compareTokens(first, second) { return second.nextReset - first.nextReset @@ -291,7 +317,7 @@ class TokenPool { * new use-remaining count and next-reset time. Invoke `invalidate()` to * indicate it should not be reused. * - * @returns {module:core/token-pooling/token-pool~Token} token + * @returns {Token} token */ next() { let token = this.currentBatch.token @@ -313,6 +339,20 @@ class TokenPool { return token } + /** + * Abandon the current batch if it belongs to `token`, so that the next call + * to `next()` rotates to a different token. The token stays in the rotation + * (it was already returned to the FIFO queue when its batch began) and is + * retried when it next reaches the front of the queue. + * + * @param {Token} token the token whose batch should be ended + */ + endBatchFor(token) { + if (this.currentBatch.token === token) { + this.currentBatch.remaining = 0 + } + } + /** * Iterate over all valid tokens. * @@ -329,25 +369,26 @@ class TokenPool { this.priorityQueue.forEach(visit) } - allValidTokenIds() { - const result = [] - this.forEach(({ id }) => result.push(id)) - return result - } - + /** + * Serialize debug information about the token pool. + * + * @param {object} options Options object + * @param {boolean} options.sanitize Whether to sanitize token IDs (default: true) + * @returns {object} Debug information about the token pool + */ serializeDebugInfo({ sanitize = true } = {}) { - const maybeSanitize = sanitize ? id => sanitizeToken(id) : id => id + const allTokenDebugInfo = [] + let totalUsesRemaining = 0 - const priorityQueue = [] - this.priorityQueue.forEach(t => - priorityQueue.push(t.getDebugInfo({ sanitize })) - ) + this.forEach(token => { + totalUsesRemaining += token.usesRemaining + allTokenDebugInfo.push(token.getDebugInfo({ sanitize })) + }) return { utcEpochSeconds: getUtcEpochSeconds(), - allValidTokenIds: this.allValidTokenIds().map(maybeSanitize), - fifoQueue: this.fifoQueue.map(t => t.getDebugInfo({ sanitize })), - priorityQueue, + totalUsesRemaining, + allTokenDebugInfo, sanitized: sanitize, } } diff --git a/core/token-pooling/token-pool.spec.js b/core/token-pooling/token-pool.spec.js index fdfa937d200a1..7363e4a0ec386 100644 --- a/core/token-pooling/token-pool.spec.js +++ b/core/token-pooling/token-pool.spec.js @@ -1,8 +1,9 @@ import { expect } from 'chai' import sinon from 'sinon' -import times from 'lodash.times' import { Token, TokenPool } from './token-pool.js' +const times = (n, fn) => [...Array(n)].map(() => fn()) + function expectPoolToBeExhausted(pool) { expect(() => { pool.next() @@ -19,86 +20,21 @@ describe('The token pool', function () { ids.forEach(id => tokenPool.add(id)) }) - it('allValidTokenIds() should return the full list', function () { - expect(tokenPool.allValidTokenIds()).to.deep.equal(ids) - }) - it('should yield the expected tokens', function () { ids.forEach(id => - times(batchSize, () => expect(tokenPool.next().id).to.equal(id)) + times(batchSize, () => expect(tokenPool.next().id).to.equal(id)), ) }) it('should repeat when reaching the end', function () { ids.forEach(id => - times(batchSize, () => expect(tokenPool.next().id).to.equal(id)) + times(batchSize, () => expect(tokenPool.next().id).to.equal(id)), ) ids.forEach(id => - times(batchSize, () => expect(tokenPool.next().id).to.equal(id)) + times(batchSize, () => expect(tokenPool.next().id).to.equal(id)), ) }) - describe('serializeDebugInfo should initially return the expected', function () { - beforeEach(function () { - sinon.useFakeTimers({ now: 1544307744484 }) - }) - - afterEach(function () { - sinon.restore() - }) - - context('sanitize is not specified', function () { - it('returns fully sanitized results', function () { - // This is `sha()` of '1', '2', '3', '4', '5'. These are written - // literally for avoidance of doubt as to whether sanitization is - // happening. - const sanitizedIds = [ - '6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b', - 'd4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35', - '4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce', - '4b227777d4dd1fc61c6f884f48641d02b4d121d3fd328cb08b5531fcacdabf8a', - 'ef2d127de37b942baad06145e54b0c619a1f22327b2ebbcfbec78f5564afe39d', - ] - - expect(tokenPool.serializeDebugInfo()).to.deep.equal({ - allValidTokenIds: sanitizedIds, - priorityQueue: [], - fifoQueue: sanitizedIds.map(id => ({ - data: '[redacted]', - id, - isFrozen: false, - isValid: true, - nextReset: Token.nextResetNever, - usesRemaining: batchSize, - })), - sanitized: true, - utcEpochSeconds: 1544307744, - }) - }) - }) - - context('with sanitize: false', function () { - it('returns unsanitized results', function () { - expect(tokenPool.serializeDebugInfo({ sanitize: false })).to.deep.equal( - { - allValidTokenIds: ids, - priorityQueue: [], - fifoQueue: ids.map(id => ({ - data: undefined, - id, - isFrozen: false, - isValid: true, - nextReset: Token.nextResetNever, - usesRemaining: batchSize, - })), - sanitized: false, - utcEpochSeconds: 1544307744, - } - ) - }) - }) - }) - context('tokens are marked exhausted immediately', function () { it('should be exhausted', function () { ids.forEach(() => { @@ -187,4 +123,90 @@ describe('The token pool', function () { expect(() => tokenPool.next()).to.throw('Token pool is exhausted') }) }) + + context('failed attempts', function () { + it('should count and reset consecutive failed attempts', function () { + const token = new Token('1') + expect(token.failedAttempts).to.equal(0) + token.recordFailedAttempt() + token.recordFailedAttempt() + expect(token.failedAttempts).to.equal(2) + token.resetFailedAttempts() + expect(token.failedAttempts).to.equal(0) + }) + }) + + context('endBatchFor()', function () { + it('rotates to a different token on the next call', function () { + const first = tokenPool.next() + tokenPool.endBatchFor(first) + expect(tokenPool.next().id).to.not.equal(first.id) + }) + + it('keeps the abandoned token in the rotation', function () { + const first = tokenPool.next() + tokenPool.endBatchFor(first) + // Cycle through the rest of the pool; the abandoned token should reappear + // once the other tokens' batches have been served. + const seen = times(ids.length * batchSize, () => tokenPool.next().id) + expect(seen).to.include(first.id) + }) + + it('does nothing when the token is not the current batch', function () { + const first = tokenPool.next() + const other = new Token('not-in-batch') + tokenPool.endBatchFor(other) + // first's batch is untouched, so it is returned again. + expect(tokenPool.next().id).to.equal(first.id) + }) + }) + + context('serializeDebugInfo()', function () { + let clock + beforeEach(function () { + clock = sinon.useFakeTimers({ now: 1544307744484 }) + }) + afterEach(function () { + clock.restore() + }) + + it('should return sanitized debug info', function () { + const debugInfo = tokenPool.serializeDebugInfo() + + expect(debugInfo.utcEpochSeconds).to.equal(1544307744) + expect(debugInfo.totalUsesRemaining).to.equal(ids.length * batchSize) + expect(debugInfo.allTokenDebugInfo).to.have.lengthOf(ids.length) + expect(debugInfo.sanitized).to.equal(true) + debugInfo.allTokenDebugInfo.forEach(tokenInfo => { + expect(tokenInfo.id).to.be.a('string') + expect(tokenInfo.id).to.have.lengthOf(64) // SHA-256 hex is 64 chars + expect(tokenInfo.data).to.equal('[redacted]') + expect(tokenInfo.usesRemaining).to.equal(batchSize) + expect(tokenInfo.nextReset).to.equal(Token.nextResetNever) + expect(tokenInfo.isValid).to.equal(true) + expect(tokenInfo.isFrozen).to.equal(false) + }) + }) + + it('should return unsanitized debug info', function () { + const debugInfo = tokenPool.serializeDebugInfo({ sanitize: false }) + + expect(debugInfo.sanitized).to.equal(false) + debugInfo.allTokenDebugInfo.forEach((tokenInfo, index) => { + expect(tokenInfo.id).to.equal(ids[index]) + }) + }) + + it('should exclude invalidated tokens', function () { + const token = tokenPool.next() + token.invalidate() + + const debugInfo = tokenPool.serializeDebugInfo() + + expect(debugInfo.allTokenDebugInfo).to.have.lengthOf(ids.length - 1) + expect(debugInfo.totalUsesRemaining).to.equal( + (ids.length - 1) * batchSize, + ) + }) + }) }) diff --git a/cypress.config.js b/cypress.config.js new file mode 100644 index 0000000000000..ca06eb960e58b --- /dev/null +++ b/cypress.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from 'cypress' + +export default defineConfig({ + fixturesFolder: false, + expose: { + backend_url: 'http://localhost:8080', + }, + e2e: { + setupNodeEvents(on, config) {}, + baseUrl: 'http://localhost:3000', + supportFile: false, + }, + video: true, + videoCompression: true, + allowCypressEnv: false, +}) diff --git a/cypress.json b/cypress.json deleted file mode 100644 index a52a02bf750b3..0000000000000 --- a/cypress.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "baseUrl": "http://localhost:3000", - "fixturesFolder": false, - "pluginsFile": false, - "supportFile": false, - "env": { - "backend_url": "http://localhost:8080" - } -} diff --git a/cypress/e2e/main-page.cy.js b/cypress/e2e/main-page.cy.js new file mode 100644 index 0000000000000..43fbbae84d286 --- /dev/null +++ b/cypress/e2e/main-page.cy.js @@ -0,0 +1,55 @@ +import { registerCommand } from 'cypress-wait-for-stable-dom' + +registerCommand() + +describe('Frontend', function () { + const backendUrl = Cypress.expose('backend_url') + const SEARCH_INPUT = 'input[placeholder="Search"]' + + function visitAndWait(page) { + cy.visit(page) + cy.waitForStableDOM({ pollInterval: 1000, timeout: 10000 }) + } + + it('Search for badges', function () { + visitAndWait('/') + + cy.get(SEARCH_INPUT).type('pypi') + + cy.contains('PyPI - License') + }) + + it('Shows badges from category', function () { + visitAndWait('/badges') + + cy.contains('Build') + cy.contains('Chat').click() + + cy.contains('Discourse Status') + cy.contains('Stack Exchange questions') + }) + + it('Shows expected code examples', function () { + visitAndWait('/badges/static-badge') + + cy.contains('button', 'URL').should('have.class', 'api-code-tab') + cy.contains('button', 'Markdown').should('have.class', 'api-code-tab') + cy.contains('button', 'rSt').should('have.class', 'api-code-tab') + cy.contains('button', 'AsciiDoc').should('have.class', 'api-code-tab') + cy.contains('button', 'HTML').should('have.class', 'api-code-tab') + }) + + it('Build a badge', function () { + visitAndWait('/badges/git-hub-license') + + cy.contains('/github/license/:user/:repo') + + cy.get('input[placeholder="user"]').type('badges') + cy.get('input[placeholder="repo"]').type('shields') + + cy.intercept('GET', `${backendUrl}/github/license/badges/shields`).as('get') + cy.contains('Execute').click() + cy.wait('@get').its('response.statusCode').should('eq', 200) + cy.get('img[id="badge-preview"]') + }) +}) diff --git a/cypress/integration/main-page.spec.js b/cypress/integration/main-page.spec.js deleted file mode 100644 index 009371ecffb80..0000000000000 --- a/cypress/integration/main-page.spec.js +++ /dev/null @@ -1,72 +0,0 @@ -describe('Main page', function () { - const backendUrl = Cypress.env('backend_url') - const SEARCH_INPUT = 'input[placeholder="search / project URL"]' - - function expectBadgeExample(title, previewUrl, pattern) { - cy.contains('tr', `${title}:`).find('code').should('have.text', pattern) - cy.contains('tr', `${title}:`) - .find('img') - .should('have.attr', 'src', previewUrl) - } - - it('Search for badges', function () { - cy.visit('/') - - cy.get(SEARCH_INPUT).type('pypi') - - cy.contains('PyPI - License') - }) - - it('Shows badge from category', function () { - cy.visit('/category/chat') - - expectBadgeExample( - 'Discourse status', - 'http://localhost:8080/badge/discourse-online-brightgreen', - '/discourse/status?server=https%3A%2F%2Fmeta.discourse.org' - ) - }) - - it('Suggest badges', function () { - const badgeUrl = `${backendUrl}/github/issues/badges/shields` - cy.visit('/') - - cy.get(SEARCH_INPUT).type('https://github.com/badges/shields') - cy.contains('Suggest badges').click() - - expectBadgeExample('GitHub issues', badgeUrl, badgeUrl) - }) - - it('Customization form is filled with suggested badge details', function () { - const badgeUrl = `${backendUrl}/github/issues/badges/shields` - cy.visit('/') - cy.get(SEARCH_INPUT).type('https://github.com/badges/shields') - cy.contains('Suggest badges').click() - - cy.contains(badgeUrl).click() - - cy.get('input[name="user"]').should('have.value', 'badges') - cy.get('input[name="repo"]').should('have.value', 'shields') - }) - - it('Customizate suggested badge', function () { - const badgeUrl = `${backendUrl}/github/issues/badges/shields` - cy.visit('/') - cy.get(SEARCH_INPUT).type('https://github.com/badges/shields') - cy.contains('Suggest badges').click() - cy.contains(badgeUrl).click() - - cy.get('table input[name="color"]').type('orange') - - cy.get(`img[src='${backendUrl}/github/issues/badges/shields?color=orange']`) - }) - - it('Do not duplicate example parameters', function () { - cy.visit('/category/funding') - - cy.contains('GitHub Sponsors').click() - cy.get('[name="style"]').should($style => { - expect($style).to.have.length(1) - }) - }) -}) diff --git a/dangerfile.js b/dangerfile.js index f59f587886e72..423b79ff99611 100644 --- a/dangerfile.js +++ b/dangerfile.js @@ -1,5 +1,3 @@ -'use strict' - // Have you identified a contributing guideline that should be included here? // Please open a pull request! // @@ -10,20 +8,18 @@ // To test changes locally: // DANGER_GITHUB_API_TOKEN=your-github-api-token npm run danger -- pr https://github.com/badges/shields/pull/2665 -const { danger, fail, message, warn } = require('danger') -const { default: noTestShortcuts } = require('danger-plugin-no-test-shortcuts') +import { danger, fail, message, warn } from 'danger' const { fileMatch } = danger.git const documentation = fileMatch( '**/*.md', - 'frontend/components/usage.tsx', - 'frontend/pages/endpoint.tsx' + 'frontend/docs/**', + 'frontend/src/**', ) const server = fileMatch('core/server/**.js', '!*.spec.js') const serverTests = fileMatch('core/server/**.spec.js') const legacyHelpers = fileMatch('lib/**/*.js', '!*.spec.js') const legacyHelperTests = fileMatch('lib/**/*.spec.js') -const logos = fileMatch('logo/*.svg') const packageJson = fileMatch('package.json') const packageLock = fileMatch('package-lock.json') const secretsDocs = fileMatch('doc/server-secrets.md') @@ -34,13 +30,13 @@ message( [ ':sparkles: Thanks for your contribution to Shields, ', `@${danger.github.pr.user.login}!`, - ].join('') + ].join(''), ) const targetBranch = danger.github.pr.base.ref if (targetBranch !== 'master') { const message = `This PR targets \`${targetBranch}\`` - const idea = 'It is likely that the target branch should be `master`' + const idea = 'It is likely that the target branch should be `master`.' warn(`${message} - ${idea}`) } @@ -49,7 +45,7 @@ if (documentation.edited) { [ 'Thanks for contributing to our documentation. ', 'We :heart: our [documentarians](http://www.writethedocs.org/)!', - ].join('') + ].join(''), ) } @@ -64,7 +60,7 @@ if (server.modified && !serverTests.modified) { [ 'This PR modified the server but none of its tests.
', "That's okay so long as it's refactoring existing code.", - ].join('') + ].join(''), ) } @@ -75,18 +71,7 @@ if (legacyHelpers.created) { [ 'This PR modified helper functions in `lib/` but not accompanying tests.
', "That's okay so long as it's refactoring existing code.", - ].join('') - ) -} - -if (logos.created) { - message( - [ - ':art: Thanks for submitting a logo.
', - 'Please ensure your contribution follows our ', - '[guidance](https://github.com/badges/shields/blob/master/doc/logos.md#contributing-logos) ', - 'for logo submissions.', - ].join('') + ].join(''), ) } @@ -95,7 +80,7 @@ if (capitals.created || underscores.created) { [ 'JavaScript source files should be named with `kebab-case` ', '(dash-separated lowercase).', - ].join('') + ].join(''), ) } @@ -114,10 +99,10 @@ if (allFiles.length > 100) { if (diff.includes('authHelper') && !secretsDocs.modified) { warn( [ - `:books: Remember to ensure any changes to \`config.private\` `, + ':books: Remember to ensure any changes to `config.private` ', `in \`${file}\` are reflected in the [server secrets documentation]`, - '(https://github.com/badges/shields/blob/master/doc/server-secrets.md)', - ].join('') + '(https://github.com/badges/shields/blob/master/doc/server-secrets.md).', + ].join(''), ) } @@ -126,8 +111,8 @@ if (allFiles.length > 100) { [ `Found 'assert' statement added in \`${file}\`.
`, 'Please ensure tests are written using Chai ', - '[expect syntax](http://chaijs.com/guide/styles/#expect)', - ].join('') + '[expect syntax](http://chaijs.com/guide/styles/#expect).', + ].join(''), ) } @@ -136,7 +121,7 @@ if (allFiles.length > 100) { [ `Found import of '@hapi/joi' in \`${file}\`.
`, "Joi must be imported as 'joi'.", - ].join('') + ].join(''), ) } }) @@ -169,15 +154,40 @@ affectedServices.forEach(service => { [ `This PR modified service code for ${service} but not its test code.
`, "That's okay so long as it's refactoring existing code.", - ].join('') + ].join(''), ) } }) -// Prevent merging exclusive services tests. -noTestShortcuts({ - testFilePredicate: filePath => filePath.endsWith('.tester.js'), - patterns: { - only: ['only()'], - }, -}) +allFiles + .filter(file => file.match(/^services\/(.+)\/.+\.service.js$/)) + .forEach(file => { + // eslint-disable-next-line promise/prefer-await-to-then + danger.git.diffForFile(file).then(({ diff }) => { + if ( + diff.match( + /^\+.*(base|pattern): '.*(download|install|license|version|release).*'/m, + ) + ) { + warn( + [ + `Found badge URL that may not follow our standard route abbreviations in \`${file}\`.
`, + "Please ensure you've reviewed our [conventions]", + '(https://github.com/badges/shields/blob/master/doc/badge-urls.md).', + ].join(''), + ) + } + }) + }) + +if (affectedServices.length > 0 || testedServices.length > 0) { + if (!/\[.+?\]/.test(danger.github.pr.title)) { + warn( + [ + 'This PR modified service code.
', + 'Please run tests by [including affected services in the pull request title]', + '(https://github.com/badges/shields/blob/master/CONTRIBUTING.md#running-service-tests-in-pull-requests).', + ].join(''), + ) + } +} diff --git a/doc/TUTORIAL.md b/doc/TUTORIAL.md index 7a5b2d28adffc..fe81807bdf94a 100644 --- a/doc/TUTORIAL.md +++ b/doc/TUTORIAL.md @@ -1,17 +1,12 @@ # Tutorial on how to add a badge for a service -This tutorial should help you add a service to shields.io in form of a badge. -You will need to learn to use JavaScript, Git and GitHub, however, this document -will guide you through that journey if you are a beginner. -Please [improve the tutorial](https://github.com/badges/shields/edit/master/doc/TUTORIAL.md) while you read it. +This tutorial should help you add a service to shields.io in form of a badge. You will need to learn to use JavaScript, Git and GitHub, however, this document will guide you through that journey if you are a beginner. Please [improve the tutorial](https://github.com/badges/shields/edit/master/doc/TUTORIAL.md) while you read it. ## (1) Reading - [Contributing Guidance](../CONTRIBUTING.md) - [Documentation](https://contributing.shields.io/index.html) for the Shields Core API -- You can also read previous - [merged pull-requests with the 'service-badge' label](https://github.com/badges/shields/pulls?utf8=%E2%9C%93&q=is%3Apr+label%3Aservice-badge+is%3Amerged) - to see how other people implemented their badges. +- You can also read previous [merged pull-requests with the 'service-badge' label](https://github.com/badges/shields/pulls?utf8=%E2%9C%93&q=is%3Apr+label%3Aservice-badge+is%3Amerged) to see how other people implemented their badges. ## (2) Setup @@ -19,93 +14,69 @@ Please [improve the tutorial](https://github.com/badges/shields/edit/master/doc/ #### Git -You should have [git](https://git-scm.com/) installed. -If you do not, [install git](https://www.linode.com/docs/development/version-control/how-to-install-git-on-linux-mac-and-windows/) -and learn about the [GitHub workflow](http://try.github.io/). +You should have [git](https://git-scm.com/) installed. If you do not, [install git](https://www.linode.com/docs/development/version-control/how-to-install-git-on-linux-mac-and-windows/) and learn about the [GitHub workflow](http://try.github.io/). #### Node, NPM -Node >=14 and NPM >=7 is required. If you don't already have them, -install node and npm: https://nodejs.org/en/download/ +Node 22 and NPM 10.x or 11.x is required. If you don't already have them, install node and npm: https://nodejs.org/en/download/ ### Setup a dev install 1. [Fork](https://github.com/badges/shields/fork) this repository. -2. Clone the fork - `git clone git@github.com:YOURGITHUBUSERNAME/shields.git` +2. Clone the fork `git clone git@github.com:YOURGITHUBUSERNAME/shields.git` 3. `cd shields` -4. Install project dependencies - `npm ci` -5. Run the badge server and the frontend dev server - `npm start` +4. Install project dependencies `npm ci` +5. Run the badge server and the frontend dev server `npm start` 6. Visit the website to check the front-end is loaded: [http://localhost:3000/](http://localhost:3000/). In case you get the _"getaddrinfo ENOTFOUND localhost"_ error, visit [http://127.0.0.1:3000/](http://127.0.0.1:3000) instead or take a look at [this issue](https://github.com/angular/angular-cli/issues/2227#issuecomment-358036526). ## (3) Open an Issue -Before you want to implement your service, you may want to [open an issue](https://github.com/badges/shields/issues/new?template=3_Badge_request.md) and describe what you have in mind: +Before you want to implement your service, you may want to [open an issue](https://github.com/badges/shields/issues/new?template=3_Badge_request.yml) and describe what you have in mind: - What is the badge for? - Which API do you want to use? -You may additionally proceed to say what you want to work on. -This information allows other humans to help and build on your work. +You may additionally proceed to say what you want to work on. This information allows other humans to help and build on your work. ## (4) Implementing ### (4.1) Structure and Layout -Service badge code is stored in the [/services](https://github.com/badges/shields/tree/master/services/) directory. -Each service has a directory for its files: +Service badge code is stored in the [/services](https://github.com/badges/shields/tree/master/services/) directory. Each service has a directory for its files: -- In files ending with `.service.js`, you can find the code which handles - incoming requests and generates the badges. - Sometimes, code for a service can be re-used. - This might be the case when you add a badge for an API which is already used - by other badges. +- In files ending with `.service.js`, you can find the code which handles incoming requests and generates the badges. Sometimes, code for a service can be re-used. This might be the case when you add a badge for an API which is already used by other badges. Imagine a service that lives at https://img.shields.io/example/some-param-here. + - For services with a single badge, the badge code will generally be stored in `/services/example/example.service.js`. If you add a badge for a new API, create a new directory. - - For services with a single badge, the badge code will generally be stored in - `/services/example/example.service.js`. - If you add a badge for a new API, create a new directory. - - Example: [wercker](https://github.com/badges/shields/tree/master/services/wercker) - - - For service families with multiple badges we usually store the code for each - badge in its own file like this: + Example: [Docs.rs](https://github.com/badges/shields/tree/master/services/docsrs) + - For service families with multiple badges we usually store the code for each badge in its own file like this: - `/services/example/example-downloads.service.js` - `/services/example/example-version.service.js` etc. Example: [ruby gems](https://github.com/badges/shields/tree/master/services/gem) -- In files ending with `.tester.js`, you can find the code which uses - the shields server to test if the badges are generated correctly. - There is a [chapter on Tests][write tests]. +- In files ending with `.tester.js`, you can find the code which uses the shields server to test if the badges are generated correctly. There is a [chapter on Tests][write tests]. ### (4.2) Our First Badge -All service badge classes inherit from [BaseService] or another class which extends it. -Other classes implement useful behavior on top of [BaseService]. - -- [BaseJsonService](https://contributing.shields.io/module-core_base-service_base-json-BaseJsonService.html) - implements methods for performing requests to a JSON API and schema validation. -- [BaseXmlService](https://contributing.shields.io/module-core_base-service_base-xml-BaseXmlService.html) - implements methods for performing requests to an XML API and schema validation. -- [BaseYamlService](https://contributing.shields.io/module-core_base-service_base-yaml-BaseYamlService.html) - implements methods for performing requests to a YAML API and schema validation. -- [BaseSvgScrapingService](https://contributing.shields.io/module-core_base-service_base-svg-scraping-BaseSvgScrapingService.html) - implements methods for retrieving information from existing third-party badges. -- [BaseGraphqlService](https://contributing.shields.io/module-core_base-service_base-graphql-BaseGraphqlService.html) - implements methods for performing requests to a GraphQL API and schema validation. -- If you are contributing to a _service family_, you may define a common super - class for the badges or one may already exist. +All service badge classes inherit from [BaseService] or another class which extends it. Other classes implement useful behavior on top of [BaseService]. + +- [BaseJsonService](https://contributing.shields.io/module-core_base-service_base-json-BaseJsonService.html) implements methods for performing requests to a JSON API and schema validation. +- [BaseJsonlService](https://contributing.shields.io/module-core_base-service_base-jsonl-BaseJsonlService.html) implements methods for performing requests to a JSONL API and schema validation. +- [BaseXmlService](https://contributing.shields.io/module-core_base-service_base-xml-BaseXmlService.html) implements methods for performing requests to an XML API and schema validation. +- [BaseYamlService](https://contributing.shields.io/module-core_base-service_base-yaml-BaseYamlService.html) implements methods for performing requests to a YAML API and schema validation. +- [BaseTomlService](https://contributing.shields.io/module-core_base-service_base-toml-BaseTomlService.html) implements methods for performing requests to a TOML API and schema validation. +- [BaseSvgScrapingService](https://contributing.shields.io/module-core_base-service_base-svg-scraping-BaseSvgScrapingService.html) implements methods for retrieving information from existing third-party badges. +- [BaseGraphqlService](https://contributing.shields.io/module-core_base-service_base-graphql-BaseGraphqlService.html) implements methods for performing requests to a GraphQL API and schema validation. +- If you are contributing to a _service family_, you may define a common super class for the badges or one may already exist. [baseservice]: https://contributing.shields.io/module-core_base-service_base.html -As a first step we will look at the code for an example which generates a badge without contacting an API. +As a first step we will look at the code for an example which generates a badge without contacting an API. Note that lower camelCase should be used for variables, functions, and path/query parameters. ```js // (1) @@ -132,17 +103,14 @@ export default class Example extends BaseService { Description of the code: -1. Our service badge class will extend `BaseService` so we need to require it. Variables are declared with `const` and `let` in preference to `var`. +1. Our service badge class will extend `BaseService` so we need to import it. 2. Our module must export a class which extends `BaseService`. -3. 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. -4. `route()` declares the URL path at which the service operates. It also maps components of the URL path to handler parameters. +3. 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-documentation-to-the-frontend) for more details. +4. `route` declares the URL path at which the service operates. It also maps components of the URL path to handler parameters. - `base` defines the first part of the URL that doesn't change, e.g. `/example/`. - - `pattern` defines the variable part of the route, everything that comes after `/example/`. It can include any - number of named parameters. These are converted into - regular expressions by [`path-to-regexp`][path-to-regexp]. - Because a service instance won't be created until it's time to handle a request, the route and other metadata must be obtained by examining the classes themselves. [That's why they're marked `static`.][static] + - `pattern` defines the variable part of the route, everything that comes after `/example/`. It can include any number of named parameters. These are converted into regular expressions by [`path-to-regexp`][path-to-regexp]. Because a service instance won't be created until it's time to handle a request, the route and other metadata must be obtained by examining the classes themselves. [That's why they're marked `static`.][static] - There is additional documentation on conventions for [designing badge URLs](./badge-urls.md) -5. All badges must implement the `async handle()` function that receives parameters to render the badge. Parameters of `handle()` will match the name defined in `route()` Because we're capturing a single variable called `text` our function signature is `async handle({ text })`. `async` is needed to let JavaScript do other things while we are waiting for result from external API. Although in this simple case, we don't make any external calls. Our `handle()` function should return an object with 3 properties: +5. All badges must implement the `async handle()` function that receives parameters to render the badge. Parameters of `handle()` will match the name defined in `route` Because we're capturing a single variable called `text` our function signature is `async handle({ text })`. `async` is needed to let JavaScript do other things while we are waiting for result from external API. Although in this simple case, we don't make any external calls. Our `handle()` function should return an object with 3 properties: - `label`: the text on the left side of the badge - `message`: the text on the right side of the badge - here we are passing through the parameter we captured in the route regex - `color`: the background color of the right side of the badge @@ -152,10 +120,8 @@ The process of turning this object into an image is handled automatically by the To try out this example badge: 1. Copy and paste this code into a new file in `/services/example/example.service.js` -2. The server should restart on its own. (If it doesn't for some reason, quit - the running server with `Control+C`, then start it again with `npm start`.) -3. Visit the badge at . - It should look like this: ![](https://img.shields.io/badge/example-foo-blue) +2. The server should restart on its own. (If it doesn't for some reason, quit the running server with `Control+C`, then start it again with `npm start`.) +3. Visit the badge at . It should look like this: ![](https://img.shields.io/badge/example-foo-blue) [path-to-regexp]: https://github.com/pillarjs/path-to-regexp#parameters [static]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/static @@ -221,29 +187,28 @@ Description of the code: 2. 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. 3. 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. Our module exports a class which extends `BaseJsonService` -5. 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. +5. 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-documentation-to-the-frontend) for more details. 6. As with our previous badge, we need to declare a route. This time we will capture a variable called `gem`. -7. We can use `defaultBadgeData()` to set a default `color`, `logo` and/or `label`. If `handle()` doesn't return any of these keys, we'll use the default. Instead of explicitly setting the label text when we return a badge object, we'll use `defaultBadgeData()` here to define it declaratively. +7. We can use `defaultBadgeData` to set a default `color`, `logo` and/or `label`. If `handle()` doesn't return any of these keys, we'll use the default. Instead of explicitly setting the label text when we return a badge object, we'll use `defaultBadgeData` here to define it declaratively. 8. We now jump to the bottom of the example code to the function all badges must implement: `async handle()`. This is the function the server will invoke to handle an incoming request. Because our URL pattern captures a variable called `gem`, our function signature is `async handle({ gem })`. We usually separate the process of generating a badge into 2 stages or concerns: fetch and render. The `fetch()` function is responsible for calling an API endpoint to get data. The `render()` function formats the data for display. In a case where there is a lot of calculation or intermediate steps, this pattern may be thought of as fetch, transform, render and it might be necessary to define some helper functions to assist with the 'transform' step. -9. Working our way upward, the `async fetch()` method is responsible for calling an API endpoint to get data. Extending `BaseJsonService` gives us the helper function `_requestJson()`. Note here that we pass the schema we defined in step 4 as an argument. `_requestJson()` will deal with validating the response against the schema and throwing an error if necessary. - +9. Working our way upward, the `async fetch()` method is responsible for calling an API endpoint to get data. Extending `BaseJsonService` gives us the helper function `_requestJson()`. Note here that we pass the schema we defined in step 3 as an argument. `_requestJson()` will deal with validating the response against the schema and throwing an error if necessary. - `_requestJson()` automatically adds an Accept header, checks the status code, parses the response as JSON, and returns the parsed response. - - `_requestJson()` uses [request](https://github.com/request/request) to perform the HTTP request. Options can be passed to request, including method, query string, and headers. If headers are provided they will override the ones automatically set by `_requestJson()`. There is no need to specify json, as the JSON parsing is handled by `_requestJson()`. See the `request` docs for [supported options](https://github.com/request/request#requestoptions-callback). - - Error messages corresponding to each status code can be returned by passing a dictionary of status codes -> messages in `errorMessages`. + - `_requestJson()` uses [got](https://github.com/sindresorhus/got) to perform the HTTP request. Options can be passed to got, including method, query string, and headers. If headers are provided they will override the ones automatically set by `_requestJson()`. There is no need to specify json, as the JSON parsing is handled by `_requestJson()`. See the `got` docs for [supported options](https://github.com/sindresorhus/got/blob/main/documentation/2-options.md). + - Error messages corresponding to each status code can be returned by passing a dictionary of status codes -> messages in `httpErrors`. - A more complex call to `_requestJson()` might look like this: ```js return this._requestJson({ schema: mySchema, url, - options: { qs: { branch: 'master' } }, - errorMessages: { + options: { searchParams: { branch: 'master' } }, + httpErrors: { 401: 'private application not supported', 404: 'application not found', }, }) ``` -10. Upward still, the `static render()` method is responsible for formatting the data for display. `render()` is a pure function so we can make it a `static` method. By convention we declare functions which don't reference `this` as `static`. We could explicitly return an object here, as we did in the previous example. In this case, we will hand the version string off to `renderVersionBadge()` which will format it consistently and set an appropriate color. Because `renderVersionBadge()` doesn't return a `label` key, the default label we defined in `defaultBadgeData()` will be used when we generate the badge. +10. Upward still, the `static render()` method is responsible for formatting the data for display. `render()` is a pure function so we can make it a `static` method. By convention we declare functions which don't reference `this` as `static`. We could explicitly return an object here, as we did in the previous example. In this case, we will hand the version string off to `renderVersionBadge()` which will format it consistently and set an appropriate color. Because `renderVersionBadge()` doesn't return a `label` key, the default label we defined in `defaultBadgeData` will be used when we generate the badge. This code allows us to call this URL to generate this badge: ![](https://img.shields.io/gem/v/formatador) @@ -256,10 +221,7 @@ Specifically `BaseJsonService` will handle the following errors for us: - API returns a response which can't be parsed as JSON - API returns a response which doesn't validate against our schema -Sometimes it may be necessary to manually throw an exception to deal with a -non-standard error condition. If so, there are several standard exceptions that can be used. The errors are documented at -[errors](https://contributing.shields.io/module-core_base-service_errors.html) -and can be imported via the import shortcut and then thrown: +Sometimes it may be necessary to manually throw an exception to deal with a non-standard error condition. If so, there are several standard exceptions that can be used. The errors are documented at [errors](https://contributing.shields.io/module-core_base-service_errors.html) and can be imported via the import shortcut and then thrown: ```js import { NotFound } from '../index.js' @@ -267,68 +229,104 @@ import { NotFound } from '../index.js' throw new NotFound({ prettyMessage: 'package not found' }) ``` -### (4.4) Adding an Example to the Front Page +### (4.4) Adding Documentation to the Frontend + +To render the shields.io website, we produce an [OpenAPI 3 specification](https://swagger.io/specification/) which describes the available badge endpoints. Then we use that specification to render the frontend. -Once we have implemented our badge, we can add it to the index so that users can discover it. We will do this by adding an additional method `examples()` to our class. +Once we have implemented our badge, we want to add it to the index so that users can discover it. We will do this by adding an additional object `openApi` to our class. This object contains an [OpenAPI Paths Object](https://swagger.io/specification/#paths-object) describing the endpoint or endpoints exposed by this service class. ```js +// (1) +import { pathParams } from '../index.js' + export default class GemVersion extends BaseJsonService { // ... - // (1) + // (2) static category = 'version' - // (2) - static examples = [ - { - // (3) - title: 'Gem', - namedParams: { gem: 'formatador' }, - staticPreview: this.render({ version: '2.1.0' }), - keywords: ['ruby'], + static openApi = { + // (3) + '/gem/v/{gem}': { + // (4) + get: { + // (5) + summary: 'Gem Version', + description: + '[Ruby Gems](https://rubygems.org/) is a registry for ruby libraries', + // (6) + parameters: pathParams({ + name: 'gem', + description: 'Name of the Ruby Gem', + example: 'formatador', + }), + }, }, - ] + } } ``` -1. We defined category earlier in the tutorial. The `category()` property defines which heading in the index our example will appear under. -2. The examples property defines an array of examples. In this case the array will contain a single object, but in some cases it is helpful to provide multiple usage examples. -3. Our example object should contain the following properties: - - `title`: Descriptive text that will be shown next to the badge - - `namedParams`: Provide a valid example of params we can substitute into - the pattern. In this case we need a valid ruby gem, so we've picked [formatador](https://rubygems.org/gems/formatador). - - `staticPreview`: On the index page we want to show an example badge, but for performance reasons we want that example to be generated without making an API call. `staticPreview` should be populated by calling our `render()` method with some valid data. - - `keywords`: If we want to provide additional keywords other than the title and the category, we can add them here. This helps users to search for relevant badges. +1. There are four helper functions we can use to assemble [Open API Parameter objects](https://swagger.io/specification/#parameter-object). These are: + - `pathParam` - returns a single Parameter object describing a single path parameter + - `pathParams` - returns an array of path parameter objects + - `queryParam` - returns a single Parameter object describing a single query parameter + - `queryParams` - returns an array of query parameter objects + + These four helper functions are documented in more detail at http://contributing.shields.io/module-core_base-service_openapi.html + +2. We defined category earlier in the tutorial. The `category` property defines which heading in the index our example will appear under. +3. The keys of the `openApi` object are routes. In this case we only need to describe one route. In some cases, a service class can define more than one badge route. Open API doesn't have the concept of optional path parameters (more specifically, `in: 'path'` implies `required: true`) so if there are any optional path parameters in our route, our `openApi` object needs to describe two URLs: one without the optional parameter, and another with it. +4. The HTTP method. Shields only allows GET requests, so this is always `get`. +5. `summary` (required) is a short title or description of the badge. `description` is an optional longer description or additional documentation. We can use markdown or HTML syntax inside the `description` field. +6. `parameters` is an array of [Open API Parameter objects](https://swagger.io/specification/#parameter-object) describing any parameters we can pass to this route. This array should include all path parameters included in the key that this value object describes and all relevant query parameters. As a minimum, we need to supply `name` and `example`. The example must be a valid example of a value we can provide for this parameter. In this case we need a valid ruby gem, so we've picked [formatador](https://rubygems.org/gems/formatador). There are also optional keys we can pass. The code + + ```js + parameters: pathParams({ + name: 'gem', + description: 'Name of the Ruby Gem', + example: 'formatador', + }) + ``` + + is equivalent to + + ```js + parameters: [ + { + name: 'gem', + in: 'path', + required: true, + schema: { type: 'string' }, + example: 'formatador', + description: 'Name of the Ruby Gem', + }, + ] + ``` + + but we have used the helper function `pathParams` to imply some defaults and reduce the amount of code we need to write by hand. + +7. Path and query parameters should be lower camelCase, for example use `gemName` instead of `gem_name`. 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. +If you update `openApi`, you don't have to restart the server. Run `npm run prestart` in another terminal window and the frontend will update. ### (4.5) Write Tests [write tests]: #45-write-tests -When creating a badge for a new service or changing a badge's behavior, tests -should be included. They serve several purposes: +When creating a badge for a new service or changing a badge's behavior, tests should be included. They serve several purposes: -1. They speed up future contributors when they are debugging or improving a - badge. -2. If the contributors would like to change your badge, chances are, they forget - edge cases and break your code. - Tests may give hints in such cases. -3. The contributor and reviewer can easily verify the code works as - intended. -4. When a badge stops working on the live server, maintainers can find out - right away. +1. They speed up future contributors when they are debugging or improving a badge. +2. If the contributors would like to change your badge, chances are, they forget edge cases and break your code. Tests may give hints in such cases. +3. The contributor and reviewer can easily verify the code works as intended. +4. When a badge stops working on the live server, maintainers can find out right away. -There is a dedicated [tutorial for tests in the service-tests folder](service-tests.md). -Please follow it to include tests on your pull-request. +There is a dedicated [tutorial for tests in the service-tests folder](service-tests.md). Please follow it to include tests on your pull-request. ### (4.6) Update the Docs -If your submission requires an API token or authentication credentials, please -update [server-secrets.md](./server-secrets.md). You should explain what the -token or credentials are for and how to obtain them. +If your submission requires an API token or authentication credentials, please update [server-secrets.md](./server-secrets.md). You should explain what the token or credentials are for and how to obtain them. ## (5) Create a Pull Request @@ -338,8 +336,6 @@ Once you have implemented a new badge: - [Create a pull-request](https://help.github.com/articles/creating-a-pull-request/) to propose your changes. - CI will check the tests pass and that your code conforms to our coding standards. - We also use [Danger](https://danger.systems/) to check for some common problems. The first comment on your pull request will be posted by a bot. If there are any errors or warnings raised, please review them. -- One of the - [maintainers](https://github.com/badges/shields/blob/master/README.md#project-leaders) - will review your contribution. +- One of the [maintainers](https://github.com/badges/shields/blob/master/README.md#project-leaders) will review your contribution. - We'll work with you to progress your contribution suggesting improvements if necessary. Although there are some occasions where a contribution is not appropriate, if your contribution conforms to our [guidelines](https://github.com/badges/shields/blob/master/CONTRIBUTING.md#badge-guidelines) we'll aim to work towards merging it. The majority of pull requests adding a service badge are merged. - If your contribution is merged, the final comment on the pull request will be an automated post which you can monitor to tell when your contribution has been deployed to production. diff --git a/doc/adding-new-config-values.md b/doc/adding-new-config-values.md new file mode 100644 index 0000000000000..bece1413edfef --- /dev/null +++ b/doc/adding-new-config-values.md @@ -0,0 +1,22 @@ +# Adding New Config Values + +The Badge Server supports a [variety of methods for defining configuration settings and secrets](./server-secrets.md), and provides a framework for loading those values during bootstrapping. + +Any new configuration setting or secret must be correctly registered so that it will be loaded at startup along with the others. + +This generally includes adding the corresponding information for your new setting(s)/secret(s) to the following locations: + +- [core/server/server.js](https://github.com/badges/shields/blob/master/core/server/server.js) - Add the new values to the [schemas](https://github.com/badges/shields/blob/e1443eb7146466ad987830c1f286e2b0b186500c/core/server/server.js#L67-L215). Secrets/tokens/etc. should go in the `privateConfigSchema` while non-secret configuration settings should go in the `publicConfigSchema`. +- [config/custom-environment-variables.yml](https://github.com/badges/shields/blob/master/config/custom-environment-variables.yml) +- [docs/server-secrets.md](https://github.com/badges/shields/blob/master/doc/server-secrets.md) (only applicable for secrets) +- [config/default.yml](https://github.com/badges/shields/blob/master/config/default.yml) (optional) +- Any other template config files (e.g. `config/local.template.yml`) (optional) + +The exact values needed will depend on what type of secret/setting you are adding, but for reference a few commits are included below which added secrets and or settings: + +- (secret) [4a75cf09a786c5e93232ae699af985e5bc89440e](https://github.com/badges/shields/commit/4a75cf09a786c5e93232ae699af985e5bc89440e) +- (secret) [bd6f4ee1465d14a8f188c37823748a21b6a46762](https://github.com/badges/shields/commit/bd6f4ee1465d14a8f188c37823748a21b6a46762) +- (secret) [0fd557d7bb623e3852c92cebac586d5f6d6d89d8](https://github.com/badges/shields/commit/0fd557d7bb623e3852c92cebac586d5f6d6d89d8) +- (configuration setting) [b1fc4925928c061234e9492f3794c0797467e123](https://github.com/badges/shields/commit/b1fc4925928c061234e9492f3794c0797467e123) + +Don't hesitate to reach out if you're unsure of the exact values needed for your new secret/setting, or have any other questions. Feel free to post questions on your corresponding Issue/Pull Request, and/or ping us on the `contributing` channel on our Discord server. diff --git a/doc/authentication.md b/doc/authentication.md new file mode 100644 index 0000000000000..1e0b5f9a445e2 --- /dev/null +++ b/doc/authentication.md @@ -0,0 +1,8 @@ +# Badges Requiring Authentication + +There are two patterns for how shields.io can interact with APIs that require auth: + +1. We can store one token at the service level which allows us to read public data for everyone's projects, or lift a rate limit. If you are looking for information on configuring credentials for a self-hosted instance see https://github.com/badges/shields/blob/master/doc/server-secrets.md +2. If every user needs to provide their own token, that has to be a token which can be passed to us as a query param in the badge URL. This means it must be possible to generate a key or token that can be exposed in a public github README public with no negative consequences. (i.e: that key or token only exposes public metrics). + +If every user would need to supply their own token for some particular service and it is only possible to generate a key or token which allows access to sensitive data or allows write access to resources, we can't provide an integration for this service. diff --git a/doc/badge-redirectors.md b/doc/badge-redirectors.md new file mode 100644 index 0000000000000..2dfe93a86325a --- /dev/null +++ b/doc/badge-redirectors.md @@ -0,0 +1,235 @@ +# Badge Redirectors + +When a badge URL pattern needs to change, we should ensure that existing badges continue to work. This is achieved through redirectors, which automatically redirect old URLs to their new equivalents. + +Redirectors issue HTTP 301 (permanent redirect) responses, allowing browsers and clients to follow the redirect to the new URL while maintaining backward compatibility for all existing badge usages. + +## Creating a Redirector + +Redirectors are created using the `redirector()` function from `core/base-service/redirector.js`. + +### Required Properties + +Every redirector must specify: + +- **`category`** - The badge category (e.g. `'build'`, `'version'`, `'downloads'`). [Here](https://github.com/badges/shields/blob/master/services/categories.js) is the list of valid categories. +- **`route`** - An object defining the old URL pattern to match: + - `base` - The first part of the old URL path + - `pattern` - The variable part of the route (using [path-to-regexp](https://github.com/pillarjs/path-to-regexp) syntax) +- **`transformPath`** - A function that transforms the old path parameters into the new badge path +- **`dateAdded`** - The date when this redirector was added (e.g., `new Date('2025-11-23')`) + +### Optional Properties + +- **`name`** - Custom name for the service class (auto-generated from route if not specified) +- **`transformQueryParams`** - A function to transform path parameters into query parameters +- **`overrideTransformedQueryParams`** - Boolean (default: `false`). When `true`, query params from the URL take precedence over transformed params in case of conflicts +- **`isRetired`** - Boolean (default: `true`). Set to `false` for non-retired redirectors that should appear in the API documentation +- **`openApi`** - OpenAPI documentation object. Only needed for non-retired redirectors (`isRetired: false`) that should appear in the user-facing documentation + +## Examples + +### Example 1: Simple Path Redirect + +When simply redirecting from an old URL pattern to a new one: + +```js +import { redirector } from '../index.js' + +export default redirector({ + category: 'other', + route: { + base: 'badge/endpoint', + pattern: '', + }, + transformPath: () => '/endpoint', + dateAdded: new Date('2025-01-01'), +}) +``` + +This redirects `/badge/endpoint` to `/endpoint`. + +### Example 2: Redirecting with Path Parameters + +When the old URL has parameters that need to be mapped to a new structure: + +```js +import { redirector } from '../index.js' + +export default redirector({ + category: 'analysis', + route: { + base: 'scrutinizer', + pattern: ':vcs(g|b)/:user/:repo/:branch*', + }, + transformPath: ({ vcs, user, repo, branch }) => + `/scrutinizer/quality/${vcs}/${user}/${repo}${branch ? `/${branch}` : ''}`, + dateAdded: new Date('2025-02-02'), +}) +``` + +This redirects patterns like `/scrutinizer/g/user/repo` to `/scrutinizer/quality/g/user/repo`. + +### Example 3: Converting Path Parameters to Query Parameters + +When migrating from path-based to query-based parameters: + +```js +import { redirector } from '../index.js' + +export default redirector({ + category: 'monitoring', + route: { + base: 'website', + pattern: ':protocol(https|http)/:hostAndPath+', + }, + transformPath: () => '/website', + transformQueryParams: ({ protocol, hostAndPath }) => ({ + url: `${protocol}://${hostAndPath}`, + }), + dateAdded: new Date('2025-03-03'), +}) +``` + +This redirects `/website/https/example.com` to `/website?url=https://example.com`. + +### Example 4: Handling Query Parameter Conflicts + +When both path-based and query-string parameters exist, conflicts can occur. By default, transformed query parameters take precedence over user-provided ones. Use `overrideTransformedQueryParams: true` to reverse this behavior and let user-provided query parameters win: + +```js +import { redirector } from '../index.js' + +export default redirector({ + category: 'build', + route: { + base: 'old/service', + pattern: 'token/:token/:param', + }, + transformPath: ({ param }) => `/new/service/${param}`, + transformQueryParams: ({ token }) => ({ token }), + overrideTransformedQueryParams: true, + dateAdded: new Date('2025-04-04'), +}) +``` + +If a user specifies `/old/service/token/abc123/foo?token=xyz789`, `xyz789` takes precedence due to `overrideTransformedQueryParams: true`. + +### Example 5: Non-Deprecated Redirector with Documentation + +In some cases, a badged based on a redirector may not be retired and should appear on the website. This requires setting `isRetired: false` and providing an `openApi` specification: + +```js +import { redirector, pathParam } from '../index.js' +import { commonParams } from '../maven-metadata/maven-metadata.js' + +export default redirector({ + category: 'version', + isRetired: false, + route: { + base: 'gradle-plugin-portal/v', + pattern: ':pluginId', + }, + openApi: { + '/gradle-plugin-portal/v/{pluginId}': { + get: { + summary: 'Gradle Plugin Portal Version', + parameters: [ + pathParam({ name: 'pluginId', example: 'com.gradle.plugin-publish' }), + ...commonParams, + ], + }, + }, + }, + transformPath: () => '/maven-metadata/v', + transformQueryParams: ({ pluginId }) => ({ + metadataUrl: `https://plugins.gradle.org/m2/...`, + label: 'plugin portal', + }), + overrideTransformedQueryParams: true, + dateAdded: new Date('2025-05-05'), +}) +``` + +This pattern is uncommon and should only be used when the redirect is part of the public API rather than a legacy compatibility layer. + +## File Naming Convention + +Redirectors should preferably be specified in a separate file named using the `*-redirect.service.js` pattern, and placed in the appropriate service directory. For example: + +``` +services/ + npm/ + my-service.service.js + my-service.tester.js + my-service-redirect.service.js + my-service-redirect.tester.js +``` + +## Testing Redirectors + +### Basic Test Structure + +Tests for redirectors should verify that the old URL correctly redirects to the new URL. + +Example redirect tests from `npm-downloads.tester.js`: + +```js +import { createServiceTester } from '../tester.js' +export const t = await createServiceTester() + +t.create('Total downloads redirect: unscoped package') + .get('/dt/left-pad.svg') + .expectRedirect('/npm/d18m/left-pad.svg') + +t.create('Total downloads redirect: scoped package') + .get('/dt/@cycle/core.svg') + .expectRedirect('/npm/d18m/@cycle/core.svg') +``` + +The `expectRedirect()` helper verifies that: + +- The response is an HTTP 301 (permanent redirect) +- The `Location` header points to the expected new URL + +### Running Redirector Tests + +Run tests the same way as other service tests: + +```bash +npm run test:services -- --only=npm +``` + +Or for more specific tests: + +```bash +npm run test:services -- --only=npm --fgrep="redirect" +``` + +## What Happens Next? + +We'll keep the redirector for a minimum of one year. It may stay in place for significantly longer, and can only be sunset if one or both of the following conditions are met: + +- The redirector led to less than 100 badge renders on a weekday. +- The redirector serves less than 1% of the traffic of the new badge URL it points to. + +It can then be removed and replaced with a retired badge linking to an issue that explains the migration path, for example: + +```js +import { retiredService } from '../index.js' + +export default retiredService({ + category: 'build', + route: { + base: 'github/workflow/status', + pattern: ':various+', + }, + label: 'githubworkflowstatus', + issueUrl: 'https://github.com/badges/shields/issues/8671', + dateAdded: new Date('2025-11-29'), +}) +``` + +Doing so will render badges similar to the following: ![](https://img.shields.io/badge/githubworkflowstatus-https%3A%2F%2Fgithub.com%2Fbadges%2Fshields%2Fissues%2F8671-red) + +Removal of the resulting retired badge follows the process documented [here](./retiring-badges.md). diff --git a/doc/badge-urls.md b/doc/badge-urls.md index bf15d43e1c599..acf88f50c15ac 100644 --- a/doc/badge-urls.md +++ b/doc/badge-urls.md @@ -1,15 +1,18 @@ # Badge URL Conventions -- The format of new badges should be of the form `/SERVICE/NOUN/PARAMETERS?QUERYSTRING` e.g: - `/github/issues/:user/:repo`. The service is github, the - badge is for issues, and the parameters are `:user/:repo`. -- Parameters should always be part of the route if they are required to display a badge e.g: `:packageName`. +- The format of new badges should be of the form `/SERVICE/NOUN/PARAMETERS?QUERYSTRING` e.g: `/github/issues/:user/:repo`. The service is github, the badge is for issues, and the parameters are `:user/:repo`. +- The `NOUN` part of the route is: + - singular if the badge message represents a single entity, such as the current status of a build (e.g: `/build`), or a more abstract or aggregate representation of the thing (e.g.: `/coverage`, `/quality`) + - plural if there are (or may) be many of the thing (e.g: `/dependencies`, `/stars`) +- Parameters should always be part of the route if they are required to display a badge e.g: `:packageName` and should be lower camelCase. - Common optional params like, `:branch` or `:tag` should also be passed as part of the route. - Query string parameters should be used when: - The parameter is related to formatting. e.g: `/appveyor/tests/:user/:repo?compact_message`. - The parameter is for an uncommon optional attribute, like an alternate registry URL. - The parameter triggers application of alternative logic, like version semantics. e.g: `/github/v/tag/:user/:repo?sort=semver`. - Services which require a url/hostname parameter always should use a query string parameter to accept that value. e.g: `/discourse/topics?server=https://meta.discourse.org`. + - Similar to parameters, query string parameters should be lower camelCase e.g: 'labelColor' and not 'label_color'. +- Query parameters for an alternative host of upstream data (like registry, repository, selfhost instance, etc) should be called `baseUrl` and include a full URL with protocol and path (if required). It is convention to use the following standard routes and abbreviations across services: diff --git a/doc/code-walkthrough.md b/doc/code-walkthrough.md index 58437a035ef18..6178396cceed4 100644 --- a/doc/code-walkthrough.md +++ b/doc/code-walkthrough.md @@ -4,24 +4,20 @@ The Shields codebase is divided into several parts: -1. The frontend (about 7% of the code) +1. The frontend 1. [`frontend`][frontend] 2. The badge renderer (which is available as an npm package) 1. [`badge-maker`][badge-maker] -3. The base service classes (about 8% of the code, and probably the most important - code in the codebase) +3. The base service classes (about 8% of the code, and probably the most important code in the codebase) 1. [`core/base-service`][base-service] 4. The server code and a few related odds and ends 1. [`core/server`][server] 5. Helper code for token pooling and persistence (used to avoid GitHub rate limiting) 1. [`core/token-pooling`][token-pooling] -6. Service common helper functions (about 7% of the code, and fairly important - since it’s shared across much of the service code) +6. Service common helper functions (about 7% of the code, and fairly important since it’s shared across much of the service code) 1. `*.js` in the root of [`services`][services] 7. The services themselves (about 80% of the code) 1. `*.js` in the folders of [`services`][services] -8. The badge suggestion endpoint (Note: it's tested as if it’s a service.) - 1. [`lib/suggest.js`][suggest] [frontend]: https://github.com/badges/shields/tree/master/frontend [badge-maker]: https://github.com/badges/shields/tree/master/badge-maker @@ -29,140 +25,77 @@ The Shields codebase is divided into several parts: [server]: https://github.com/badges/shields/tree/master/core/server [token-pooling]: https://github.com/badges/shields/tree/master/core/token-pooling [services]: https://github.com/badges/shields/tree/master/services -[suggest]: https://github.com/badges/shields/tree/master/lib/suggest.js The tests are also divided into several parts: -1. Unit and functional tests of the frontend - 1. `frontend/**/*.spec.js` -2. Unit and functional tests of the badge renderer +1. Unit and functional tests of the badge renderer 1. `badge-maker/**/*.spec.js` -3. Unit and functional tests of the core code +2. Unit and functional tests of the core code 1. `core/**/*.spec.js` -4. Unit and functional tests of the service helper functions +3. Unit and functional tests of the service helper functions 1. `services/*.spec.js` -5. Unit and functional tests of the service code (we have only a few of these) +4. Unit and functional tests of the service code (we have only a few of these) 1. `services/*/**/*.spec.js` +5. End-to-end tests for the frontend + 1. `cypress/e2e/*.cy.js` 6. The service tester and service test runner 1. [`core/service-test-runner`][service-test-runner] -7. [The service tests themselves][service tests] live integration tests of the - services, and some mocked tests +7. [The service tests themselves][service tests] live integration tests of the services, and some mocked tests 1. `*.tester.js` in subfolders of [`services`][services] -8. Integration tests of Redis-backed persistence code - 1. [`core/token-pooling/redis-token-persistence.integration.js`][redis-token-persistence.integration] +8. Integration tests of PostgreSQL-backed persistence code + 1. [`core/token-pooling/sql-token-persistence.integration.js`][sql-token-persistence.integration] 9. Integration tests of the GitHub authorization code 1. [`services/github/github-api-provider.integration.js`][github-api-provider.integration] [service-test-runner]: https://github.com/badges/shields/tree/master/core/service-test-runner [service tests]: https://github.com/badges/shields/blob/master/doc/service-tests.md -[redis-token-persistence.integration]: https://github.com/badges/shields/blob/master/core/token-pooling/redis-token-persistence.integration.js +[sql-token-persistence.integration]: https://github.com/badges/shields/blob/master/core/token-pooling/sql-token-persistence.integration.js [github-api-provider.integration]: https://github.com/badges/shields/blob/master/services/github/github-api-provider.integration.js -Our goal is for the core code is to reach 100% coverage of the code in the -frontend, core, and service helper functions when the unit and functional -tests are run. +Our goal is to reach 100% coverage of the code in the frontend, core, and service helper functions when the unit and functional tests are run. -Our test strategy for the service code is a bit different. It’s primarily -based on live integration tests. That’s because service response formats can -change, and when they do the badges break. We want our tests to fail when this -happens. That way we can fix the problems proactively instead of waiting for -users to report them. There’s a good discussion about this decision in -[#927][issue 927]. It’s acceptable to write mocked tests of logic that is -difficult to reach using live tests, however where possible, it’s preferred to -test this kind of logic through unit tests (e.g. of `render()` and -`transform()` functions). +Our test strategy for the service code is a bit different. It’s primarily based on live integration tests. That’s because service response formats can change, and when they do the badges break. We want our tests to fail when this happens. That way we can fix the problems proactively instead of waiting for users to report them. There’s a good discussion about this decision in [#927][issue 927]. It’s acceptable to write mocked tests of logic that is difficult to reach using live tests, however where possible, it’s preferred to test this kind of logic through unit tests (e.g. of `render()` and `transform()` functions). [issue 927]: https://github.com/badges/shields/issues/927 ## Server initialization -1. The server entrypoint is [`server.js`][entrypoint] which sets up error - reporting, loads config, and creates an instance of the server. - -2. The Server, which is defined in - [`core/server/server.js`][core/server/server], is based on the web - framework [Scoutcamp][]. It creates an http server, sets up helpers for - token persistence and monitoring. Then it loads all the services, - injecting dependencies as it asks each one to register its route - with Scoutcamp. - -3. The service registration continues in `BaseService.register`. From its - `route` property, it derives a regular expression to match the route - path, and invokes `camp.route` with this value. - -4. At this point the situation gets gnarly and hard to follow. For the - purpose of initialization, suffice it to say that `camp.route` invokes a - callback with the four parameters `( queryParams, match, end, ask )` which - is created in a legacy helper function in - [`legacy-request-handler.js`][legacy-request-handler]. This callback - delegates to a callback in `BaseService.register` with four different - parameters `( queryParams, match, sendBadge, request )`, which - then runs `BaseService.invoke`. `BaseService.invoke` instantiates the - service and runs `BaseService#handle`. +1. The server entrypoint is [`server.js`][entrypoint] which sets up error reporting, loads config, and creates an instance of the server. + +2. The Server, which is defined in [`core/server/server.js`][core/server/server], is based on the web framework [Scoutcamp][]. It creates an http server, sets up helpers for token persistence and monitoring. Then it loads all the services, injecting dependencies as it asks each one to register its route with Scoutcamp. + +3. The service registration continues in `BaseService.register`. From its `route` property, it derives a regular expression to match the route path, and invokes `camp.route` with this value. + +4. At this point the situation gets gnarly and hard to follow. For the purpose of initialization, suffice it to say that `camp.route` invokes a callback with the four parameters `( queryParams, match, end, ask )` which is created in a legacy helper function in [`legacy-request-handler.js`][legacy-request-handler]. This callback delegates to a callback in `BaseService.register` with three different parameters `( queryParams, match, sendBadge )`, which then runs `BaseService.invoke`. `BaseService.invoke` instantiates the service and runs `BaseService#handle`. [entrypoint]: https://github.com/badges/shields/blob/master/server.js [core/server/server]: https://github.com/badges/shields/blob/master/core/server/server.js -[scoutcamp]: https://github.com/espadrine/sc +[scoutcamp]: https://github.com/badges/sc [legacy-request-handler]: https://github.com/badges/shields/blob/master/core/base-service/legacy-request-handler.js ## Downstream caching -1. In production, the majority of requests are served from caches, including - the browser cache, GitHub’s camo proxy server, and other downstream caches. -2. The Shields servers sit behind the Cloudflare CDN. The CDN itself handles - about 40% of the HTTPS requests that come in. +1. In production, the majority of requests are served from caches, including the browser cache, GitHub’s camo proxy server, and other downstream caches. +2. The Shields servers sit behind the Cloudflare CDN. The CDN itself handles about 40% of the HTTPS requests that come in. 3. The remaining requests are proxied to one of the servers. -4. See the [production hosting documentation][production hosting] for a - fuller discussion of the production architecture. +4. See the [production hosting documentation][production hosting] for a fuller discussion of the production architecture. [production hosting]: https://github.com/badges/shields/blob/master/doc/production-hosting.md ## How the server makes a badge -1. An HTTPS request arrives. Scoutcamp inspects the URL path and matches it - against the regexes for all the registered routes until it finds one that - matches. (See *Initialization* above for an explanation of how routes are - registered.) -2. Scoutcamp invokes a callback with the four parameters: - `( queryParams, match, end, ask )`. This callback is defined in - [`legacy-request-handler`][legacy-request-handler]. A timeout is set to - handle unresponsive service code and the next callback is invoked: the - legacy handler function. -3. The legacy handler function receives - `( queryParams, match, sendBadge, request )`. Its job is to extract data - from the regex `match` and `queryParams`, invoke `request` to fetch - whatever data it needs, and then invoke `sendBadge` with the result. -4. The implementation of this function is in `BaseService.register`. It - works by running `BaseService.invoke`, which instantiates the service, - injects more dependencies, and invokes `BaseService#handle` which is - implemented by the service subclass. -5. The job of `handle()`, which should be implemented by each service - subclass, is to return an object which partially describes a badge or - throw one of the handled error classes. "Partially rendered" most - commonly means a non-empty message and an optional color. In the case - of the Endpoint badge, it could include many other parameters. At the - time of writing the handled error classes were NotFound, - InvalidResponse, Inaccessible, InvalidParameter, and Deprecated. - Throwing any other error is a programmer error which will be - [reported][error reporting] and described to the user as a **shields - internal error**. -6. A typical `handle()` function delegates to one or more helpers to - handle stages of the request: - 1. **fetch**: load the needed data from the upstream service and - validate it - 2. **transform**: pluck, convert, or summarize the response format - into a few properties which will be displayed on the badge - 3. **render**: given a few properties, return a message, optional - color, and optional label. -7. When an error is thrown, BaseService steps in and converts the error - object to renderable properties: `{ isError, message, color }`. -8. The service invokes [`coalesceBadge`][coalescebadge] whose job is to - coalesce query string overrides with values from the service and the - service’s defaults to produce an object that fully describes the badge to - be rendered. -9. `sendBadge` is invoked with that object. It does some housekeeping on the - timeout. Then it renders the badge to svg or raster and pushes out the - result over the HTTPS connection. +1. An HTTPS request arrives. Scoutcamp inspects the URL path and matches it against the regexes for all the registered routes until it finds one that matches. (See *Initialization* above for an explanation of how routes are registered.) +2. Scoutcamp invokes a callback with the four parameters: `( queryParams, match, end, ask )`. This callback is defined in [`legacy-request-handler`][legacy-request-handler]. A timeout is set to handle unresponsive service code and the next callback is invoked: the legacy handler function. +3. The legacy handler function receives `( queryParams, match, sendBadge )`. Its job is to extract data from the regex `match` and `queryParams`, and then invoke `sendBadge` with the result. +4. The implementation of this function is in `BaseService.register`. It works by running `BaseService.invoke`, which instantiates the service, injects more dependencies, and invokes `BaseService.handle` which is implemented by the service subclass. +5. The job of `handle()`, which should be implemented by each service subclass, is to return an object which partially describes a badge or throw one of the handled error classes. "Partially rendered" most commonly means a non-empty message and an optional color. In the case of the Endpoint badge, it could include many other parameters. At the time of writing the handled error classes were NotFound, InvalidResponse, Inaccessible, and InvalidParameter. Throwing any other error is a programmer error which will be [reported][error reporting] and described to the user as a **shields internal error**. +6. A typical `handle()` function delegates to one or more helpers to handle stages of the request: + 1. **fetch**: load the needed data from the upstream service and validate it + 2. **transform**: pluck, convert, or summarize the response format into a few properties which will be displayed on the badge + 3. **render**: given a few properties, return a message, optional color, and optional label. +7. When an error is thrown, BaseService steps in and converts the error object to renderable properties: `{ isError, message, color }`. +8. The service invokes [`coalesceBadge`][coalescebadge] whose job is to coalesce query string overrides with values from the service and the service’s defaults to produce an object that fully describes the badge to be rendered. +9. `sendBadge` is invoked with that object. It does some housekeeping on the timeout. 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 diff --git a/doc/input-validation.md b/doc/input-validation.md index c2fbac2e60346..b3bcc5c836afc 100644 --- a/doc/input-validation.md +++ b/doc/input-validation.md @@ -8,14 +8,13 @@ When we receive input data from an upstream API, we perform input validation to: ## 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. +- The default validation mechanism should be to use [Joi](https://github.com/hapijs/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. @@ -24,6 +23,8 @@ When we receive input data from an upstream API, we perform input validation to: - 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()` +- https://joi.dev/tester/ is a tool that can be used to reverse engineer a schema from an API response. This can be a great starting point to tweak from. If using this as a starting point, remember to remove fields we don't rely on to render a badge. + - 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. diff --git a/doc/json-format.md b/doc/json-format.md index 892a8285982a6..5297426fc2c77 100644 --- a/doc/json-format.md +++ b/doc/json-format.md @@ -1,10 +1,8 @@ # JSON Format -Even though Shields is probably best known for its SVG badges, you can also retrieve -a JSON payload by replacing the `.svg` extension with `.json`. +Even though Shields is probably best known for its SVG badges, you can also retrieve a JSON payload by replacing the `.svg` extension with `.json`. -For instance, hitting [this endpoint](https://img.shields.io/badge/hello-world-brightgreen.json) -will generate the following payload: +For instance, hitting [this endpoint](https://img.shields.io/badge/hello-world-brightgreen.json) will generate the following payload: ``` { @@ -16,10 +14,6 @@ will generate the following payload: } ``` -Note that the values of the `name` and `value` fields are duplicates of the `label` -and `message` ones, respectively. As of April 2019, `name` and `value` are deprecated -and will be removed in a future release, please consider migrating your application -to use `label` and `message` instead. +Note that the values of the `name` and `value` fields are duplicates of the `label` and `message` ones, respectively. As of April 2019, `name` and `value` are deprecated and will be removed in a future release, please consider migrating your application to use `label` and `message` instead. -Feel free to [open an issue](https://github.com/badges/shields/issues/new/choose) -if you have any queries regarding the JSON format. +Feel free to [open an issue](https://github.com/badges/shields/issues/new/choose) if you have any queries regarding the JSON format. diff --git a/doc/logos.md b/doc/logos.md deleted file mode 100644 index ff07f9f014541..0000000000000 --- a/doc/logos.md +++ /dev/null @@ -1,63 +0,0 @@ -# Logos - -## Using Logos - -### SimpleIcons - -We support a wide range of logos via [SimpleIcons][]. They should be referenced by the logo slug e.g: - -![](https://img.shields.io/npm/v/npm.svg?logo=nodedotjs) - https://img.shields.io/npm/v/npm.svg?logo=nodedotjs - -The set of Simple Icon slugs can be found in the [slugs.md](https://github.com/simple-icons/simple-icons/blob/develop/slugs.md) file in the Simple Icons repository. NB - the Simple Icons site and that slugs.md page may at times contain new icons that haven't yet been pulled into the Shields.io runtime. More information on how and when we incorporate icon updates can be found [here](https://github.com/badges/shields/discussions/5369). - -### Shields logos - -We also maintain a small number of custom logos for some services: https://github.com/badges/shields/tree/master/logo They can also be referenced by name and take preference to SimpleIcons e.g: - -![](https://img.shields.io/npm/v/npm.svg?logo=npm) - https://img.shields.io/npm/v/npm.svg?logo=npm - -### Custom Logos - -Any custom logo can be passed in a URL parameter by base64 encoding it. e.g: - -![](https://img.shields.io/badge/play-station-blue.svg?logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEiIHdpZHRoPSI2MDAiIGhlaWdodD0iNjAwIj48cGF0aCBkPSJNMTI5IDExMWMtNTUgNC05MyA2Ni05MyA3OEwwIDM5OGMtMiA3MCAzNiA5MiA2OSA5MWgxYzc5IDAgODctNTcgMTMwLTEyOGgyMDFjNDMgNzEgNTAgMTI4IDEyOSAxMjhoMWMzMyAxIDcxLTIxIDY5LTkxbC0zNi0yMDljMC0xMi00MC03OC05OC03OGgtMTBjLTYzIDAtOTIgMzUtOTIgNDJIMjM2YzAtNy0yOS00Mi05Mi00MmgtMTV6IiBmaWxsPSIjZmZmIi8+PC9zdmc+) - https://img.shields.io/badge/play-station-blue.svg?logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEiIHdpZHRoPSI2MDAiIGhlaWdodD0iNjAwIj48cGF0aCBkPSJNMTI5IDExMWMtNTUgNC05MyA2Ni05MyA3OEwwIDM5OGMtMiA3MCAzNiA5MiA2OSA5MWgxYzc5IDAgODctNTcgMTMwLTEyOGgyMDFjNDMgNzEgNTAgMTI4IDEyOSAxMjhoMWMzMyAxIDcxLTIxIDY5LTkxbC0zNi0yMDljMC0xMi00MC03OC05OC03OGgtMTBjLTYzIDAtOTIgMzUtOTIgNDJIMjM2YzAtNy0yOS00Mi05Mi00MmgtMTV6IiBmaWxsPSIjZmZmIi8+PC9zdmc+ - -## Contributing Logos - -Our preferred way to consume icons is via the SimpleIcons logo. As a first port of call, we encourage you to contribute logos to [the SimpleIcons project][simple-icons github]. Please review their [guidance](https://github.com/simple-icons/simple-icons/blob/develop/CONTRIBUTING.md) before contributing. - -In some cases we may also accept logo submissions directly. In general, we do this only when: - -- We have a corresponding badge on the homepage, (e.g. the Eclipse logo because we support service badges for the Eclipse Marketplace). We may also approve logos for other tools widely used by developers. -- The logo provided in SimpleIcons is unclear when displayed at small size on a badge. -- There is substantial benefit in using a multi-colored icon over a monochrome icon. -- The logo doesn't meet the requirements to be included in the SimpleIcons set. - -If you are submitting a pull request for a custom logo, please: - -- Minimize SVG files through [SVGO][]. This can be done in one of two ways - - The [SVGO Command Line Tool][svgo] - - Install SVGO - - With npm: `npm install -g svgo` - - With Homebrew: `brew install svgo` - - Run the following command `svgo --precision=3 icon.svg icon.min.svg` - - Check if there is a loss of quality in the output, if so increase the precision. - - The [SVGOMG Online Tool][svgomg] - - Click "Open SVG" and select an SVG file. - - Set the precision to about 3, depending on if there is a loss of quality. - - Leave the remaining settings untouched (or reset them with the button at the bottom of the settings). - - Click the download button. -- Set a viewbox and ensure the logo is scaled to fit the viewbox, while preserving the logo's original proportions. This means the icon should be touching at least two sides of the viewbox. -- Ensure the logo is vertically and horizontally centered. -- Ensure the logo is minified to a single line with no formatting. -- Ensure the SVG does not contain extraneous attributes. -- Ensure your submission conforms to any relevant brand or logo guidelines. - -### Problems - -We try to ensure our logos are compliant with brand guidelines. If one of our custom logos does not conform to the necessary brand guidelines, please open an issue on the [shields.io tracker](https://github.com/badges/shields/issues) and we'll work with you to resolve it. If a logo from the simple-icons set does not conform to the relevant brand guidelines, please open an issue on the [simple-icons tracker](https://github.com/simple-icons/simple-icons/issues) first. - -[simpleicons]: https://simpleicons.org/ -[simple-icons github]: https://github.com/simple-icons/simple-icons -[svgo]: https://github.com/svg/svgo -[svgomg]: https://jakearchibald.github.io/svgomg/ diff --git a/doc/performance-testing.md b/doc/performance-testing.md index 8e48fd0610eda..4f06b51563dc1 100644 --- a/doc/performance-testing.md +++ b/doc/performance-testing.md @@ -1,15 +1,12 @@ # Performance testing -Shields has some basic tooling available to help you get started with -performance testing. +Shields has some basic tooling available to help you get started with performance testing. ## Benchmarking the badge generation -Want to micro-benchmark a section of the code responsible for generating the -static badges? Follow these two simple steps: +Want to micro-benchmark a section of the code responsible for generating the static badges? Follow these two simple steps: -1. Surround the code you want to time with `console.time` and `console.timeEnd` - statements. For example: +1. Surround the code you want to time with `console.time` and `console.timeEnd` statements. For example: ``` console.time('makeBadge') @@ -17,29 +14,19 @@ const svg = makeBadge(badgeData) console.timeEnd('makeBadge') ``` -2. Run `npm run benchmark:badge` in your terminal. An average timing will - be displayed! +2. Run `npm run benchmark:badge` in your terminal. An average timing will be displayed! -If you want to change the number of iterations in the benchmark, you can modify -the values specified by the `benchmark:badge` script in _package.json_. If -you want to benchmark a specific code path not covered by the static badge, you -can modify the badge URL in _scripts/benchmark-performance.js_. +If you want to change the number of iterations in the benchmark, you can modify the values specified by the `benchmark:badge` script in _package.json_. If you want to benchmark a specific code path not covered by the static badge, you can modify the badge URL in _scripts/benchmark-performance.js_. ## Profiling the full code -Want to have an overview of how the entire application is performing? Simply -run `npm run profile:server` in your terminal. This will start the -backend server (i.e. without the frontend) in profiling mode and any requests -you make on `localhost:8080` will generate data in a file with a name -similar to _isolate-00000244AB6ED3B0-11920-v8.log_. +Want to have an overview of how the entire application is performing? Simply run `npm run profile:server` in your terminal. This will start the backend server (i.e. without the frontend) in profiling mode and any requests you make on `localhost:8080` will generate data in a file with a name similar to _isolate-00000244AB6ED3B0-11920-v8.log_. -You can then make use of this profiling data in various tools, for example -[flamebearer](https://github.com/mapbox/flamebearer): +You can then make use of this profiling data in various tools, for example [flamebearer](https://github.com/mapbox/flamebearer): ``` npm install -g flamebearer node --prof-process --preprocess -j isolate-00000244AB6ED3B0-11920-v8.log | flamebearer ``` -An example output is the following: -![](https://raw.github.com/badges/shields/master/doc/flamegraph.png) +An example output is the following: ![](https://raw.github.com/badges/shields/master/doc/flamegraph.png) diff --git a/doc/production-hosting.md b/doc/production-hosting.md index d7f6f9e2ad8cc..f5507887a75a7 100644 --- a/doc/production-hosting.md +++ b/doc/production-hosting.md @@ -3,7 +3,7 @@ Production hosting is managed by the Shields ops team: - [calebcartwright](https://github.com/calebcartwright) -- [chris48s](https://github.com/chris48s) +- [jNullj](https://github.com/jnullj) - [paulmelnikow](https://github.com/paulmelnikow) - [PyvesB](https://github.com/PyvesB) @@ -14,67 +14,51 @@ 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 | -| GitLab | Account owner | @calebcartwright | -| GitLab | Account access | @calebcartwright, @chris48s, @paulmelnikow, @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 | +| --- | --- | --- | +| Fly.io | Admin access | @calebcartwright, @chris48s, @paulmelnikow, @PyvesB | +| Fly.io | Account access | @calebcartwright, @chris48s, @jNullj, @paulmelnikow, @PyvesB | +| Cloudflare (CDN) | Account owner | @espadrine | +| Cloudflare (CDN) | Admin access | @calebcartwright, @chris48s, @espadrine, @paulmelnikow, @PyvesB | +| DNS | Account owner | @olivierlacan | +| DNS | Read-only account access | @chris48s, @espadrine, @paulmelnikow | +| Sentry | Owner | @paulmelnikow, @jNullj | +| Sentry | Account access | @calebcartwright, @jNullj, @paulmelnikow, @PyvesB | +| Metrics server | Owner | @platan | +| More metrics | Owner | @RedSparr0w | +| Twitch | OAuth app | @PyvesB | +| Reddit | OAuth app | @chris48s, @PyvesB | +| Discord | OAuth app | @PyvesB | +| CurseForge | OAuth app | @PyvesB | +| YouTube | Account owner | @PyvesB | +| GitLab | Account owner | @calebcartwright | +| GitLab | Account access | @calebcartwright, @chris48s, @paulmelnikow, @PyvesB | ## 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 stored in a fly.io postgres database +2. The server keeps the [resource cache][] in memory. It is neither persisted nor inspectable. -[github auth admin endpoint]: https://github.com/badges/shields/blob/master/services/github/auth/admin.js -[regular-update cache]: https://github.com/badges/shields/blob/master/core/legacy/regular-update.js +[resource cache]: https://github.com/badges/shields/blob/master/core/base-service/resource-cache.js ## Configuration -To bootstrap the configuration process, -[the script that starts the server][start-shields.sh] sets a single -environment variable: +To bootstrap the configuration of non-secret settings, we set a single environment variable: ``` NODE_CONFIG_ENV=shields-io-production ``` -With that variable set, the server ([using `config`][config]) reads these -files: +With that variable set, the server ([using `config`][config]) reads these files: -- [`local-shields-io-production.yml`][local-shields-io-production.yml]. - This file contains secrets which are checked in with a deploy commit. -- [`shields-io-production.yml`][shields-io-production.yml]. This file - contains non-secrets which are checked in to the main repo. +- [`local-shields-io-production.yml`][local-shields-io-production.yml]. This file contains secrets which are checked in with a deploy commit. +- [`shields-io-production.yml`][shields-io-production.yml]. This file contains non-secrets which are checked in to the main repo. - [`default.yml`][default.yml]. This file contains defaults. -[start-shields.sh]: https://github.com/badges/ServerScript/blob/master/start-shields.sh#L7 +Secrets are supplied directly as environment vars. + [config]: https://github.com/lorenwest/node-config/wiki/Configuration-Files [local-shields-io-production.yml]: ../config/local-shields-io-production.template.yml [shields-io-production.yml]: ../config/shields-io-production.yml @@ -82,8 +66,7 @@ files: ## Badge CDN -Sitting in front of the three servers is a Cloudflare Free account which -provides several services: +Sitting in front of our servers is a Cloudflare Free account which provides several services: - Global CDN, caching, and SSL gateway for `img.shields.io` and `shields.io` - Analytics through the Cloudflare dashboard @@ -91,22 +74,11 @@ provides several services: Cloudflare is configured to respect the servers' cache headers. -## Raster server - -The raster server `raster.shields.io` (a.k.a. the rasterizing proxy) is -hosted on [Zeit Now][]. It's managed in the -[svg-to-image-proxy repo][svg-to-image-proxy]. - -[zeit now]: https://zeit.co/now -[svg-to-image-proxy]: https://github.com/badges/svg-to-image-proxy - -### Heroku Deployment +### Fly.io Deployment -Both the badge server and frontend are served from Heroku. +Both the badge server and frontend are served from Fly.io. Deployments are triggered using GitHub actions in a private repo. -After merging a commit to master, heroku should create a staging deploy. Check this has deployed correctly in the `shields-staging` pipeline and review https://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. +The raster server `raster.shields.io` (a.k.a. the rasterizing proxy) is also hosted on Fly.io. It's managed in the [squint](https://github.com/badges/squint/) repo. ## DNS @@ -114,19 +86,12 @@ DNS is registered with [DNSimple][]. [dnsimple]: https://dnsimple.com/ -## Logs - -Logs can be retrieved [from heroku](https://devcenter.heroku.com/articles/logging#log-retrieval). - ## Error reporting -[Error reporting][sentry] is one of the most useful tools we have for monitoring -the server. It's generously donated by [Sentry][sentry home]. We bundle -[`raven`][raven] into the application, and the Sentry DSN is configured via -`local-shields-io-production.yml` (see [documentation][sentry configuration]). +[Error reporting][sentry] is one of the most useful tools we have for monitoring the server. It's generously donated by [Sentry][sentry home]. We bundle [`@sentry/node`][sentry-node] into the application, and the Sentry DSN is configured via `local-shields-io-production.yml` (see [documentation][sentry configuration]). [sentry]: https://sentry.io/shields/ -[raven]: https://www.npmjs.com/package/raven +[sentry-node]: https://www.npmjs.com/package/@sentry/node [sentry home]: https://sentry.io/shields/ [sentry configuration]: https://github.com/badges/shields/blob/master/doc/self-hosting.md#sentry @@ -139,20 +104,14 @@ The canonical and only recommended domain for badge URLs is `img.shields.io`. Cu ## Monitoring -Overall server performance and requests by service are monitored using -[Prometheus and Grafana][metrics]. +Overall server performance and requests by service are monitored using [Prometheus and Grafana][server metrics]. Request performance is monitored in two places: -- [Status][] (using [UptimeRobot][]) -- [Server metrics][] using Prometheus and Grafana -- [@RedSparr0w's monitor][monitor] which posts [notifications][] to a private - [#monitor chat room][monitor discord] +- [Status][] (using NodePing) +- [Server metrics][] using Prometheus and Grafana [#monitor chat room][monitor discord] [metrics]: https://metrics.shields.io/ -[status]: https://stats.uptimerobot.com/PjXogHB5p +[status]: https://nodeping.com/reports/status/YBISBQB254 [server metrics]: https://metrics.shields.io/ -[uptimerobot]: https://uptimerobot.com/ -[monitor]: https://shields.redsparr0w.com/1568/ -[notifications]: http://shields.redsparr0w.com/discord_notification [monitor discord]: https://discordapp.com/channels/308323056592486420/470700909182320646 diff --git a/doc/releases.md b/doc/releases.md index 2770bf9e66d41..c9f51640d5f93 100644 --- a/doc/releases.md +++ b/doc/releases.md @@ -2,7 +2,7 @@ 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. +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 are described in the respective sections below. ## badge-maker package @@ -20,7 +20,7 @@ This is the core, free, anonymous service available for anyone to use and which 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. +We do not have any guaranteed SLA for the Shields.io service, but we do have monitoring and observability capabilities in place and our [Maintainer 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] @@ -45,11 +45,11 @@ We are happy to document and collate any self-hosting patterns/approaches that o 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. +- 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) and [GitHub Container Registry](https://github.com/badges/shields/pkgs/container/shields/versions?filters%5Bversion_type%5D=tagged). 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. +- You can stay on the bleeding edge by tracking the `master` branch for source installs or the `next` tag on DockerHub/GHCR. [shields.io]: https://shields.io [npm package]: https://www.npmjs.com/package/badge-maker diff --git a/doc/deprecating-badges.md b/doc/retiring-badges.md similarity index 50% rename from doc/deprecating-badges.md rename to doc/retiring-badges.md index 49a49ebbef561..76b52910e06c2 100644 --- a/doc/deprecating-badges.md +++ b/doc/retiring-badges.md @@ -1,26 +1,30 @@ -# Deprecating Badges +# Retiring Badges -When a service that Shields integrates with shuts down, those badges will no longer work and need to be deprecated within Shields. +When a service that Shields integrates with shuts down, those badges will no longer work and need to be retired (deprecated) within Shields. -Deprecating a badge involves two steps: +Retiring a badge involves two steps: -1. Updating the service code to use the `DeprecatedService` class -2. Updating the service tests to reflect the new behavior of the deprecated service +1. Updating the service code to use the `RetiredService` class +2. Updating the service tests to reflect the new behavior of the retired service ## Update Service Implementation Locate the source file(s) for the service, which can be found in `*.service.js` files located within the directory for the service (`./services/:service-name/`) such as `./services/imagelayers/imagelayers.service.js`. -Replace the existing service class implementation with the `DeprecatedService` class from `./core/base-service/deprecated-service.js` using the respective `category`, `route`, and `label` values for that service. For example: +Replace the existing service class implementation with the `RetiredService` class from `./core/base-service/retired-service.js` using the respective `category`, `route`, and `label` values for that service. + +Set the badge label to the service name. This ensures users can immediately identify which service is no longer available at a glance. + +For example: ```js -import { deprecatedService } from '../index.js' +import { retiredService } from '../index.js' -export default deprecatedService({ +export default retiredService({ category: 'size', route: { base: 'imagelayers', - format: '(?:.+?)', + pattern: ':various+', }, label: 'imagelayers', dateAdded: new Date('2019-xx-xx'), // Be sure to update this with today's date! @@ -31,7 +35,7 @@ export default deprecatedService({ Locate the test file(s) for the service, which can be found in `*.tester.js` files located in the service directory (`./services/:service-name/`), such as `./services/imagelayers/imagelayers.tester.js`. -With `DeprecatedService` classes we cannot use `createServiceTester()` so you will need to create the `ServiceTester` class directly. For example: +With `RetiredService` classes we cannot use `createServiceTester()` so you will need to create the `ServiceTester` class directly. For example: ```js import { ServiceTester } from '../tester.js' @@ -42,18 +46,18 @@ export const t = new ServiceTester({ }) ``` -Next you will need to replace/refactor the existing tests to validate the new deprecated badge behavior for this service. Deprecated badges always return a message of `no longer available` (such as `imagelayers | no longer available`) so the tests need to be updated to reflect that message value. For example: +Next you will need to replace/refactor the existing tests to validate the new retired badge behavior for this service. Retired badges always return a message of `retired badge` (such as `imagelayers | retired badge`) so the tests need to be updated to reflect that message value. For example: ```js -t.create('no longer available (previously image size)') +t.create('retired badge (previously image size)') .get('/image-size/_/ubuntu/latest.json') .expectBadge({ label: 'imagelayers', - message: 'no longer available', + message: 'retired badge', }) ``` -Make sure to have a live (non-mocked) test for each badge the service provides that validates the each badge returns the `no longer available` message. +Make sure to have a live (non-mocked) test for each badge the service provides that validates the each badge returns the `retired badge` message. Here is an example of what the final result would look like for a test file: @@ -65,51 +69,75 @@ export const t = new ServiceTester({ title: 'ImageLayers', }) -t.create('no longer available (previously image size)') +t.create('retired badge (previously image size)') .get('/image-size/_/ubuntu/latest.json') .expectBadge({ label: 'imagelayers', - message: 'no longer available', + message: 'retired badge', }) -t.create('no longer available (previously number of layers)') +t.create('retired badge (previously number of layers)') .get('/layers/_/ubuntu/latest.json') .expectBadge({ label: 'imagelayers', - message: 'no longer available', + message: 'retired badge', }) ``` ## What Happens Next? -Once a service is deprecated, we'll keep the deprecation notice for a minimum of one year. During that time, the badge will render as follows: -![](https://img.shields.io/badge/gratipay-no%20longer%20available-inactive) +Once a service is retired, we'll keep the retirement notice for a minimum of one year. During that time, the badge will render as follows: ![](https://img.shields.io/badge/gratipay-retired%20badge-inactive) -Past that point, all related code will be deleted, and a not found error will be rendered instead: -![](https://img.shields.io/badge/404-badge%20not%20found-critical) +Past that point, all related code will be deleted, and a not found error will be rendered instead: ![](https://img.shields.io/badge/404-badge%20not%20found-critical) Here is a listing of all deleted badges that were once part of the Shields.io service: +- Ansible Collection, Role, Quality +- APM +- Beerpay +- Bintray - bitHound +- Bountysource - Cauditor +- Chrome Web Store Price - CocoaPods Apps - CocoaPods Downloads +- Codetally +- Coincap +- continuousphp - Coverity +- Criterion +- David +- dependabot - Dockbit - Dotnet Status - Gemnasium - Gratipay/Gittip +- Hackage (dependencies) - ImageLayers - Issue Stats - JitPack Downloads - Leanpub +- LGTM - Libscore - Magnum CI +- MicroBadger - NSP +- pkgreview - PHP Eye +- Pub (popularity) +- Redmine +- requires.io +- Shippable - Snap CI +- Snyk +- Tas +- Tokei +- Travis.org - VersionEye - Waffle +- Wercker +- Wheelmap ## Additional Information @@ -118,4 +146,4 @@ Some other information that may be useful: - [Contributing Docs](../CONTRIBUTING.md) - [Badge Tutorial](./TUTORIAL.md) - [Service Tests Tutorial](./service-tests.md) -- Previous Pull Requests that deprecated badges like [#2352](https://github.com/badges/shields/pull/2352) and [#2410](https://github.com/badges/shields/pull/2410) +- Previous Pull Requests that retired badges like [#10371](https://github.com/badges/shields/pull/10371) and [#11075](https://github.com/badges/shields/pull/11075) diff --git a/doc/self-hosting.md b/doc/self-hosting.md index 217544bc68bc1..e9cdff1535e84 100644 --- a/doc/self-hosting.md +++ b/doc/self-hosting.md @@ -4,13 +4,12 @@ This document describes how to host your own shields server either from source o ## Installing from Source -You will need Node 14 or later, which you can install using a -[package manager][]. +You will need Node 22, which you can install using a [package manager][]. On Ubuntu / Debian: ```sh -curl -sL https://deb.nodesource.com/setup_14.x | sudo -E bash -; sudo apt-get install -y nodejs +curl -sL https://deb.nodesource.com/setup_22.x | sudo -E bash -; sudo apt-get install -y nodejs ``` ```sh @@ -71,18 +70,34 @@ vercel ## Docker -### DockerHub +### Public Images -We publish images to DockerHub at https://registry.hub.docker.com/r/shieldsio/shields +We publish images to: -The `next` tag is the latest build from `master`, or tagged releases are available -https://registry.hub.docker.com/r/shieldsio/shields/tags +- DockerHub at https://registry.hub.docker.com/r/shieldsio/shields and +- GitHub Container Registry at https://github.com/badges/shields/pkgs/container/shields -```console +The `next` tag is the latest build from `master`. These are only available for linux/amd64 + +```sh +# DockerHub $ docker pull shieldsio/shields:next $ docker run shieldsio/shields:next ``` +```sh +# GHCR +$ docker pull ghcr.io/badges/shields:next +$ docker pull ghcr.io/badges/shields:next +``` + +Tagged snapshot releases are also available: + +- https://registry.hub.docker.com/r/shieldsio/shields/tags +- https://github.com/badges/shields/pkgs/container/shields/versions?filters%5Bversion_type%5D=tagged + +We push both linux/amd64 and linux/arm64 snapshot images. We use the linux/amd64 image ourselves to host shields.io. We push a linux/arm64 image, but we don't consume it ourselves and it receives no testing beyond ensuring the docker image builds without error. + ### Building Docker Image Locally Alternatively, you can build and run the server locally using Docker. First build an image: @@ -94,51 +109,32 @@ Sending build context to Docker daemon 3.923 MB Successfully built 4471b442c220 ``` -Optionally, create a file called `shields.env` that contains the needed -configuration. See [server-secrets.md](server-secrets.md) and [config/custom-environment-variables.yml](/config/custom-environment-variables.yml) for examples. +Optionally, alter the default values for configuration by setting them via [environment variables](https://docs.docker.com/engine/reference/commandline/run/#set-environment-variables--e---env---env-file). See [server-secrets.md](server-secrets.md) and [config/custom-environment-variables.yml](/config/custom-environment-variables.yml) for possible values. In [config/custom-environment-variables.yml](/config/custom-environment-variables.yml), environment variable names are specified as the quoted, uppercase key values (e.g. `GH_TOKEN`). -Then run the container: +Then run the container, and be sure to specify the same mapped port as the one Shields is listening on : ```console -$ docker run --rm -p 8080:80 --name shields shields -# or if you have shields.env file, run the following instead -$ docker run --rm -p 8080:80 --env-file shields.env --name shields shields - -> badge-maker@3.0.0 start /usr/src/app -> node server.js +$ docker run --rm -p 8080:8080 --env PORT=8080 --name shields shieldsio/shields:next -http://[::1]/ +Configuration: +... +0916211515 Server is starting up: http://0.0.0.0:8080/ ``` -Assuming Docker is running locally, you should be able to get to the -application at http://localhost:8080/. +Assuming Docker is running locally, you should be able to get to the application at http://localhost:8080/. -If you run Docker in a virtual machine (such as boot2docker or Docker Machine) -then you will need to replace `localhost` with the IP address of that virtual -machine. - -[shields.example.env]: ../shields.example.env +If you run Docker in a virtual machine (such as boot2docker or Docker Machine) then you will need to replace `localhost` with the IP address of that virtual machine. ## Raster server -If you want to host PNG badges, you can also self-host a [raster server][] -which points to your badge server. It's designed as a web function which is -tested on Zeit Now, though you may be able to run it on AWS Lambda. It's -built on the [micro][] framework, and comes with a `start` script that allows -it to run as a standalone Node service. +If you want to host PNG badges, you can also self-host a [raster server][] which points to your badge server. It's a docker container. We host it on Fly.io but should be possible to host on a wide variety of platforms. -- In your raster instance, set `BASE_URL` to your Shields instance, e.g. - `https://shields.example.co`. -- Optionally, in your Shields, instance, configure `RASTER_URL` to the base - URL, e.g. `https://raster.example.co`. This will send 301 redirects - for the legacy raster URLs instead of 404's. +- In your raster instance, set `BASE_URL` to your Shields instance, e.g. `https://shields.example.co`. +- Optionally, in your Shields, instance, configure `RASTER_URL` to the base URL, e.g. `https://raster.example.co`. This will send 301 redirects for the legacy raster URLs instead of 404's. -If anyone has set this up, more documentation on how to do this would be -welcome! It would also be nice to ship a Docker image that includes a -preconfigured raster server. +If anyone has set this up, more documentation on how to do this would be welcome! -[raster server]: https://github.com/badges/svg-to-image-proxy -[micro]: https://github.com/zeit/micro +[raster server]: https://github.com/badges/squint ## Server secrets @@ -148,30 +144,19 @@ These are documented in [server-secrets.md](./server-secrets.md) ## Separate frontend hosting -If you want to host the frontend on a separate server, such as cloud storage -or a CDN, you can do that. +If you want to host the frontend on a separate server, such as cloud storage or a CDN, you can do that. -First, build the frontend, pointing `GATSBY_BASE_URL` to your server. +First, build the frontend, pointing `BASE_URL` to your server. ```sh -GATSBY_BASE_URL=https://your-server.example.com npm run build +BASE_URL=https://your-server.example.com npm run build ``` -Then copy the contents of the `build/` folder to your static hosting / CDN. +Then copy the contents of the `public/` folder to your static hosting / CDN. There are also a couple settings you should configure on the server. -If you want to use server suggestions, you should also set `ALLOWED_ORIGIN`: - -```sh -ALLOWED_ORIGIN=http://my-custom-shields.s3.amazonaws.com,https://my-custom-shields.s3.amazonaws.com -``` - -This should be a comma-separated list of allowed origin headers. They should -not have paths or trailing slashes. - -To help out users, you can make the Shields server redirect the server root. -Set the `REDIRECT_URI` environment variable: +To help out users, you can make the Shields server redirect the server root. Set the `REDIRECT_URI` environment variable: ```sh REDIRECT_URI=http://my-custom-shields.s3.amazonaws.com/ @@ -209,8 +194,7 @@ sudo node server ## 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. +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. ```bash METRICS_PROMETHEUS_ENABLED=true METRICS_PROMETHEUS_ENDPOINT_ENABLED=true npm start @@ -220,6 +204,4 @@ 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`. +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`. diff --git a/doc/server-secrets.md b/doc/server-secrets.md index 02b7ad1534c35..e72573bc4f2d6 100644 --- a/doc/server-secrets.md +++ b/doc/server-secrets.md @@ -1,13 +1,10 @@ # Server Secrets -It is possible to provide a token or credentials for a number of external -services. These may be used to lift a rate limit or provide access to -private resources from a self-hosted instance. +It is possible to provide a token or credentials for a number of external services. These may be used to lift a rate limit or provide access to private resources from a self-hosted instance. There are two ways of setting secrets: -1. Via environment variables. This is a good way to set them in a PaaS - environment. +1. Via environment variables. This is a good way to set them in a PaaS environment. ```sh DRONE_TOKEN=... @@ -25,40 +22,24 @@ private: drone_token: '...' ``` -For more complex scenarios, configuration files can cascade. See the [node-config documentation][] -for details. +For more complex scenarios, configuration files can cascade. See the [node-config documentation][] for details. [node-config documentation]: https://github.com/lorenwest/node-config/wiki/Configuration-Files ## Authorized origins -Several of the badges provided by Shields allow users to specify the target -URL/server of the upstream instance to use via a query parameter in the badge URL -(e.g. https://img.shields.io/nexus/s/com.google.guava/guava?server=https%3A%2F%2Foss.sonatype.org). -This supports scenarios where your users may need badges from multiple upstream -targets, for example if you have more than one Nexus server. +Several of the badges provided by Shields allow users to specify the target URL/server of the upstream instance to use via a query parameter in the badge URL (e.g. https://img.shields.io/nexus/s/com.google.guava/guava?server=https%3A%2F%2Foss.sonatype.org). This supports scenarios where your users may need badges from multiple upstream targets, for example if you have more than one Nexus server. -Accordingly, if you configure credentials for one of these services with your -self-hosted Shields instance, you must also specifically authorize the hosts -to which the credentials are allowed to be sent. If your self-hosted Shields -instance then receives a badge request for a target that does not match any -of the authorized origins, one of two things will happen: +Accordingly, if you configure credentials for one of these services with your self-hosted Shields instance, you must also specifically authorize the hosts to which the credentials are allowed to be sent. If your self-hosted Shields instance then receives a badge request for a target that does not match any of the authorized origins, one of two things will happen: -- if credentials are required for the targeted service, Shields will render - an error badge. -- if credentials are optional for the targeted service, Shields will attempt - the request, but without sending any credentials. +- if credentials are required for the targeted service, Shields will render an error badge. +- if credentials are optional for the targeted service, Shields will attempt the request, but without sending any credentials. -When setting authorized origins through an environment variable, use a space -to separate multiple origins. Note that failing to define authorized origins -for a service will default to an empty list, i.e. no authorized origins. +When setting authorized origins through an environment variable, use a space to separate multiple origins. Note that failing to define authorized origins for a service will default to an empty list, i.e. no authorized origins. -It is highly recommended to use `https` origins with valid SSL, to avoid the -possibility of exposing your credentials, for example through DNS-based attacks. +It is highly recommended to use `https` origins with valid SSL, to avoid the possibility of exposing your credentials, for example through DNS-based attacks. -It is also recommended to use tokens for a service account having -[the fewest privileges needed][polp] for fetching the relevant status -information. +It is also recommended to use tokens for a service account having [the fewest privileges needed][polp] for fetching the relevant status information. [polp]: https://en.wikipedia.org/wiki/Principle_of_least_privilege @@ -85,8 +66,7 @@ An Azure DevOps Token (PAT) is required for accessing [private Azure DevOps proj - `BITBUCKET_USER` (yml: `private.bitbucket_username`) - `BITBUCKET_PASS` (yml: `private.bitbucket_password`) -Bitbucket badges use basic auth. Provide a username and password to give your -self-hosted Shields installation access to private repositories hosted on bitbucket.org. +Bitbucket badges use basic auth. Provide a username and password to give your self-hosted Shields installation access to private repositories hosted on bitbucket.org. ### Bitbucket Server @@ -94,25 +74,41 @@ self-hosted Shields installation access to private repositories hosted on bitbuc - `BITBUCKET_SERVER_USER` (yml: `private.bitbucket_server_username`) - `BITBUCKET_SERVER_PASS` (yml: `private.bitbucket_server_password`) -Bitbucket badges use basic auth. Provide a username and password to give your -self-hosted Shields installation access to a private Bitbucket Server instance. +Bitbucket badges use basic auth. Provide a username and password to give your self-hosted Shields installation access to a private Bitbucket Server instance. + +### CurseForge + +- `CURSEFORGE_API_KEY` (yml: `private.curseforge_api_key`) + +A CurseForge API key is required to use the [CurseForge API][cf api]. To obtain an API key, [signup to CurseForge Console][cf signup] with a Google account and create an organization, then go to the [API keys page][cf api key] and copy the generated API key. + +[cf api]: https://docs.curseforge.com +[cf signup]: https://console.curseforge.com/#/signup +[cf api key]: https://console.curseforge.com/#/api-keys ### Discord -Using a token for Dicsord is optional but will allow higher API rates. +Using a token for Discord is optional but will allow higher API rates. + +- `DISCORD_BOT_TOKEN` (yml: `private.discord_bot_token`) + +Register an application in the [Discord developer console](https://discord.com/developers). To obtain a token, simply create a bot for your application. -- `DISCORD_BOT_TOKEN` (yml: `discord_bot_token`) +### DockerHub -Register an application in the [Discord developer console](https://discord.com/developers). -To obtain a token, simply create a bot for your application. +Using authentication for DockerHub is optional but can be used to allow higher API rates or access to private repos. + +- `DOCKERHUB_USER` (yml: `private.dockerhub_username`) +- `DOCKERHUB_PAT` (yml: `private.dockerhub_pat`) + +`DOCKERHUB_PAT` is a Personal Access Token. Generate a token in your [account security settings](https://hub.docker.com/settings/security) with "Read-Only" or "Public Repo Read-Only", depending on your needs. ### Drone - `DRONE_ORIGINS` (yml: `public.services.drone.authorizedOrigins`) - `DRONE_TOKEN` (yml: `private.drone_token`) -The self-hosted Drone API [requires authentication][drone auth]. Log in to your -Drone instance and obtain a token from the user profile page. +The self-hosted Drone API [requires authentication][drone auth]. Log in to your Drone instance and obtain a token from the user profile page. [drone auth]: https://0-8-0.docs.drone.io/api-authentication/ @@ -121,31 +117,33 @@ Drone instance and obtain a token from the user profile page. - `GITHUB_URL` (yml: `public.services.github.baseUri`) - `GH_TOKEN` (yml: `private.gh_token`) -Because of GitHub rate limits, you will need to provide a token, or else badges -will stop working once you hit 60 requests per hour, the -[unauthenticated rate limit][github rate limit]. +Because of GitHub rate limits, you will need to provide a token, or else badges will stop working once you hit 60 requests per hour, the [unauthenticated rate limit][github rate limit]. + +You can [create a personal access token][personal access tokens] (PATs) through the GitHub website. When you create the token, you can choose to give read access to your repositories. If you do that, your self-hosted Shields installation will have access to your private repositories. -You can [create a personal access token][personal access tokens] through the -GitHub website. When you create the token, you can choose to give read access -to your repositories. If you do that, your self-hosted Shields installation -will have access to your private repositories. +For most users we recommend using a classic PAT as opposed to a [fine-grained PAT][fine-grained pat]. It is possible to request a fairly large subset of the GitHub badge suite using a fine-grained PAT for authentication but there are also some badges that won't work. This is because some of our badges make use of GitHub's v4 GraphQL API and the GraphQL API only supports authentication with a classic PAT. -When a `gh_token` is specified, it is used in place of the Shields token -rotation logic. +When a `gh_token` is specified, it is used in place of the Shields token rotation logic. -`GITHUB_URL` can be used to optionally send all the GitHub requests to a -GitHub Enterprise server. This can be done in conjunction with setting a -token, though it's not required. +`GITHUB_URL` can be used to optionally send all the GitHub requests to a GitHub Enterprise server. This can be done in conjunction with setting a token, though it's not required. [github rate limit]: https://developer.github.com/v3/#rate-limiting [personal access tokens]: https://github.com/settings/tokens +[fine-grained pat]: https://github.blog/2022-10-18-introducing-fine-grained-personal-access-tokens-for-github/ - `GH_CLIENT_ID` (yml: `private.gh_client_id`) - `GH_CLIENT_SECRET` (yml: `private.gh_client_secret`) -These settings are used by shields.io for GitHub OAuth app authorization -but will not be necessary for most self-hosted installations. See -[production-hosting.md](./production-hosting.md). +These settings are used by shields.io for GitHub OAuth app authorization but will not be necessary for most self-hosted installations. See [production-hosting.md](./production-hosting.md). + +### Gitea + +- `GITEA_ORIGINS` (yml: `public.services.gitea.authorizedOrigins`) +- `GITEA_TOKEN` (yml: `private.gitea_token`) + +A Gitea [Personal Access Token][gitea-pat] is required for accessing private content. If you need a Gitea token for your self-hosted Shields server then we recommend limiting the scopes to the minimal set necessary for the badges you are using. + +[gitea-pat]: https://docs.gitea.com/development/api-usage#generating-and-listing-api-tokens ### GitLab @@ -162,8 +160,7 @@ A GitLab [Personal Access Token][gitlab-pat] is required for accessing private c - `JENKINS_USER` (yml: `private.jenkins_user`) - `JENKINS_PASS` (yml: `private.jenkins_pass`) -Provide a username and password to give your self-hosted Shields installation -access to a private Jenkins CI instance. +Provide a username and password to give your self-hosted Shields installation access to a private Jenkins CI instance. ### Jira @@ -171,8 +168,25 @@ access to a private Jenkins CI instance. - `JIRA_USER` (yml: `private.jira_user`) - `JIRA_PASS` (yml: `private.jira_pass`) -Provide a username and password to give your self-hosted Shields installation -access to a private JIRA instance. +Provide a username and password to give your self-hosted Shields installation access to a private JIRA instance. + +### Libraries.io/Bower + +- `LIBRARIESIO_TOKENS` (yml: `private.librariesio_tokens`) + +Note that the Bower badges utilize the Libraries.io API, so use this secret for both Libraries.io badges and/or Bower badges. + +Just like the `*_ORIGINS` type secrets, this value can accept a single token as a string, or a group of tokens provided as an array of strings. For example: + +```yaml +private: + librariesio_tokens: my-token +## Or +private: + librariesio_tokens: [my-token some-other-token] +``` + +When using the environment variable with multiple tokens, be sure to use a space to separate the tokens, e.g. `LIBRARIESIO_TOKENS="my-token some-other-token"` ### Nexus @@ -180,51 +194,72 @@ access to a private JIRA instance. - `NEXUS_USER` (yml: `private.nexus_user`) - `NEXUS_PASS` (yml: `private.nexus_pass`) -Provide a username and password to give your self-hosted Shields installation -access to your private nexus repositories. +Provide a username and password to give your self-hosted Shields installation access to your private nexus repositories. ### npm - `NPM_ORIGINS` (yml: `public.services.npm.authorizedOrigins`) - `NPM_TOKEN` (yml: `private.npm_token`) -[Generate an npm token][npm token] to give your self-hosted Shields -installation access to private npm packages +[Generate an npm token][npm token] to give your self-hosted Shields installation access to private npm packages [npm token]: https://docs.npmjs.com/getting-started/working_with_tokens -## Open Build Service +### Open Build Service - `OBS_USER` (yml: `private.obs_user`) - `OBS_PASS` (yml: `private.obs_user`) -Only authenticated users are allowed to access the Open Build Service API. -Authentication is done by sending a Basic HTTP Authorisation header. A user -account for the [reference instance](https://build.opensuse.org) is a SUSE -IdP account, which can be created [here](https://idp-portal.suse.com/univention/self-service/#page=createaccount). +Only authenticated users are allowed to access the Open Build Service API. Authentication is done by sending a Basic HTTP Authorisation header. A user account for the [reference instance](https://build.opensuse.org) is a SUSE IdP account, which can be created [here](https://idp-portal.suse.com/univention/self-service/#page=createaccount). + +While OBS supports [API tokens](https://openbuildservice.org/help/manuals/obs-user-guide/cha.obs.authorization.token.html#id-1.5.10.16.4), they can only be scoped to execute specific actions on a POST request. This means however, that an actual account is required to read the build status of a package. + +### OpenCollective + +- `OPENCOLLECTIVE_TOKEN` (yml: `private.opencollective_token`) + +OpenCollective's GraphQL API only allows 10 reqs/minute for anonymous users. An [API token](https://graphql-docs-v2.opencollective.com/access) can be provided to access a higher rate limit of 100 reqs/minute. + +### Pepy + +- `PEPY_KEY` (yml: `private.pepy_key`) + +The Pepy API requires authentication. To obtain a key, Create an account, sign in and obtain generate a key on your [account page](https://www.pepy.tech/user). + +### PyPI + +- `PYPI_URL` (yml: `public.pypi.baseUri`) + +`PYPI_URL` can be used to optionally send all the PyPI requests to a Self-hosted Pypi registry, users can also override this by query parameter `pypiBaseUrl`. -While OBS supports [API tokens](https://openbuildservice.org/help/manuals/obs-user-guide/cha.obs.authorization.token.html#id-1.5.10.16.4), -they can only be scoped to execute specific actions on a POST request. This -means however, that an actual account is required to read the build status -of a package. +### Reddit + +Using a token for Reddit is optional but will allow higher API rates. + +- `REDDIT_CLIENT_ID` (yml: `private.reddit_client_id`) +- `REDDIT_CLIENT_SECRET` (yml: `private.reddit_client_secret`) + +Register to use the API using [this form](https://support.reddithelp.com/hc/en-us/requests/new?ticket_form_id=14868593862164) and create an app in the [Reddit preferences page](https://www.reddit.com/prefs/apps) in order to obtain a client id and a client secret for making Reddit API calls. ### SymfonyInsight (formerly Sensiolabs) - `SL_INSIGHT_USER_UUID` (yml: `private.sl_insight_userUuid`) - `SL_INSIGHT_API_TOKEN` (yml: `private.sl_insight_apiToken`) -The SymfonyInsight API requires authentication. To obtain a token, -Create an account, sign in and obtain a uuid and token from your -[account page](https://insight.sensiolabs.com/account). +The SymfonyInsight API requires authentication. To obtain a token, Create an account, sign in and obtain a uuid and token from your [account page](https://insight.sensiolabs.com/account). ### SonarQube - `SONAR_ORIGINS` (yml: `public.services.sonar.authorizedOrigins`) - `SONARQUBE_TOKEN` (yml: `private.sonarqube_token`) -[Generate a token](https://docs.sonarqube.org/latest/user-guide/user-token/) -to give your self-hosted Shields installation access to a -private SonarQube instance or private project on a public instance. +[Generate a token](https://docs.sonarqube.org/latest/user-guide/user-token/) to give your self-hosted Shields installation access to a private SonarQube instance or private project on a public instance. + +### StackApps (for StackExchange and StackOverflow) + +- `STACKAPPS_API_KEY`: (yml: `private.stackapps_api_key`) + +Anonymous requests to the stackexchange API are limited to 300 calls per day. To increase your quota to 10,000 calls per day, create an account at [StackApps](https://stackapps.com/) and [register an OAuth app](https://stackapps.com/apps/oauth/register). Having registered an OAuth app, you'll be granted a key which can be used to increase your request quota. It is not necessary to perform a full OAuth Flow to gain an access token. ### TeamCity @@ -232,49 +267,32 @@ private SonarQube instance or private project on a public instance. - `TEAMCITY_USER` (yml: `private.teamcity_user`) - `TEAMCITY_PASS` (yml: `private.teamcity_pass`) -Provide a username and password to give your self-hosted Shields installation -access to your private nexus repositories. +Provide a username and password to give your self-hosted Shields installation access to your private nexus repositories. ### Twitch -- `TWITCH_CLIENT_ID` (yml: `twitch_client_id`) -- `TWITCH_CLIENT_SECRET` (yml: `twitch_client_secret`) +- `TWITCH_CLIENT_ID` (yml: `private.twitch_client_id`) +- `TWITCH_CLIENT_SECRET` (yml: `private.twitch_client_secret`) -Register an application in the [Twitch developer console](https://dev.twitch.tv/console) -in order to obtain a client id and a client secret for making Twitch API calls. +Register an application in the [Twitch developer console](https://dev.twitch.tv/console) in order to obtain a client id and a client secret for making Twitch API calls. ### Weblate - `WEBLATE_ORIGINS` (yml: `public.services.weblate.authorizedOrigins`) - `WEBLATE_API_KEY` (yml: `private.weblate_api_key`) -By default Weblate throttles [unauthenticated request][weblate authentication] -to only 100 requests per day, after this you will need an API key or else -badges will stop working. +Weblate heavily throttles [unauthenticated request][weblate authentication], it is recommended to use an API key for requests. -You can find your Weblate API key in your profile under -["API access"][weblate api key location]. +You can find your Weblate API key in your profile under ["API access"][weblate api key location]. [weblate authentication]: https://docs.weblate.org/en/latest/api.html#authentication-and-generic-parameters [weblate api key location]: https://hosted.weblate.org/accounts/profile/#api -### Wheelmap - -- `WHEELMAP_TOKEN` (yml: `private.wheelmap_token`) - -The wheelmap API requires authentication. To obtain a token, -Create an account, [sign in][wheelmap token] and use the _Authentication Token_ -displayed on your profile page. - -[wheelmap token]: http://classic.wheelmap.org/en/users/sign_in - ### YouTube - `YOUTUBE_API_KEY` (yml: `private.youtube_api_key`) -The YouTube API requires authentication. To obtain an API key, -log in to a Google account, go to the [credentials page][youtube credentials], -and create an API key for the YouTube Data API v3. +The YouTube API requires authentication. To obtain an API key, log in to a Google account, go to the [credentials page][youtube credentials], and create an API key for the YouTube Data API v3. [youtube credentials]: https://console.developers.google.com/apis/credentials @@ -282,8 +300,7 @@ and create an API key for the YouTube Data API v3. - `SENTRY_DSN` (yml: `private.sentry_dsn`) -A [Sentry DSN][] may be used to send error reports from your installation to -[Sentry.io][]. For more info, see the [self hosting docs][]. +A [Sentry DSN][] may be used to send error reports from your installation to [Sentry.io][]. For more info, see the [self hosting docs][]. [sentry dsn]: https://docs.sentry.io/error-reporting/quickstart/?platform=javascript#configure-the-dsn [sentry.io]: http://sentry.io/ diff --git a/doc/service-tests.md b/doc/service-tests.md index 0a332806bc9e6..de3b8c9eb27a5 100644 --- a/doc/service-tests.md +++ b/doc/service-tests.md @@ -1,18 +1,14 @@ # Service tests -When creating a badge for a new service or changing a badge's behavior, -automated tests should be included. They serve three purposes: +When creating a badge for a new service or changing a badge's behavior, automated tests should be included. They serve three purposes: -1. The contributor and reviewer can easily verify the code works as - intended. +1. The contributor and reviewer can easily verify the code works as intended. -2. When a badge stops working due to an upstream API, maintainers can find out - right away. +2. When a badge stops working due to an upstream API, maintainers can find out right away. -3. They speed up future contributors when they are debugging or improving a - badge. +3. They speed up future contributors when they are debugging or improving a badge. -Test should cover: +Tests should cover: 1. Valid behavior 2. Optional parameters like tags or branches @@ -21,14 +17,13 @@ Test should cover: ## Tutorial -Before getting started, set up a development environment by following the -[setup instructions](https://github.com/badges/shields/blob/master/doc/TUTORIAL.md#2-setup) +Before getting started, set up a development environment by following the [setup instructions](https://github.com/badges/shields/blob/master/doc/TUTORIAL.md#2-setup) -We will write some tests for the [Wercker Build service](https://github.com/badges/shields/blob/master/services/wercker/wercker.service.js) +We will write some tests for [Docs.rs](https://github.com/badges/shields/blob/master/services/docsrs/docsrs.service.js), a service that builds documentation of crates, which are packages in the Rust programming language. ### (1) Boilerplate -The code for our badge is in `services/wercker/wercker.service.js`. Tests for this badge should be stored in `services/wercker/wercker.tester.js`. +The code for our badge is in `services/docsrs/docsrs.service.js`. Tests for this badge should be stored in `services/docsrs/docsrs.tester.js`. We'll start by adding some boilerplate to our file: @@ -38,44 +33,34 @@ import { createServiceTester } from '../tester.js' export const t = await createServiceTester() ``` -If our `.service.js` module exports a single class, we can -`createServiceTester`, which uses convention to create a -`ServiceTester` object. Calling this inside -`services/wercker/wercker.tester.js` will create a `ServiceTester` object -configured for the service exported in `services/wercker/wercker.service.js`. -We will add our tests to this `ServiceTester` object `t`, which is exported -from the module. +If our `.service.js` module exports a single class, we can `createServiceTester`, which uses convention to create a `ServiceTester` object. Calling this inside `services/docsrs/docsrs.tester.js` will create a `ServiceTester` object configured for the service exported in `services/docsrs/docsrs.service.js`. We will add our tests to this `ServiceTester` object `t`, which is exported from the module. ### (2) Our First Test Case First we'll add a test for the typical case: ```js -import { isBuildStatus } from '../test-validators.js' +import Joi from 'joi' -t.create('Build status') - .get('/build/wercker/go-wercker-api.json') - .expectBadge({ label: 'build', message: isBuildStatus }) +t.create('Docs with no version specified') + .get('/tokio.json') + .expectBadge({ + label: 'docs', + message: Joi.equal('passing', 'failing'), + }) ``` -1. The `create()` method adds a new test to the tester object. - The chained-on calls come from the API testing framework [IcedFrisby][]. - Here's a [longer example][] and the complete [API guide][icedfrisby api]. +1. The `create()` method adds a new test to the tester object. The chained-on calls come from the API testing framework [IcedFrisby][]. Here's a [longer example][] and the complete [API guide][icedfrisby api]. 2. We use the `get()` method to request a badge. There are several points to consider here: - - We need a real project to test against. In this case we have used [wercker/go-wercker-api](https://app.wercker.com/wercker/go-wercker-api/runs) but we could have chosen any stable project. + - We need a real crate to test against. In this case we have used [Tokio](https://docs.rs/tokio) but we could have chosen any one. - 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. - 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]. -4. We expect `label` to be a string literal `"build"`. -5. Because this test depends on a live service, we don't want our test to depend on our API call returning a particular build status. Instead we should perform a "picture check" to assert that the badge data conforms to an expected pattern. Our test should not depend on the status of the example project's build, but should fail if trying to generate the badge throws an error, or if there is a breaking change to the upstream API. In this case we will use a pre-defined regular expression to check that the badge value looks like a build status. [services/test-validators.js](https://github.com/badges/shields/blob/master/services/test-validators.js) defines a number of useful validators we can use. Many of the common badge types (version, downloads, rank, etc.) already have validators defined here. - -When defining an IcedFrisby test, typically you would invoke the `toss()` -method, to register the test. This is not necessary, because the Shields test -harness will call it for you. + - All badges on shields can be requested in a number of formats. As well as calling https://img.shields.io/docsrs/tokio.svg to generate ![](https://img.shields.io/docsrs/tokio.svg) we can also call https://img.shields.io/docsrs/tokio.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 `/docsrs/tokio.json` here, only `/tokio.json`. When we create a tester object with `createServiceTester()` the URL base defined in our service class (in this case `/docsrs`) 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. Joi is a validation library that is built 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]. +4. We expect `label` to be a string literal `"docs"`. +5. Because this test depends on a live service, we don't want our test to depend on our API call returning a particular build status. Instead we should perform a "picture check" to assert that the badge data conforms to an expected pattern. Our test should not depend on the status of the example crates's documentation build, but should fail if trying to generate the badge throws an error, or if there is a breaking change to the upstream API. In this case, we specify a list with all possible response values, `Joi.equal('passing', 'failing')`. For more complex cases, [services/test-validators.js](https://github.com/badges/shields/blob/master/services/test-validators.js) defines a number of useful validators we can use with regular expressions. Many of the common badge types (version, downloads, rank, etc.) already have validators defined there. + +When defining an IcedFrisby test, typically you would invoke the `toss()` method, to register the test. This is not necessary, because the Shields test harness will call it for you. [icedfrisby]: https://github.com/MarkHerhold/IcedFrisby [longer example]: https://github.com/MarkHerhold/IcedFrisby/#show-me-some-code @@ -89,23 +74,22 @@ harness will call it for you. Lets run the test we have written: ``` -npm run test:services -- --only=wercker +npm run test:services -- --only=docsrs ``` -The `--only=` option indicates which service or services you want to test. You -can provide a comma-separated list here. +The `--only=` option indicates which service or services you want to test. You can provide a comma-separated list here. -The `--` tells the NPM CLI to pass the remaining arguments through to the test -runner. +The `--` tells the NPM CLI to pass the remaining arguments through to the test runner. Here's the output: ``` -Server is starting up: http://lib/service-test-runner/cli.js:80/ - Wercker - Build status - ✓ - [ GET /build/wercker/go-wercker-api.json ] (572ms) +Server is starting up: http://localhost:1111/ + DocsRs + [live] Docs with no version specified + √ + [ GET /tokio.json ] (441ms) + 1 passing (1s) ``` @@ -115,137 +99,106 @@ That's looking good! Sometimes if we have a failing test, it is useful to be able to see some logging output to help work out why the test is failing. We can do that by calling `npm run test:services:trace`. Try running ``` -npm run test:services:trace -- --only=wercker +npm run test:services:trace -- --only=docsrs ``` to run the test with some additional debug output. ### (4) Writing More Tests -We should write tests cases for valid paths through our code. The Wercker badge supports an optional branch parameter so we'll add a second test for a branch build. +We should write tests cases for valid paths through our code. The Docs.rs badge supports an optional version parameter so we'll add a second test for a branch build. In this case, we know for sure that the documentation for this older version was successfully built, we specify a string literal instead of a Joi schema for `message`. This narrows down the expectation and gives us a more helpful error message if the test fails. ```js -t.create('Build status (with branch)') - .get('/build/wercker/go-wercker-api/master.json') - .expectBadge({ label: 'build', message: isBuildStatus }) +t.create('Passing docs for version').get('/tokio/1.37.0.json').expectBadge({ + label: 'docs@1.37.0', + message: 'passing', + color: 'brightgreen', +}) ``` ``` -Server is starting up: http://lib/service-test-runner/cli.js:80/ - Wercker - Build status - ✓ - [ GET /build/wercker/go-wercker-api.json ] (572ms) - Build status (with branch) - ✓ - [ GET /build/wercker/go-wercker-api/master.json ] (368ms) - - 2 passing (1s) -``` +Server is starting up: http://localhost:1111/ + DocsRs + [live] Docs with no version specified + √ + [ GET /tokio.json ] (408ms) + [live] Passing docs for version + √ + [ GET /tokio/1.37.0.json ] (171ms) -Once we have multiple tests, sometimes it is useful to run only one test. We can do this using the `--fgrep` argument. For example: -``` -npm run test:services -- --only="wercker" --fgrep="Build status (with branch)" + 2 passing (2s) ``` -Having covered the typical and custom cases, we'll move on to errors. We should include a test for the 'not found' response and also tests for any other custom error handling. The Wercker integration defines a custom error condition for 401 as well as a custom 404 message: +Once we have multiple tests, sometimes it is useful to run only one test. We can do this using the `--fgrep` argument. For example: -```js -errorMessages: { - 401: 'private application not supported', - 404: 'application not found', -} ``` - -First we'll add a test for a project which will return a 404 error: - -```js -t.create('Build status (application not found)') - .get('/build/some-project/that-doesnt-exist.json') - .expectBadge({ label: 'build', message: 'application not found' }) +npm run test:services -- --only="docsrs" --fgrep="Passing docs for version" ``` -In this case we are expecting a string literal instead of a pattern for `message`. This narrows down the expectation and gives us a more helpful error message if the test fails. - -We also want to include a test for the 'private application not supported' case. One way to do this would be to find another example of a private project which is unlikely to change. For example: +Documentation for tokio version 1.32.1 failed to build, we can also add a corresponding test: ```js -t.create('Build status (private application)') - .get('/build/wercker/blueprint.json') - .expectBadge({ label: 'build', message: 'private application not supported' }) +t.create('Failing docs for version').get('/tokio/1.32.1.json').expectBadge({ + label: 'docs@1.32.1', + message: 'failing', + color: 'red', +}) ``` -## (5) Mocking Responses +Note that in these tests, we have specified a `color` parameter in `expectBadge`. This is helpful in a case like this when we want to test custom color logic, but it is only necessary to explicitly test color values if our badge implements custom logic for setting the badge colors. -If we didn't have a stable example of a private project, another approach would be to mock the response. An alternative test for the 'private application' case might look like: +Having covered the typical and custom cases, we'll move on to errors. We should include a test for the 'not found' response and also tests for any other custom error handling. When a version is specified, the Docs.rs integration defines a custom error condition for 400 status codes: ```js -t.create('Build status (private application)') - .get('/build/wercker/go-wercker-api.json') - .intercept(nock => - nock('https://app.wercker.com/api/v3/applications/') - .get('/wercker/go-wercker-api/builds?limit=1') - .reply(401) - ) - .expectBadge({ label: 'build', message: 'private application not supported' }) +httpErrors: version ? { 400: 'malformed version' } : {}, ``` -This will intercept the request and provide our own mock response. -We use the `intercept()` method provided by the -[icedfrisby-nock plugin][icedfrisby-nock]. It takes a setup function, -which returns an interceptor, and exposes the full API of the HTTP mocking -library [Nock][]. +First we'll add a test for a crate and a test for a version which will return 404 errors: -Nock is fussy. All parts of a request must match perfectly for the mock to -take effect, including the HTTP method (in this case GET), scheme (https), host, -and path. +```js +t.create('Crate not found') + .get('/not-a-crate/latest.json') + .expectBadge({ label: 'docs', message: 'not found' }) -[icedfrisby-nock]: https://github.com/paulmelnikow/icedfrisby-nock#usage -[nock]: https://github.com/node-nock/nock +t.create('Version not found') + .get('/tokio/0.8.json') + .expectBadge({ label: 'docs', message: 'not found' }) +``` -Our test suite should also include service tests which receive a known value from the API. For example, in the `render()` method of our service, there is some logic which sets the badge color based on the build status: +We also want to include a test for a case where a malformed version was specified. For example: ```js -static render({ status, result }) { - if (status === 'finished') { - if (result === 'passed') { - return { message: 'passing', color: 'brightgreen' } - } else { - return { message: result, color: 'red' } - } - } - return { message: status } -} +t.create('Malformed version') + .get('/tokio/not-a-version.json') + .expectBadge({ label: 'docs', message: 'malformed version' }) ``` -We can also use nock to intercept API calls to return a known response body. +## (5) Mocking Responses + +If we didn't have a stable example of crate version with a failing documentation build, another approach would be to mock the response. An alternative test for the 'Failing docs for version' case might look like: ```js -t.create('Build passed') - .get('/build/wercker/go-wercker-api.json') +t.create('Failing docs for version') + .get('/tokio/1.32.1.json') .intercept(nock => - nock('https://app.wercker.com/api/v3/applications/') - .get('/wercker/go-wercker-api/builds?limit=1') - .reply(200, [{ status: 'finished', result: 'passed' }]) + nock('https://docs.rs/crate') + .get('/tokio/1.32.1/status.json') + .reply(200, { doc_status: false }), ) .expectBadge({ - label: 'build', - message: 'passing', - color: 'brightgreen', + label: 'docs@1.32.1', + message: 'failing', + color: 'red', }) - -t.create('Build failed') - .get('/build/wercker/go-wercker-api.json') - .intercept(nock => - nock('https://app.wercker.com/api/v3/applications/') - .get('/wercker/go-wercker-api/builds?limit=1') - .reply(200, [{ status: 'finished', result: 'failed' }]) - ) - .expectBadge({ label: 'build', message: 'failed', color: 'red' }) ``` -Note that in these tests, we have specified a `color` parameter in `expectBadge`. This is helpful in a case like this when we want to test custom color logic, but it is only necessary to explicitly test color values if our badge implements custom logic for setting the badge colors. +This will intercept the request and provide our own mock response. We use the `intercept()` method provided by the [icedfrisby-nock plugin][icedfrisby-nock]. It takes a setup function, which returns an interceptor, and exposes the full API of the HTTP mocking library [Nock][]. + +Nock is fussy. All parts of a request must match perfectly for the mock to take effect, including the HTTP method (in this case GET), scheme (https), host, and path. + +[icedfrisby-nock]: https://github.com/paulmelnikow/icedfrisby-nock#usage +[nock]: https://github.com/nock/nock ## Code coverage @@ -254,7 +207,7 @@ By checking code coverage, we can make sure we've covered all our bases. We can generate a coverage report and open it: ``` -npm run coverage:test:services -- --only=wercker +npm run coverage:test:services -- -- --only=docsrs npm run coverage:report:open ``` @@ -266,13 +219,11 @@ Pull requests must follow the [documented conventions][pr-conventions] in order ## Getting help -If you have questions about how to write your tests, please open an issue. If -there's already an issue open for the badge you're working on, you can post a -comment there instead. +If you have questions about how to write your tests, please open an issue. If there's already an issue open for the badge you're working on, you can post a comment there instead. ## Further reading - [IcedFrisby API][] - [Joi API][] - [icedfrisby-nock][] -- [Nock API](https://github.com/node-nock/nock#use) +- [Nock API](https://github.com/nock/nock#usage) diff --git a/entrypoint.spec.js b/entrypoint.spec.js index 4325c0365ab81..6246d70a91474 100644 --- a/entrypoint.spec.js +++ b/entrypoint.spec.js @@ -18,7 +18,7 @@ after('shut down the server', async function () { it('should render a badge', async function () { this.timeout('30s') const { statusCode, body } = await got( - 'http://localhost:1111/badge/fruit-apple-green.svg' + 'http://localhost:1111/badge/fruit-apple-green.svg', ) expect(statusCode).to.equal(200) expect(body).to.satisfy(isSvg).and.to.include('fruit').and.to.include('apple') diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000000000..56c9819342326 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,250 @@ +import chaiFriendlyPlugin from 'eslint-plugin-chai-friendly' +import cypressPlugin from 'eslint-plugin-cypress' +import jsdocPlugin from 'eslint-plugin-jsdoc' +import mochaPlugin from 'eslint-plugin-mocha' +import icedfrisbyPlugin from 'eslint-plugin-icedfrisby' +import sortClassMembersPlugin from 'eslint-plugin-sort-class-members' +import importPlugin from 'eslint-plugin-import' +import reactHooksPlugin from 'eslint-plugin-react-hooks' +import prettierConfig from 'eslint-plugin-prettier/recommended' +import promisePlugin from 'eslint-plugin-promise' +import globals from 'globals' +import neostandard from 'neostandard' +import tsParser from '@typescript-eslint/parser' +import js from '@eslint/js' + +// Config that is used across the whole codebase +// and customisations to built-in ESLint rules +const globalConfig = { + plugins: { + import: importPlugin, + promise: promisePlugin, + }, + + rules: { + 'import/order': ['error', { 'newlines-between': 'never' }], + 'promise/prefer-await-to-then': 'error', + + // ESLint built-in rules config + 'no-empty': ['error', { allowEmptyCatch: true }], + 'no-var': 'error', + 'prefer-const': 'error', + 'arrow-body-style': ['error', 'as-needed'], + 'object-shorthand': ['error', 'properties'], + 'prefer-template': 'error', + 'func-style': ['error', 'declaration', { allowArrowFunctions: true }], + 'new-cap': ['error', { capIsNew: true }], + quotes: [ + 'error', + 'single', + { avoidEscape: true, allowTemplateLiterals: false }, + ], + camelcase: [ + 'error', + { + ignoreDestructuring: true, + properties: 'never', + ignoreGlobals: true, + allow: ['^UNSAFE_'], + }, + ], + }, +} + +// config specific to linting Node (CommonJS) files +const commonJsConfig = { + files: ['badge-maker/**/*.js', '**/*.cjs'], + + languageOptions: { + globals: { + ...globals.node, + }, + }, +} + +// config specific to linting Node (ESModules) files +const nodeEsmConfig = { + files: ['**/*.@(js|mjs)', '!frontend/**/*.js', '!badge-maker/**/*.js'], + + languageOptions: { + globals: { + ...globals.node, + }, + parser: tsParser, + sourceType: 'module', + }, + + rules: { + 'no-console': 'off', + }, +} + +// config specific to linting Frontend (ESModules) files +const frontendConfig = { + files: ['frontend/**/*.js'], + + plugins: { + 'react-hooks': reactHooksPlugin, + }, + + languageOptions: { + globals: { + ...globals.browser, + }, + sourceType: 'module', + }, + + rules: { + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'error', + }, +} + +// config specific to linting Services +const servicesConfig = { + files: ['core/base-service/**/*.js', 'services/**/*.js'], + + plugins: { + 'sort-class-members': sortClassMembersPlugin, + }, + + rules: { + 'sort-class-members/sort-class-members': [ + 'error', + { + order: [ + 'name', + 'category', + 'isRetired', + 'route', + 'auth', + 'openApi', + '_cacheLength', + 'defaultBadgeData', + 'render', + 'constructor', + 'fetch', + 'transform', + 'handle', + ], + }, + ], + }, +} + +// config specific to linting Mocha tests +const mochaConfig = { + files: [ + '**/*.spec.@(js|mjs|ts)', + '**/*.integration.js', + '**/test-helpers.js', + 'core/service-test-runner/**/*.js', + ], + + plugins: { + mocha: mochaPlugin, + }, + + languageOptions: { + globals: { + ...globals.mocha, + }, + }, + + rules: { + 'mocha/no-exclusive-tests': 'error', + 'mocha/no-pending-tests': 'error', + 'mocha/no-mocha-arrows': 'error', + 'mocha/prefer-arrow-callback': 'error', + 'no-unused-expressions': 'off', + }, +} + +// config specific to linting Cypress tests +const cypressConfig = { + files: ['**/*.cy.@(js|ts)'], + ...cypressPlugin.configs.recommended, +} +// append these to cypress.configs.recommended, without overwriting +cypressConfig.plugins.mocha = mochaPlugin +cypressConfig.rules['mocha/no-exclusive-tests'] = 'error' +cypressConfig.rules['mocha/no-pending-tests'] = 'error' +cypressConfig.rules['mocha/no-mocha-arrows'] = 'off' + +// config specific to linting Service tests (IcedFrisby) +const serviceTestsConfig = { + files: ['services/**/*.tester.js'], + + plugins: { + icedfrisby: icedfrisbyPlugin, + }, + + rules: { + 'icedfrisby/no-exclusive-tests': 'error', + 'icedfrisby/no-skipped-tests': 'error', + 'no-unused-expressions': 'off', + }, +} + +// config specific to linting JSDoc comments +const jsDocConfig = { + plugins: { + jsdoc: jsdocPlugin, + }, + + rules: { + 'jsdoc/require-jsdoc': 'off', + 'jsdoc/no-undefined-types': ['error', { definedTypes: ['Joi'] }], + 'jsdoc/check-alignment': 'error', + 'jsdoc/check-param-names': 'error', + 'jsdoc/check-tag-names': 'error', + 'jsdoc/check-types': 'error', + 'jsdoc/implements-on-classes': 'error', + 'jsdoc/tag-lines': ['error', 'any', { startLines: 1 }], + 'jsdoc/require-param': 'error', + 'jsdoc/require-param-description': 'error', + 'jsdoc/require-param-name': 'error', + 'jsdoc/require-param-type': 'error', + 'jsdoc/require-returns': 'error', + 'jsdoc/require-returns-check': 'error', + 'jsdoc/require-returns-description': 'error', + 'jsdoc/require-returns-type': 'error', + 'jsdoc/valid-types': 'error', + }, +} + +const config = [ + { + ignores: [ + 'api-docs/', + 'build', + 'coverage', + '__snapshots__', + 'public', + 'badge-maker/node_modules/', + '!.github/', + 'frontend/.docusaurus/**', + '**/package.json', + ], + }, + + js.configs.recommended, + chaiFriendlyPlugin.configs.recommendedFlat, + ...neostandard({ noStyle: true }), + + globalConfig, + commonJsConfig, + nodeEsmConfig, + frontendConfig, + servicesConfig, + mochaConfig, + cypressConfig, + serviceTestsConfig, + jsDocConfig, + + // register prettierConfig last, as per + // https://github.com/prettier/eslint-plugin-prettier?tab=readme-ov-file#configuration-new-eslintconfigjs + prettierConfig, +] + +export default config diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000000000..f3507898059b9 --- /dev/null +++ b/fly.toml @@ -0,0 +1,53 @@ +app = "shields-io-review-apps" + +kill_signal = "SIGINT" +kill_timeout = 5 +processes = [] + +[env] + HTTPS="false" + GITLAB_ORIGINS = "https://gitlab.com" + METRICS_PROMETHEUS_ENABLED = "false" + REQUEST_TIMEOUT_SECONDS = "20" + REQUIRE_CLOUDFLARE = "false" + USER_AGENT_BASE = "Shields-Review-App" + +[deploy] + strategy = "immediate" + +[experimental] + allowed_public_ports = [] + auto_rollback = true + +[[services]] + http_checks = [] + internal_port = 80 + processes = ["app"] + protocol = "tcp" + script_checks = [] + auto_stop_machines = "suspend" + auto_start_machines = true + + [services.concurrency] + hard_limit = 25 + soft_limit = 20 + type = "connections" + + [[services.ports]] + force_https = true + handlers = ["http"] + port = 80 + + [[services.ports]] + handlers = ["tls", "http"] + port = 443 + + [[services.tcp_checks]] + grace_period = "1s" + interval = "15s" + restart_limit = 0 + timeout = "2s" + +[[vm]] + size = "shared-cpu-1x" + memory = "256mb" diff --git a/frontend/babel.config.cjs b/frontend/babel.config.cjs new file mode 100644 index 0000000000000..6752648189150 --- /dev/null +++ b/frontend/babel.config.cjs @@ -0,0 +1,3 @@ +module.exports = { + presets: [require.resolve('@docusaurus/core/lib/babel/preset')], +} diff --git a/frontend/blog/2023-07-03-new-frontend.md b/frontend/blog/2023-07-03-new-frontend.md new file mode 100644 index 0000000000000..f5322b8abdae8 --- /dev/null +++ b/frontend/blog/2023-07-03-new-frontend.md @@ -0,0 +1,21 @@ +--- +slug: new-frontend +title: We launched a new frontend +authors: + name: chris48s + title: Shields.io Core Team + url: https://github.com/chris48s + image_url: https://avatars.githubusercontent.com/u/6025893 +tags: [] +--- + +Alongside the general visual refresh and improvements to look and feel, our new frontend has allowed us to address a number of long-standing feature requests and enhancements: + +- Clearer and more discoverable documentation for our [static](https://shields.io/badges/static-badge), dynamic [json](https://shields.io/badges/dynamic-json-badge)/[xml](https://shields.io/badges/dynamic-xml-badge)/[yaml](https://shields.io/badges/dynamic-yaml-badge) and [endpoint](https://shields.io/badges/endpoint-badge) badges +- Improved badge builder interface, with all optional query parameters included in the builder for each badge +- Each badge now has its own documentation page, which we can link to. e.g: [https://shields.io/badges/discord](https://shields.io/badges/discord) +- Light/dark mode themes +- Improved search +- Documentation for individual path and query parameters + +The new site also comes with big maintenance benefits for the core team. We rely heavily on [docusaurus](https://docusaurus.io/), [docusaurus-openapi](https://github.com/cloud-annotations/docusaurus-openapi), and [docusaurus-search-local](https://github.com/easyops-cn/docusaurus-search-local). This moves us to a mostly declarative setup, massively reducing the amount of custom frontend code we maintain ourselves. diff --git a/frontend/blog/2023-07-29-tag-filter.md b/frontend/blog/2023-07-29-tag-filter.md new file mode 100644 index 0000000000000..7bb863e2ef5ac --- /dev/null +++ b/frontend/blog/2023-07-29-tag-filter.md @@ -0,0 +1,19 @@ +--- +slug: tag-filter +title: Applying filters to GitHub Tag and Release badges +authors: + name: chris48s + title: Shields.io Core Team + url: https://github.com/chris48s + image_url: https://avatars.githubusercontent.com/u/6025893 +tags: [] +--- + +We recently shipped a feature which allows you to pass an arbitrary filter to the GitHub tag and release badges. The `filter` param can be used to apply a filter to the project's tag or release names before selecting the latest from the list. Two constructs are available: `*` is a wildcard matching zero or more characters, and if the pattern starts with a `!`, the whole pattern is negated. + +To give an example of how this might be useful, we create two types of tags on our GitHub repo: https://github.com/badges/shields/tags There are tags in the format `major.minor.patch` which correspond to our [NPM package releases](https://www.npmjs.com/package/badge-maker?activeTab=versions) and tags in the format `server-YYYY-MM-DD` that correspond to our [docker snapshot releases](https://registry.hub.docker.com/r/shieldsio/shields/tags?page=1&ordering=last_updated). + +In our case, this would allow us to make a badge that applies the filter `!server-*` to filter out the snapshot tags and just select the latest package tag. + +- ![tag badge without filter](https://img.shields.io/github/v/tag/badges/shields) - https://img.shields.io/github/v/tag/badges/shields +- ![tag badge with filter](https://img.shields.io/github/v/tag/badges/shields?filter=%21server-%2A) - https://img.shields.io/github/v/tag/badges/shields?filter=%21server-%2A diff --git a/frontend/blog/2023-11-29-simpleicons10.md b/frontend/blog/2023-11-29-simpleicons10.md new file mode 100644 index 0000000000000..716b14c0f0d7e --- /dev/null +++ b/frontend/blog/2023-11-29-simpleicons10.md @@ -0,0 +1,14 @@ +--- +slug: simple-icons-10 +title: Simple Icons 10 +authors: + name: chris48s + title: Shields.io Core Team + url: https://github.com/chris48s + image_url: https://avatars.githubusercontent.com/u/6025893 +tags: [] +--- + +Logos on Shields.io are provided by SimpleIcons. We've recently upgraded to SimpleIcons 10. This release removes 45 icons. A full list of the removals can be found in the [release notes](https://github.com/simple-icons/simple-icons/releases/tag/10.0.0). + +Please remember that we are just consumers of SimpleIcons. Decisions about changes and removals are made by the [SimpleIcons](https://github.com/simple-icons/simple-icons) project. diff --git a/frontend/blog/2024-01-13-simpleicons11.md b/frontend/blog/2024-01-13-simpleicons11.md new file mode 100644 index 0000000000000..ccb13dd42edc1 --- /dev/null +++ b/frontend/blog/2024-01-13-simpleicons11.md @@ -0,0 +1,21 @@ +--- +slug: simple-icons-11 +title: Simple Icons 11 +authors: + name: chris48s + title: Shields.io Core Team + url: https://github.com/chris48s + image_url: https://avatars.githubusercontent.com/u/6025893 +tags: [] +--- + +Logos on Shields.io are provided by SimpleIcons. We've recently upgraded to SimpleIcons 11. This release removes the following 4 icons: + +- Babylon.js +- Hulu +- Pepsi +- Uno + +More details can be found in the [release notes](https://github.com/simple-icons/simple-icons/releases/tag/11.0.0). + +Please remember that we are just consumers of SimpleIcons. Decisions about changes and removals are made by the [SimpleIcons](https://github.com/simple-icons/simple-icons) project. diff --git a/frontend/blog/2024-06-01-simpleicons12.md b/frontend/blog/2024-06-01-simpleicons12.md new file mode 100644 index 0000000000000..9b5e382856e81 --- /dev/null +++ b/frontend/blog/2024-06-01-simpleicons12.md @@ -0,0 +1,33 @@ +--- +slug: simple-icons-12 +title: Simple Icons 12 +authors: + name: chris48s + title: Shields.io Core Team + url: https://github.com/chris48s + image_url: https://avatars.githubusercontent.com/u/6025893 +tags: [] +--- + +Logos on Shields.io are provided by SimpleIcons. We've recently upgraded to SimpleIcons 12. This release removes the following 10 icons: + +- FITE +- Flattr +- Google Bard +- Integromat +- Niantic +- Nintendo Network +- Rome +- Shotcut +- Skynet +- Twitter + +And renames the following 3: + +- Airbrake.io to Airbrake +- Amazon AWS to Amazon Web Services +- RStudio to RStudio IDE + +More details can be found in the [release notes](https://github.com/simple-icons/simple-icons/releases/tag/12.0.0). + +Please remember that we are just consumers of SimpleIcons. Decisions about changes and removals are made by the [SimpleIcons](https://github.com/simple-icons/simple-icons) project. diff --git a/frontend/blog/2024-07-05-simpleicons13.md b/frontend/blog/2024-07-05-simpleicons13.md new file mode 100644 index 0000000000000..88de5e518d789 --- /dev/null +++ b/frontend/blog/2024-07-05-simpleicons13.md @@ -0,0 +1,14 @@ +--- +slug: simple-icons-13 +title: Simple Icons 13 +authors: + name: chris48s + title: Shields.io Core Team + url: https://github.com/chris48s + image_url: https://avatars.githubusercontent.com/u/6025893 +tags: [] +--- + +Logos on Shields.io are provided by SimpleIcons. We've recently upgraded to SimpleIcons 13. This release removes 65 icons and renames one. A full list of the changes can be found in the [release notes](https://github.com/simple-icons/simple-icons/releases/tag/13.0.0). + +Please remember that we are just consumers of SimpleIcons. Decisions about changes and removals are made by the [SimpleIcons](https://github.com/simple-icons/simple-icons) project. diff --git a/frontend/blog/2024-07-10-sunsetting-shields-custom-logos.md b/frontend/blog/2024-07-10-sunsetting-shields-custom-logos.md new file mode 100644 index 0000000000000..78bebe2d8d315 --- /dev/null +++ b/frontend/blog/2024-07-10-sunsetting-shields-custom-logos.md @@ -0,0 +1,39 @@ +--- +slug: sunsetting-shields-custom-logos +title: Sunsetting Shields custom logos +authors: + name: PyvesB + title: Shields.io Core Team + url: https://github.com/PyvesB + image_url: https://avatars.githubusercontent.com/u/10694593 +tags: [] +--- + +Following discussions in [#9476](https://github.com/badges/shields/issues/9476), we've gone ahead and deleted all custom logos that were maintained on the Shields.io side (bitcoin, dependabot, gitlab, npm, paypal, serverfault, stackexchange, superuser, telegram, travis), and will solely rely on the [Simple-Icons project](https://github.com/simple-icons/simple-icons) to provide named logos for our badges from now on. If you were using a Shields custom logo, you will have transparently switched over to the corresponding Simple-Icon and do not need to make changes to your badges. + +The reasons behind this decision include the following: + +- reducing code complexity and induced overhead by deleting several dozens lines of code. +- reducing maintenance load; we received regular pull requests to add logos that do not comply with our guidelines, or various other related questions. +- providing a less confusing user experience; all named logos now behave in the same way with regards to `logoColor` and other parameters. +- reducing frustration for contributors who prepared logo pull requests only to be told that they hadn't read the guidelines or that there was a misalignment on the interpretation of said guidelines. +- reinforcing Shields.io's mission to provide consistent badges, with all named logos now being monochrome. +- improving compliance with third-party brands; Simple-Icons regularly reviews whether their icons respect latest brand guidelines, whereas we do not. +- unblocking [#4947](https://github.com/badges/shields/issues/4947). + +We do acknowledge the fact that some of you voiced your preference for a given Shields custom logo over its Simple-Icons equivalent in [#7684](https://github.com/badges/shields/issues/7684). If you really want to go back to the Shields custom logo, you can leverage [custom logos](https://shields.io/docs/logos#custom-logos) to do so. Here are the corresponding Base64-encoded logo parameters for all our existing logos: + +| Name | Logo Preview | `logo` Parameter | +| --- | :-- | :-- | +| bitcoin | ![bitcoin](https://github.com/badges/shields/assets/10694593/20ea99c4-a557-476c-91a8-3b886ce98e5e) | `data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIzLjYzNiAxNC45MDJjLTEuNjAyIDYuNDMtOC4xMTQgMTAuMzQyLTE0LjU0MyA4Ljc0QzIuNjY2IDIyLjAzNy0xLjI0NiAxNS41MjUuMzU3IDkuMDk4IDEuOTYgMi42NjkgOC40Ny0xLjI0NCAxNC44OTcuMzU5YzYuNDMgMS42MDIgMTAuMzQxIDguMTE1IDguNzM5IDE0LjU0NCIgZmlsbD0iI2Y3OTMxYSIvPjxwYXRoIGQ9Ik0xNC42ODYgMTAuMjY3Yy0uMzcxIDEuNDg3LTIuNjYzLjczMS0zLjQwNi41NDZsLjY1NS0yLjYyOWMuNzQzLjE4NiAzLjEzOC41MzEgMi43NSAyLjA4M20tLjQwNiA0LjI0MmMtLjQwNyAxLjYzNS0zLjE2Ljc1LTQuMDUzLjUzbC43MjQtMi45Yy44OTMuMjI0IDMuNzU0LjY2NCAzLjMzIDIuMzdtMy4wMDgtNC4yMTljLjIzOC0xLjU5Ni0uOTc3LTIuNDU1LTIuNjQtMy4wMjdsLjU0LTIuMTYzLTEuMzE4LS4zMy0uNTI1IDIuMTA3YTU0LjI5MiA1NC4yOTIgMCAwIDAtMS4wNTQtLjI0OWwuNTMtMi4xMi0xLjMxNy0uMzI4LS41NCAyLjE2MmMtLjI4Ni0uMDY1LS41NjctLjEzLS44NC0uMTk4bC4wMDEtLjAwNy0xLjgxNi0uNDUzLS4zNSAxLjQwNnMuOTc3LjIyNC45NTYuMjM4Yy41MzMuMTMzLjYzLjQ4Ni42MTMuNzY2bC0uNjE1IDIuNDYzYy4wMzguMDEuMDg1LjAyNC4xMzcuMDQ1bC0uMTM4LS4wMzUtLjg2MiAzLjQ1MmMtLjA2NS4xNjEtLjIzLjQwNS0uNjA0LjMxMi4wMTQuMDItLjk1Ny0uMjM5LS45NTctLjIzOUw1LjgzNiAxNS42bDEuNzE0LjQyN2MuMzE4LjA4LjYzLjE2NC45MzguMjQybC0uNTQ1IDIuMTkgMS4zMTUuMzI4LjU0LTIuMTY0Yy4zNi4wOTcuNzA4LjE4NyAxLjA1LjI3MWwtLjUzOCAyLjE1NiAxLjMxNi4zMjguNTQ2LTIuMTgzYzIuMjQ1LjQyNCAzLjkzMy4yNTMgNC42NDMtMS43NzcuNTc0LTEuNjM1LS4wMjctMi41NzgtMS4yMDgtMy4xOTQuODYtLjE5OCAxLjUwOC0uNzY1IDEuNjgxLTEuOTM0IiBmaWxsPSIjZmZmIi8+PC9zdmc+` | +| dependabot | ![dependabot](https://github.com/badges/shields/assets/10694593/5fb27ba4-f940-4782-bba0-8c01f98cce0e) | `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1NCA1NCIgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTI1IDNhMSAxIDAgMCAwLTEgMXY3YTEgMSAwIDAgMCAxIDFoNXYzSDZhMyAzIDAgMCAwLTMgM3YxMkgxYTEgMSAwIDAgMC0xIDF2MTBhMSAxIDAgMCAwIDEgMWgydjZhMyAzIDAgMCAwIDMgM2g0MmEzIDMgMCAwIDAgMy0zdi02aDJhMSAxIDAgMCAwIDEtMVYzMWExIDEgMCAwIDAtMS0xaC0yVjE4YTMgMyAwIDAgMC0zLTNIMzNWNGExIDEgMCAwIDAtMS0xaC03em0tMy45ODIgMjZhMS4yMSAxLjIxIDAgMCAxIC44MzcuMzU1bDEuMjkgMS4yOWExLjIxIDEuMjEgMCAwIDEgMCAxLjcwOSAxLjIxIDEuMjEgMCAwIDEgMCAuMDAxbC02LjI5MSA2LjI5YTEuMjEgMS4yMSAwIDAgMS0xLjcxIDBsLTMuNzktMy43OTFhMS4yMSAxLjIxIDAgMCAxIDAtMS43MWwxLjI5LTEuMjlhMS4yMSAxLjIxIDAgMCAxIDEuNzEgMEwxNiAzMy41bDQuMTQ1LTQuMTQ1YTEuMjEgMS4yMSAwIDAgMSAuODczLS4zNTV6bTE5Ljk2MiAwYTEuMjEgMS4yMSAwIDAgMSAuODc0LjM1NGwxLjI5IDEuMjlhMS4yMSAxLjIxIDAgMCAxIDAgMS43MWwtNi4yOSA2LjI4OXYuMDAyYTEuMjEgMS4yMSAwIDAgMS0xLjcxMSAwbC0zLjc5LTMuNzlhMS4yMSAxLjIxIDAgMCAxIDAtMS43MWwxLjI5LTEuMjlhMS4yMSAxLjIxIDAgMCAxIDEuNzEgMGwxLjY0NSAxLjY0NSA0LjE0Ny00LjE0NkExLjIxIDEuMjEgMCAwIDEgNDAuOTggMjl6Ii8+PC9zdmc+` | +| gitlab | ![gitlab](https://github.com/badges/shields/assets/10694593/e9c8e584-3860-4fe2-b802-2ed7c87f996f) | `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjkzIDkzIDE5NCAxOTQiPjxkZWZzPjxzdHlsZT4uYntmaWxsOiNmYzZkMjZ9PC9zdHlsZT48L2RlZnM+PHBhdGggc3R5bGU9ImZpbGw6I2UyNDMyOSIgZD0ibTI4Mi44MyAxNzAuNzMtLjI3LS42OS0yNi4xNC02OC4yMmE2LjgxIDYuODEgMCAwIDAtMi42OS0zLjI0IDcgNyAwIDAgMC04IC40MyA3IDcgMCAwIDAtMi4zMiAzLjUybC0xNy42NSA1NGgtNzEuNDdsLTE3LjY1LTU0YTYuODYgNi44NiAwIDAgMC0yLjMyLTMuNTMgNyA3IDAgMCAwLTgtLjQzIDYuODcgNi44NyAwIDAgMC0yLjY5IDMuMjRMOTcuNDQgMTcwbC0uMjYuNjlhNDguNTQgNDguNTQgMCAwIDAgMTYuMSA1Ni4xbC4wOS4wNy4yNC4xNyAzOS44MiAyOS44MiAxOS43IDE0LjkxIDEyIDkuMDZhOC4wNyA4LjA3IDAgMCAwIDkuNzYgMGwxMi05LjA2IDE5LjctMTQuOTEgNDAuMDYtMzAgLjEtLjA4YTQ4LjU2IDQ4LjU2IDAgMCAwIDE2LjA4LTU2LjA0WiIvPjxwYXRoIGNsYXNzPSJiIiBkPSJtMjgyLjgzIDE3MC43My0uMjctLjY5YTg4LjMgODguMyAwIDAgMC0zNS4xNSAxNS44TDE5MCAyMjkuMjVjMTkuNTUgMTQuNzkgMzYuNTcgMjcuNjQgMzYuNTcgMjcuNjRsNDAuMDYtMzAgLjEtLjA4YTQ4LjU2IDQ4LjU2IDAgMCAwIDE2LjEtNTYuMDhaIi8+PHBhdGggc3R5bGU9ImZpbGw6I2ZjYTMyNiIgZD0ibTE1My40MyAyNTYuODkgMTkuNyAxNC45MSAxMiA5LjA2YTguMDcgOC4wNyAwIDAgMCA5Ljc2IDBsMTItOS4wNiAxOS43LTE0LjkxUzIwOS41NSAyNDQgMTkwIDIyOS4yNWMtMTkuNTUgMTQuNzUtMzYuNTcgMjcuNjQtMzYuNTcgMjcuNjRaIi8+PHBhdGggY2xhc3M9ImIiIGQ9Ik0xMzIuNTggMTg1Ljg0QTg4LjE5IDg4LjE5IDAgMCAwIDk3LjQ0IDE3MGwtLjI2LjY5YTQ4LjU0IDQ4LjU0IDAgMCAwIDE2LjEgNTYuMWwuMDkuMDcuMjQuMTcgMzkuODIgMjkuODJMMTkwIDIyOS4yMVoiLz48L3N2Zz4=` | +| npm | ![npm](https://github.com/badges/shields/assets/10694593/ba629fa3-a467-4c96-b191-62c339faac66) | `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0MCA0MCI+PHBhdGggZD0iTTAgMGg0MHY0MEgwVjB6IiBmaWxsPSIjY2IwMDAwIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTcgN2gyNnYyNmgtN1YxNGgtNnYxOUg3eiIvPjwvc3ZnPgo=` | +| paypal | ![paypal](https://github.com/badges/shields/assets/10694593/f2eacc65-7a19-4816-8897-f7723a97b26f) | `data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE5LjcxNSA2LjEzM2MuMjQ5LTEuODY2IDAtMy4xMS0uOTk5LTQuMjY2QzE3LjYzNC42MjIgMTUuNzIxIDAgMTMuMzA3IDBINi4yMzVjLS40MTggMC0uOTE2LjQ0NC0xIC44ODlMMi4zMjMgMjAuNjIyYzAgLjM1Ni4yNS44LjY2NS44aDQuMzI4bC0uMjUgMS45NTZjLS4wODQuMzU1LjE2Ni42MjIuNDk4LjYyMmgzLjY2M2MuNDE3IDAgLjgzMi0uMjY3LjkxNS0uNzExdi0uMjY3bC43NDktNC42MjJ2LS4xNzhjLjA4My0uNDQ0LjUtLjguOTE1LS44aC41YzMuNTc4IDAgNi4zMjUtMS41MSA3LjE1Ni01Ljk1NS40MTgtMS44NjcuMjUyLTMuMzc4LS43NDctNC40NDUtLjI1LS4zNTUtLjY2Ni0uNjIyLTEtLjg4OSIgZmlsbD0iIzAwOWNkZSIvPjxwYXRoIGQ9Ik0xOS43MTUgNi4xMzNjLjI0OS0xLjg2NiAwLTMuMTEtLjk5OS00LjI2NkMxNy42MzQuNjIyIDE1LjcyMSAwIDEzLjMwNyAwSDYuMjM1Yy0uNDE4IDAtLjkxNi40NDQtMSAuODg5TDIuMzIzIDIwLjYyMmMwIC4zNTYuMjUuOC42NjUuOGg0LjMyOGwxLjE2NC03LjM3OC0uMDgzLjI2N2MuMDg0LS41MzMuNS0uODg5Ljk5OC0uODg5aDIuMDhjNC4wNzkgMCA3LjI0MS0xLjc3OCA4LjI0LTYuNzU1LS4wODMtLjI2NyAwLS4zNTYgMC0uNTM0IiBmaWxsPSIjMDEyMTY5Ii8+PHBhdGggZD0iTTkuNTYzIDYuMTMzYy4wODItLjI2Ni4yNS0uNTMzLjQ5OC0uNzEuMTY2IDAgLjI1LS4wOS40MTYtLjA5aDUuNDk0Yy42NjYgMCAxLjMzLjA5IDEuODMuMTc4LjE2NiAwIC4zMzMgMCAuNDk4LjA4OS4xNjguMDg5LjMzNC4wODkuNDE4LjE3OGguMjVjLjI0OC4wODkuNDk3LjI2Ni43NDguMzU1LjI0OC0xLjg2NiAwLTMuMTEtLjk5OS00LjM1NUMxNy43MTcuNTMzIDE1LjgwNCAwIDEzLjM5IDBINi4yMzVjLS40MTggMC0uOTE2LjM1Ni0xIC44ODlMMi4zMjMgMjAuNjIyYzAgLjM1Ni4yNS44LjY2NS44aDQuMzI4bDEuMTY0LTcuMzc4IDEuMDg0LTcuOTF6IiBmaWxsPSIjMDAzMDg3Ii8+PC9zdmc+` | +| serverfault | ![serverfault](https://github.com/badges/shields/assets/10694593/d1b7a0e5-2465-4009-ba5f-89f364554a46) | `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjAgMTIwIj48c3R5bGU+LnN0MHtmaWxsOiNhN2E5YWN9LnN0MXtmaWxsOiM4MTgyODV9LnN0MntmaWxsOiM1ODU4NWF9LnN0M3tmaWxsOiNkMWQyZDR9LnN0NHtmaWxsOiMyMzFmMjB9PC9zdHlsZT48cGF0aCBjbGFzcz0ic3QwIiBkPSJNMTMuNyA0MS42aDQ0djguN2gtNDR6Ii8+PHBhdGggY2xhc3M9InN0MSIgZD0iTTEzLjcgNTUuOGg0NHY4LjdoLTQ0eiIvPjxwYXRoIGNsYXNzPSJzdDIiIGQ9Ik0xMy43IDY5aDQ0djguN2gtNDR6Ii8+PHBhdGggY2xhc3M9InN0MyIgZD0iTTEzLjcgMjcuNmg0NHY4LjdoLTQ0eiIvPjxwYXRoIGNsYXNzPSJzdDQiIGQ9Ik0xMy43IDgzLjJoNDR2OC43aC00NHoiLz48cGF0aCBmaWxsPSIjOTkyMjI0IiBkPSJNNjMgNDEuNmgxOC43djguN0g2M3oiLz48cGF0aCBmaWxsPSIjNjMwZjE2IiBkPSJNNjMgNTUuOGgxOC43djguN0g2M3oiLz48cGF0aCBmaWxsPSIjMmIxNDE1IiBkPSJNNjMgNjloMTguN3Y4LjdINjN6Ii8+PHBhdGggZmlsbD0iI2U3MjgyZCIgZD0iTTYzIDI3LjZoMTguN3Y4LjdINjN6Ii8+PHBhdGggY2xhc3M9InN0NCIgZD0iTTYzIDgzLjJoMTguN3Y4LjdINjN6Ii8+PGc+PHBhdGggY2xhc3M9InN0MCIgZD0iTTg2LjggNDJoMTguN3Y4LjdIODYuOHoiLz48cGF0aCBjbGFzcz0ic3QxIiBkPSJNODYuOCA1Ni4yaDE4Ljd2OC43SDg2Ljh6Ii8+PHBhdGggY2xhc3M9InN0MiIgZD0iTTg2LjggNjkuNGgxOC43djguN0g4Ni44eiIvPjxwYXRoIGNsYXNzPSJzdDMiIGQ9Ik04Ni44IDI4aDE4Ljd2OC43SDg2Ljh6Ii8+PHBhdGggY2xhc3M9InN0NCIgZD0iTTg2LjggODMuNmgxOC43djguN0g4Ni44eiIvPjwvZz48L3N2Zz4=` | +| stackexchange | ![stackexchange](https://github.com/badges/shields/assets/10694593/409644d3-4679-4f0d-9fb9-538215eec8c7) | `data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuMjczIDEwLjQ2M2gxOS4zMjV2My45NzhIMi4yNzN6IiBmaWxsPSIjMzc2ZGI2Ii8+PHBhdGggZD0iTTIuMjczIDUuMzIyaDE5LjMyNVY5LjNIMi4yNzN6IiBmaWxsPSIjNGNhMmRhIi8+PHBhdGggZD0iTTE4LjU3NSAwSDUuMzc0Yy0xLjcwNSAwLTMuMSAxLjQyLTMuMSAzLjE3OFY0LjIxaDE5LjMyNFYzLjE3OEMyMS41OTggMS40MiAyMC4yNTQgMCAxOC41NzUgMHoiIGZpbGw9IiM5MWQ4ZjQiLz48cGF0aCBkPSJNMi4yNzMgMTUuNTc4djEuMDMzYzAgMS43NTcgMS4zOTYgMy4xNzggMy4xIDMuMTc4aDguMjY4VjI0bDQuMDgxLTQuMjExaC45MDVjMS43MDUgMCAzLjEtMS40MiAzLjEtMy4xNzh2LTEuMDMzeiIgZmlsbD0iIzFlNTM5NyIvPjwvc3ZnPg==` | +| superuser | ![superuser](https://github.com/badges/shields/assets/10694593/f8d0b5ad-5b67-49f8-8989-59256baad56e) | `data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNTk0IDBhLjUxNC41MTQgMCAwIDAtLjM0NC4xMS40MDQuNDA0IDAgMCAwLS4xMzMuMzA2djIzLjE5N2MwIC4xMjQuMDQ4LjI0Ni4xNDUuMzEyLjA5Ni4wNjUuMjA4LjA3NS4zMzIuMDc1aDUuNTkzYy4xMyAwIC4yNDMtLjAyLjMzNC0uMDkzLjA5Mi0uMDcyLjEzMS0uMi4xMTItLjMxN2wuMDAyLjAyM3YtMS40NjdhLjM2Ny4zNjcgMCAwIDAtLjE2LS4zMDEuNjEyLjYxMiAwIDAgMC0uMzQ0LS4wODdINS42MTNjLS4xMSAwLS4xNy0uMDItLjE5MS0uMDM3LS4wMjItLjAxNi0uMDMyLS4wMy0uMDMyLS4xVjIuNDA4YzAtLjA3MS4wMTItLjA5NC4wNDEtLjExNi4wMy0uMDIzLjEwMi0uMDUuMjM5LS4wNWgyLjQ4OGMuMTI0IDAgLjIzNS0uMDEuMzMyLS4wNzYuMDk3LS4wNjYuMTQ1LS4xODguMTQ1LS4zMTFWLjQxNmEuMzk2LjM5NiAwIDAgMC0uMTU3LS4zMjNBLjU4My41ODMgMCAwIDAgOC4xMzEgMHoiIGZpbGw9IiMwMDAiLz48cGF0aCBkPSJNMjAuOTU4IDE0LjQ3Yy0xLjQ4Mi40MTQtMi40ODkgMS4yNzMtMi40ODkgMi42ODR2NC4wNDJjMCAzLjAxNy0yLjkwOSAyLjY4NS02LjUxNyAyLjY4NWgtLjU2Yy0uMjIzIDAtLjM2My0uMDgzLS4zNjMtLjI3N1YyMi4yMmMwLS4xOTQuMTEyLS4yNzcuMzM2LS4yNzdoLjQ0N2MyLjE1NCAwIDMuNjY0LjQ3IDMuNjY0LTEuMjQ1di0zLjg3NmMwLTEuMTkuODQtMi44NTEgMi41MTctMy40Ni4xMTItLjAyOC4xNC0uMDgzLjE0LS4xMzggMC0uMDU2LS4wMjgtLjEzOS0uMTQtLjE5NC0xLjUzOC0uNjkyLTIuNTE3LTEuODI3LTIuNTE3LTMuMTg0VjUuNDczYzAtMS42ODktMS41MS0zLjM3Ny0zLjY2NC0zLjM3N2gtLjQ0N2MtLjIyNCAwLS4zMzYtLjA4My0uMzM2LS4yNzdWLjQzNWMwLS4xOTQuMTQtLjI3Ny4zNjQtLjI3N2guNTZjMy42MDcgMCA2LjU0NCAyLjU0NyA2LjU0NCA1LjU2NHYzLjY4MmMwIDEuMzg0IDEuMDA3IDIuMTg2IDIuNTE3IDIuNzEyLjU2LjE2Ni44NjcuMTk0Ljg2Ny42Mzd2MS4xNjNjLjAyOC4yNDktLjI1MS4zNi0uOTIzLjU1MyIgZmlsbD0iIzJlYWNlMyIvPjxwYXRoIGQ9Ik0xMS41NzYgOC4zM2MtLjQwNiAwLS43ODUuMzAzLS43ODUuNzJ2MS4zMjhjMCAuMzg5LjM1LjcyMS43ODUuNzIxaDEuNDgyYy40MDYgMCAuNzg0LS4zMDQuNzg0LS43MlY5LjA1YzAtLjM4OC0uMzQ4LS43Mi0uNzg0LS43MnoiIGZpbGw9IiMwMDAiLz48L3N2Zz4=` | +| telegram | ![telegram](https://github.com/badges/shields/assets/10694593/c5c5acc3-f434-4a8d-a834-6d94a7ffb45a) | `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDI0YzYuNjI3IDAgMTItNS4zNzMgMTItMTJTMTguNjI3IDAgMTIgMCAwIDUuMzczIDAgMTJzNS4zNzMgMTIgMTIgMTJaIiBmaWxsPSJ1cmwoI2EpIi8+PHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik01LjQyNSAxMS44NzFhNzk2LjQxNCA3OTYuNDE0IDAgMCAxIDYuOTk0LTMuMDE4YzMuMzI4LTEuMzg4IDQuMDI3LTEuNjI4IDQuNDc3LTEuNjM4LjEgMCAuMzIuMDIuNDcuMTQuMTIuMS4xNS4yMy4xNy4zMy4wMi4xLjA0LjMxLjAyLjQ3LS4xOCAxLjg5OC0uOTYgNi41MDQtMS4zNiA4LjYyMi0uMTcuOS0uNSAxLjE5OS0uODE5IDEuMjI5LS43LjA2LTEuMjI5LS40Ni0xLjg5OC0uOS0xLjA2LS42ODktMS42NDktMS4xMTktMi42NzgtMS43OTgtMS4xOS0uNzgtLjQyLTEuMjA5LjI2LTEuOTA4LjE4LS4xOCAzLjI0Ny0yLjk3OCAzLjMwNy0zLjIyOC4wMS0uMDMuMDEtLjE1LS4wNi0uMjEtLjA3LS4wNi0uMTctLjA0LS4yNS0uMDItLjExLjAyLTEuNzg4IDEuMTQtNS4wNTYgMy4zNDgtLjQ4LjMzLS45MDkuNDktMS4yOTkuNDgtLjQzLS4wMS0xLjI0OC0uMjQtMS44NjgtLjQ0LS43NS0uMjQtMS4zNDktLjM3LTEuMjk5LS43OS4wMy0uMjIuMzMtLjQ0Ljg5LS42NjlaIiBmaWxsPSIjZmZmIi8+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJhIiB4MT0iMTEuOTkiIHkxPSIwIiB4Mj0iMTEuOTkiIHkyPSIyMy44MSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiMyQUFCRUUiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiMyMjlFRDkiLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48L3N2Zz4K` | +| travis | ![travis](https://github.com/badges/shields/assets/10694593/67110d9b-b825-4ef7-85ff-1bba963121e1) | `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNjYuNyIgaGVpZ2h0PSIyNjQuNSI+PHBhdGggZmlsbD0iI2NkMjQ0NSIgZD0iTTY0IDExNXMtNDIgMzAtNDMgNDFsMy0xczQ5LTMzIDg5LTM3bDEtNS01MCAybTY1LTQtMzMgMjMgMiAyIDU4LTE5IDEyLTctMzkgMW0yOCAyOGMyMyAwIDU4LTIyIDU4LTIybC0xMS0zaC0xOGwtOC0zLTIwIDIzLTIgNCAxIDFtLTk4IDg2LTMtMnptMTc0LTEzLTcgMi0zMy0xLTIxLTE2LTI1IDYtMjktMi0xNiAxNy0zMSAxMC0xNS01LTEtMSA3IDE3czE2IDE2IDI0IDE4YzkgMiAyNCAwIDM2LTIgMTItMSAyMS02IDI1LTEybDQtOXMxMSAxNiAyMSAxN2MxMCAyIDM4LTggMzgtOHMxOC00IDIxLTEwbDExLTI2LTkgNSIvPjxwYXRoIGZpbGw9IiNmMmYxOWIiIGQ9Ik0yNjEgOTNhNjYgNjYgMCAwIDAgMC00bC04LTZhMTA2IDEwNiAwIDAgMC0yMC05bC01LTItNS0yIDExIDNhMTQ0IDE0NCAwIDAgMSA2IDJjLTE2LTQzLTU0LTcwLTk2LTcwLTQzIDAtODEgMjctOTcgNzBhMTQ1IDE0NSAwIDAgMSAxNy01bC01IDJhMjAwIDIwMCAwIDAgMC0zMiAxN2wtMSAyYTcwIDcwIDAgMCAwIDAgMiA2OSA2OSAwIDAgMCAwIDYgNzkgNzkgMCAwIDAgMyAyMSA1NyA1NyAwIDAgMCAxIDUgNDMgNDMgMCAwIDAgMiA0bDEgMSAxIDEgNCAxLTMtMTIgMTYtM2E1MiA1MiAwIDAgMS0zLTFsLTYtMmEzMCAzMCAwIDAgMS0zLTFsLTMtMmMxMSAzIDMzIDIgNTMgMGE1MzggNTM4IDAgMCAxIDEwOCAwYzIwIDIgNDIgMyA1MyAwbC0zIDJhMzAgMzAgMCAwIDEtMyAxbC03IDItMSAxIDE4IDMtMyAxMWgybDEtMSAxLTFhMjIgMjIgMCAwIDAgMi00IDU2IDU2IDAgMCAwIDItNSA3OCA3OCAwIDAgMCAyLTIxIDY4IDY4IDAgMCAwIDAtNiIvPjxwYXRoIGZpbGw9IiNlNWM5YTMiIGQ9Ik0xNTYgMjQ0YTU4IDU4IDAgMCAxLTUgMGgtM2E3NzYgNzc2IDAgMCAwIDMtNiAxOTggMTk4IDAgMCAwIDUgNm0zIDNjNCA0IDEwIDcgMTYgNy0xMCA0LTIwIDYtMjcgNi04IDEtMTUgMC0yMi0yYTI3IDI3IDAgMCAxIDEgMGMxLTEgMTQtMiAyMC0xMWg1YTU4IDU4IDAgMCAwIDYtMWwxIDEiLz48cGF0aCBmaWxsPSIjNWQ2NzYyIiBkPSJNMTcxIDExNmExMjggMTI4IDAgMCAxLTEyIDEzIDQ5MyA0OTMgMCAwIDAtMTUgMGwtMjQgMWExOTcgMTk3IDAgMCAxIDUxLTE0bS02NSA1LTEyIDExYTQ4MCA0ODAgMCAwIDAtMjkgM2MxMi01IDI2LTEwIDQxLTE0bTEzNiAyMy01IDMyLTIxIDE1LTU3LTctOC0yOGEyIDIgMCAwIDAtMS0xIDM1IDM1IDAgMCAwLTExIDAgMiAyIDAgMCAwLTIgMWwtOCAyOC01NiAxMi0yMi0xNi01LTM1YTI2NyAyNjcgMCAwIDEgMy0yaDVsNCAzMyAxIDEgMTUgMTFhMiAyIDAgMCAwIDEgMGw0Ni0xMGgxYTIgMiAwIDAgMCAxLTFsOC0yOCAxMy0yIDEzIDIgOCAyOCAyIDEgNDYgNWgxbDE1LTExIDEtMSA0LTI5IDggMm02LTIwLTQgMTVjLTgtMi0yNi01LTUxLTdsMjQtMTMgMzEgNSIvPjxwYXRoIGZpbGw9IiNlNGM4OTYiIGQ9Im0xNTQgMTM0LTcgNS04IDVhNjkgNjkgMCAwIDAtMTAgMiAyIDIgMCAwIDAtMSAxbC04IDI4LTQ1IDktMTQtOS00LTMzIDMyLTVhNzYgNzYgMCAwIDAtNCA1bC04IDExIDExLTdzNy01IDE5LTEwYTUwMyA1MDMgMCAwIDEgNDctMnptLTY5IDM3IDEyLTFhNDAgNDAgMCAwIDAgNCAwYzYgMCAxMCAwIDEwLTlzLTQtMTYtOS0xNmMtNiAwLTEyIDctMTEgMTZsMSA2Yy01IDEtNyA0LTcgNG0xNDYtMjktNCAyOC0xNCA5LTQ1LTUtOC0yN2EyIDIgMCAwIDAtMS0xbC0xMi0yaDFjMSAwIDE3LTIgMzUtOCAyMCAxIDM2IDQgNDggNnptLTI5IDI2YzAtMS0xLTQtNi00bDEtNWMtMS05LTYtMTYtMTItMTZzLTkgNy05IDE2IDUgOSAxMSA5YzcgMCAxMi0yIDE1IDAiLz48cGF0aCBmaWxsPSIjYzRhZjkwIiBkPSJtMTU0IDEzNC03IDUtOCA1YTY5IDY5IDAgMCAwLTEwIDIgMiAyIDAgMCAwLTEgMWwtOCAyOC00NSA5LTE0LTktNC0zMyAzMi01YTc2IDc2IDAgMCAwLTQgNWwtOCAxMSAxMS03czctNSAxOS0xMGE1MDMgNTAzIDAgMCAxIDQ3LTJ6bS02OSAzNyAxMi0xYTQwIDQwIDAgMCAwIDQgMGM2IDAgMTAgMCAxMC05cy00LTE2LTktMTZjLTYgMC0xMiA3LTExIDE2bDEgNmMtNSAxLTcgNC03IDRtMTQ2LTI5LTQgMjgtMTQgOS00NS01LTgtMjdhMiAyIDAgMCAwLTEtMWwtMTItMmgxYzEgMCAxNy0yIDM1LTggMjAgMSAzNiA0IDQ4IDZ6bS0yOSAyNmMwLTEtMS00LTYtNGwxLTVjLTEtOS02LTE2LTEyLTE2cy05IDctOSAxNiA1IDkgMTEgOWM3IDAgMTItMiAxNSAwIi8+PHBhdGggZmlsbD0iI2U1YzlhMyIgZD0ibTI1MCAxNTQgMSA5Yy0xIDgtNSAyMi03IDI1bC0xMC0xIDEtNSA1LTQgMS0xIDQtMjYgNSAzTTU1IDE4OHYzbC0xMSAxYy0yLTItNi0xNy03LTI1di05bDctMyAzIDI3IDEgMSA3IDVtMTA3IDB2MnMtNyA2LTE2IDdjLTEwIDEtMTgtNS0xOC01bDMgNmE3MSA3MSAwIDAgMC04LTFoLTRjLTYgMC0xNCAxMC0yMCAxOWwtMjMgN2MtMTAtMTQtMTUtMjgtMTUtMjlsLTEtMiAzIDIgNiA0IDIgMmEyIDIgMCAwIDAgMiAwbDU4LTEzYTIgMiAwIDAgMCAxLTFsOC0yN2EyOCAyOCAwIDAgMSA4IDBsOCAyOCAyIDFoNCIvPjxwYXRoIGZpbGw9IiNlNWM5YTMiIGQ9Im0yMjggMTg3IDItMmExMjAgMTIwIDAgMCAxLTEwIDI3aC0zbC04LTEtMTYtMi0xNi0xMi0xLTFjLTEtMS0yLTItNS0ybC0xNCAzYzUtMyA1LTcgNS03di0ybDU0IDZhMiAyIDAgMCAwIDEgMGw2LTQgNS0zIi8+PHBhdGggZmlsbD0iI2U5ZDU4NiIgZD0iTTE4OCA3MmMwIDUgMCAxNC0yIDIyYTIgMiAwIDAgMCAwIDEgMzQ2IDM0NiAwIDAgMC05LTFjMy02IDQtMTQgNC0xNmw3LTZtLTgyIDZjMCAyIDEgMTAgNCAxNmEzMzMgMzMzIDAgMCAwLTkgMGMtMi04LTItMTctMi0yMmw3IDYiLz48cGF0aCBmaWxsPSIjMmEyYzMwIiBkPSJNMTg0IDE1NGEzIDMgMCAwIDAgMy0zIDMgMyAwIDAgMC0zLTIgMyAzIDAgMCAwLTMgMyAzIDMgMCAwIDAgMyAyem0xOCAxNGMtMy0yLTggMC0xNSAwLTYgMC0xMSAwLTExLTlzMy0xNiA5LTE2IDExIDcgMTIgMTZsLTEgNWM1IDAgNiAzIDYgNCIvPjxwYXRoIGZpbGw9IiNmMWZhZmMiIGQ9Ik0xODQgMTQ5YTMgMyAwIDEgMSAwIDUgMyAzIDAgMSAxIDAtNSIvPjxwYXRoIGZpbGw9IiMyYTJjMzAiIGQ9Ik0xMDIgMTU3YTMgMyAwIDEgMCAwLTYgMyAzIDAgMCAwIDAgNnptOSA0YzAgOS00IDktMTAgOWgtNGwtMTIgMXMyLTMgNy00bC0xLTZjLTEtOSA1LTE2IDExLTE2IDUgMCA5IDcgOSAxNiIvPjxwYXRoIGZpbGw9IiNmMWZhZmMiIGQ9Ik0xMDIgMTUxYTMgMyAwIDEgMSAwIDYgMyAzIDAgMCAxIDAtNiIvPjxwYXRoIGZpbGw9IiNlYmRiOGIiIGQ9Im02NiAxMDEtMS0xdi0zbDItMjAgMzAtNyAyIDI1Yy0xLTItNC02LTUtMTMtMS0zLTMtNC02LTRsLTcgMWMtNCAxLTEwIDMtMTEgNS0yIDUtMiAxNi0yIDE2bC0yIDFtMTU1IDAtMS0xcy0xLTExLTMtMTZjLTEtMi03LTQtMTEtNWwtNy0xYy0zIDAtNSAxLTYgNC0xIDctMyAxMS01IDEzbDItMjUgMzAgNyAyIDE3djZsLTEgMSIvPjxwYXRoIGZpbGw9IiNlYmRjOGMiIGQ9Im0xNzQgOTctNjAtMS0zLTctMy0xMi01LTRWNDVsNy0yMHMxIDYwIDE1IDYwaDM3YzE0IDAgMTUtNjAgMTUtNjBsMTAgMzQtNyAxOS0xIDUtMiA5LTMgNW02MiAxMC0zLTEgMyAxbS0zLTEtMTEtNFY4MXMzIDIxIDExIDI1Ii8+PHBhdGggZmlsbD0iI2VhZDY4NyIgZD0iTTIyMiAxMDB2LTYgNiIvPjxwYXRoIGZpbGw9IiNlYmRjOGMiIGQ9Im01MSAxMDcgNC0xLTQgMW00LTFjNy00IDEwLTI1IDEwLTI1djIxbC0xMCA0Ii8+PHBhdGggZmlsbD0iI2VhZDY4NyIgZD0iTTY1IDEwMHYtMyAzIi8+PHBhdGggZmlsbD0iIzJhMmMzMCIgZD0iTTk4IDk2di0xYy0zLTktMy0xOS0yLTI0bC0yOCA4Yy0yIDEyLTEgMjAgMCAyMmExODUgMTg1IDAgMCAxIDYtMiAyMjkgMjI5IDAgMCAxIDIzLTNoMXptMTItMmMtMy02LTQtMTQtNC0xNmwtNy02YTc3IDc3IDAgMCAwIDIgMjNsOS0xem03NiAwYzItOCAyLTE3IDItMjJsLTcgNmMwIDItMSAxMC00IDE2bDkgMWEyIDIgMCAwIDEgMC0xem0zNCA3YzAtMSAxLTEwLTEtMjJsLTI4LThjMSA1IDEgMTUtMiAyNHYxaDFhMjI4IDIyOCAwIDAgMSAzMCA1em00IDEgMTEgNS02LTJhOTQgOTQgMCAwIDAtNS0xIDE3NSAxNzUgMCAwIDAtMjMtM2wtMTItMWE3NjEgNzYxIDAgMCAwLTkxIDBsLTEyIDFhMjQyIDI0MiAwIDAgMC0zNCA2bDExLTVoMWMwLTEtMS0xMSAxLTI1YTIgMiAwIDAgMSAxLTFsNC0yYzEtMzAgMTMtNDQgMTQtNDRhODMgODMgMCAwIDAtMTEgNDRsMjQtN2gybDIgMmMtMS04IDAtMzEgMTEtNTAtMSAxLTkgMjYtNiA1NGE0NSA0NSAwIDAgMCAzIDJ2MnMxIDkgNSAxN2E0NDQgNDQ0IDAgMCAxIDU5IDBjNC04IDUtMTcgNS0xN2EyIDIgMCAwIDEgMC0yIDQ5IDQ5IDAgMCAwIDMtMmMzLTI4LTUtNTMtNS01NCAxMCAxOSAxMSA0MiAxMSA1MGE4MyA4MyAwIDAgMCAxLTJoMmwyNCA3YzAtNSAwLTI2LTExLTQ0IDEgMCAxMyAxNCAxNCA0NGw0IDJhMiAyIDAgMCAxIDEgMWMzIDE0IDEgMjQgMSAyNWgxIi8+PHBhdGggZmlsbD0iIzJhMmMzMCIgZD0iTTE2OCA0M1YzMGgtNDd2MTNoNnYtNmgxM3Y0MGgtNXY3aDE4di03aC01VjM3aDEzdjZ6bTQtMTh2MjNoLTE2di03aC0zdjMxaDV2MTdoLTI4VjcyaDVWNDFoLTN2N2gtMTZWMjVoNTYiLz48cGF0aCBmaWxsPSIjY2QyNDQ1IiBkPSJNMTY4IDMwdjEzaC03di02aC0xM3Y0MGg1djdoLTE4di03aDVWMzdoLTEzdjZoLTZWMzBoNDciLz48cGF0aCBmaWxsPSIjNWQ2NzYyIiBkPSJtNDEgMTI0IDktMmExMzkgMTM5IDAgMCAwLTggNmwtMS00Ii8+PHBhdGggZmlsbD0iI2M0YWY5MCIgZD0iTTEyNyAxODZzMCA1LTUgNmMtNiAyLTQ0IDEzLTQ4IDEzbC0xNS04LTMtOSAxNiAxMCA1NS0xMm0zMyAwczAgNCA2IDVsNDggMTFjNC0xIDE1LTkgMTUtOWwyLTktMTUgOC01Ni02Ii8+PHBhdGggZmlsbD0iI2M0YWY5MCIgZD0iTTM4IDE3OXM1IDEwIDEzIDZsNiAzdjRsLTEzIDItMy0yLTMtMTNtMjEyLTNzLTggMTEtMTcgN2wtMiAydjRsMTMgMiAzLTIgMy0xM20tMTMxIDgxczMxIDE2IDY2LTVsLTEyLTFzLTI0IDEwLTQzIDRsLTExIDIiLz48cGF0aCBmaWxsPSIjMmEyYzMwIiBkPSJNNTAgMTIyYTMwNSAzMDUgMCAwIDAtOSAybDEgNGExMzkgMTM5IDAgMCAxIDgtNnptNSA2OWE5MSA5MSAwIDAgMSAwLTNsLTctNWEyIDIgMCAwIDEtMS0xbC0zLTI3LTcgM3Y5YzEgOCA1IDIzIDcgMjVsMTEtMXptNDItNzMgMTUtM2ExMTczIDExNzMgMCAwIDAtNDUgMmMtNiA0LTI4IDE4LTQzIDM2IDE1LTEwIDQwLTI2IDczLTM1em0tMyAxNCAxMi0xMWMtMTUgNC0yOSA5LTQxIDE0YTQ4MSA0ODEgMCAwIDEgMjktM3ptNTgtMThoLTIzbC04IDRjLTEgMS0xMiA2LTIzIDE2IDktNSAyMy0xMSAzOS0xNmExOTIgMTkyIDAgMCAxIDE1LTR6bS01IDI1IDctNWE1NjMgNTYzIDAgMCAwLTEwIDAgNTAyIDUwMiAwIDAgMC0zNyAyYy0xMiA1LTE5IDEwLTE5IDEwbC0xMSA3IDgtMTFhNzAgNzAgMCAwIDEgNC01bC0zMiA1IDQgMzMgMTQgOSA0NS05IDgtMjhhMiAyIDAgMCAxIDEtMWwxMC0yem00IDEwNWg1YTE4NSAxODUgMCAwIDEtNS02IDUzMSA1MzEgMCAwIDEtMyA2aDN6bTI0IDEwYTI1IDI1IDAgMCAxLTE3LThsLTYgMWgtNWMtNiA5LTE5IDEwLTIwIDExaC0xYzcgMiAxNCAzIDIyIDIgNyAwIDE3LTIgMjctNnptLTUtMTM2IDEtMmExNTAgMTUwIDAgMCAwLTEzIDJjLTE0IDMtMjcgNy0zOCAxMmE1MTEgNTExIDAgMCAxIDI0LTFoMTVsMTEtMTF6bTQwLTFhNzM1IDczNSAwIDAgMC0xMy0xbC0xOS0xLTIgM2MtMyA0LTEwIDEzLTE4IDE5IDEyLTIgMzMtNyA1MC0xOWwyLTF6bTIwIDY4LTIgMi01IDMtNiA0YTIgMiAwIDAgMS0xIDBsLTU0LTZoLTRhMiAyIDAgMCAxLTItMWwtOC0yOGEyOCAyOCAwIDAgMC04IDBsLTggMjdhMiAyIDAgMCAxLTEgMWwtNTggMTNoLTJsLTItMi02LTQtMy0yIDEgMmMwIDEgNSAxNSAxNSAyOWExMTY2IDExNjYgMCAwIDAgMjMtN2M2LTkgMTQtMTkgMjAtMTlhNjggNjggMCAwIDEgNCAwbDggMS0zLTZzOCA2IDE4IDVjOS0xIDE2LTcgMTYtN3MwIDQtNSA3bDE0LTNjMyAwIDQgMSA1IDJsMSAxYTQ2MjYgNDYyNiAwIDAgMSAxNiAxMiA4MTYgODE2IDAgMCAwIDI3IDMgMTM0IDEzNCAwIDAgMCAxMC0yN3ptLTMtMTUgNC0yOGMtMTItMi0yOC01LTQ4LTYtMTggNi0zNCA4LTM1IDhoLTFsMTIgMiAxIDEgOCAyNyA0NSA1em0xMCA2IDUtMzJhMzIxIDMyMSAwIDAgMC04LTJsLTQgMjlhMiAyIDAgMCAxLTEgMWwtMTUgMTFoLTFsLTQ2LTVhMiAyIDAgMCAxLTItMWwtOC0yOC0xMy0yLTEzIDItOCAyOGEyIDIgMCAwIDEtMiAxbC00NiAxMGgtMWwtMTUtMTFhMiAyIDAgMCAxLTEtMWwtNC0zM2EzMjkgMzI5IDAgMCAwLTUgMGwtMyAyIDUgMzUgMjIgMTYgNTYtMTIgOC0yOCAyLTFhMzUgMzUgMCAwIDEgMTEgMGwxIDEgOCAyOCA1NyA3em03LTM3IDQtMTUtMzEtNS0yNCAxM2MyNSAyIDQzIDUgNTEgN3ptNyAyNC0xLTktNS0zLTQgMjYtMSAxLTUgNC0xIDUgMTAgMWMyLTMgNi0xNyA3LTI1em0xNi02NC0xIDExYTc0IDc0IDAgMCAxLTIgMTIgNjAgNjAgMCAwIDEtMiA1IDUxIDUxIDAgMCAxLTEgM2wtMSAyLTEgMXYxaC0xYTQ1IDQ1IDAgMCAxLTEgMWwtMiAxLTQgMWE4NCA4NCAwIDAgMC0yIDFsLTIgN2gtMWwtMSAxIDggNGMzIDIgMyA2IDMgMTNsLTQgMTdjLTIgMTEtNCAxMy02IDEzYTE3IDE3IDAgMCAxLTQgMWwtMTAtMWMwIDMtMyA5LTcgMTdoMWwxNS03IDctMy0zIDctMTQgMjZjLTUgMTAtMTQgMTItMjAgMTNsLTIgMS0yMCA1YTg1IDg1IDAgMCAxLTE1IDdjLTE5IDctMzkgNy01OCAwbC0xMiAyYTYwIDYwIDAgMCAxLTcgMGMtMTggMC0yNy05LTM0LTE4bC0xNi0yNC02LTggOSA0IDIwIDEwIDEtMWMtNi04LTExLTE4LTE0LTI5bC0xMSAyaC0zYy0yLTEtNC0zLTgtMTNsLTMtMTdjLTEtNy0xLTEwIDItMTNsOS01di0yYy0xOSAxMS0zMCAyMC0zMCAyMUwwIDE3OWw5LTE1YzgtMTEgMTYtMjEgMjQtMjhoLTFsLTItMWE0NyA0NyAwIDAgMS0xLTFoLTF2LTFsLTEtMS0xLTJhNDEgNDEgMCAwIDEtMS0zIDYwIDYwIDAgMCAxLTItNSA3MyA3MyAwIDAgMS0yLTEyIDczIDczIDAgMCAxIDAtMTEgNjkgNjkgMCAwIDEgMS0xMnYtMWgxYzMtMyA2LTQgMTAtNmExMDUgMTA1IDAgMCAxIDgtM2M4LTIyIDIyLTQyIDQwLTU2YTEwNCAxMDQgMCAwIDEgMTI2IDBjMTcgMTQgMzEgMzQgMzkgNTZsOCAzYzQgMiA3IDMgMTAgNmgxdjFhMzYgMzYgMCAwIDEgMSA2bDEgNnpNNTQgNzRhMTkyIDE5MiAwIDAgMC0yNyAxNWwtMSAyYTY4IDY4IDAgMCAwIDAgMiA3MCA3MCAwIDAgMCAwIDYgODAgODAgMCAwIDAgNiAzMGwxIDEgMSAxIDQgMS0zLTEyIDE2LTNhNDUgNDUgMCAwIDEtMy0xIDQ5IDQ5IDAgMCAxLTktM2wtMy0yYzExIDMgMzMgMiA1MyAwYTU0MCA1NDAgMCAwIDEgMTA4IDBjMjAgMiA0MiAzIDUzIDBsLTMgMi0zIDEtNyAyLTEgMSAxOCAzLTMgMTFoMmwxLTEgMS0xYTI4IDI4IDAgMCAwIDItNCA1NiA1NiAwIDAgMCAyLTUgNzcgNzcgMCAwIDAgMi0yMSA3MCA3MCAwIDAgMCAwLTYgNjkgNjkgMCAwIDAgMC0ydi0ybC04LTZhMTA3IDEwNyAwIDAgMC0yMC05bC01LTItNS0yYTEyNCAxMjQgMCAwIDEgMTcgNWMtMTYtNDMtNTQtNzAtOTYtNzAtNDMgMC04MSAyNy05NyA3MGExNDQgMTQ0IDAgMCAxIDE3LTUgMjQ4IDI0OCAwIDAgMC0xMCA0em05NiAxNTUgMiAyIDExIDEyYzQgMyA4IDUgMTQgNWg1bDYtMmE2OTQgNjk0IDAgMCAwIDIyLTVjNS0xIDEyLTMgMTYtMTFsMTAtMTgtNyAzYy0zIDItNyAyLTEyIDJoLTlsLTE2LTJoLTFsLTEtMS0xNy0xM3YtMWgtMmwtMjQgN2gtMnMtMTAtNC0yMi00aC0zYy0xIDAtOCAzLTE3IDE3djFoLTFhMTA2MCAxMDYwIDAgMCAxLTMxIDlsLTEgMS0xLTEtMTItNSAxMSAxNWM2IDggMTQgMTUgMjkgMTVoNmwyMi0zaDJzMTItMiAxNi0xMGw0LTEwIDItM3YtMmwxIDIiLz48L3N2Zz4=` | + +Feel free to reach out to us if you have any questions, and happy badging! diff --git a/frontend/blog/2024-09-25-rce.md b/frontend/blog/2024-09-25-rce.md new file mode 100644 index 0000000000000..e3aecd2ad0a25 --- /dev/null +++ b/frontend/blog/2024-09-25-rce.md @@ -0,0 +1,660 @@ +--- +slug: GHSA-rxvx-x284-4445 +title: Our response to RCE Security Advisory +authors: + name: chris48s + title: Shields.io Core Team + url: https://github.com/chris48s + image_url: https://avatars.githubusercontent.com/u/6025893 +tags: [] +--- + +We've just published a critical security advisory relating to a Remote Code Execution vulnerability in Dynamic JSON/TOML/YAML badges: https://github.com/badges/shields/security/advisories/GHSA-rxvx-x284-4445 Thanks to [@nickcopi](https://github.com/nickcopi) for his help with this. + +If you self-host your own instance of Shields you should upgrade to [server-2024-09-25](https://hub.docker.com/layers/shieldsio/shields/server-2024-09-25/images/sha256-28aaea75049e325c9f1d63c8a8b477fc387d3d3fe35b933d6581487843cd610f?context=explore) or later as soon as possible to protect your instance. + +This is primarily a concern for self-hosting users. However this does also have a couple of knock-on implications for some users of shields.io itself. + +## 1. Users who have authorized the Shields.io GitHub OAuth app + +While we have taken steps to close this vulnerability quickly after becoming aware of it, this attack vector has existed in our application for some time. We aren't aware of it having been actively exploited on shields.io. We also can't prove that it has not been exploited. + +We don't log or track our users, so a breach offers a very limited attack surface against end users of shields.io. This is by design. One of the (few) information assets shields.io does hold is our GitHub token pool. This allows users to share a token with us by authorizing our OAuth app. Doing this gives us access to a token with read-only access to public data which we can use to increase our rate limit when making calls to the GitHub API. + +The tokens we hold are not of high value to an attacker because they have read-only access to public data, but we can't say for sure they haven't been exfiltrated. If you've donated a token in the past and want to revoke it, you can revoke the Shields.io OAuth app at https://github.com/settings/applications which will de-activate the token you have shared with us. + +## 2. Users of Dynamic JSON/TOML/YAML badges + +Up until now, we have been using https://github.com/dchester/jsonpath as our library querying documents using JSONPath expressions. [@nickcopi](https://github.com/nickcopi) responsibly reported to us how a prototype pollution vulnerability in this library could be exploited to construct a JSONPath expression allowing an attacker to perform remote code execution. This vulnerability was reported on the package's issue tracker but not flagged by security scanning tools. It seems extremely unlikely that this will be fixed in the upstream package despite being widely used. It also seems unlikely this package will receive any further maintenance in future, even in response to critical security issues. In order to resolve this issue, we needed to switch to a different JSONPath library. We've decided to switch https://github.com/JSONPath-Plus/JSONPath using the `eval: false` option to disable script expressions. + +This is an important security improvement and we have to make a change to protect the security of shields.io and users hosting their own instance of the application. However, this does come with some tradeoffs from a backwards-compatibility perspective. + +### Using `eval: false` + +Using JSONPath-Plus with `eval: false` does disable some query syntax which relies on evaluating javascript expressions. + +For example, it would previously have been possible to use a JSONPath query like `$..keywords[(@.length-1)]` against the document https://github.com/badges/shields/raw/master/package.json to select the last element from the keywords array https://github.com/badges/shields/blob/e237e40ab88b8ad4808caad4f3dae653822aa79a/package.json#L6-L12 + +This is now not a supported query. + +In this particular case, you could rewrite that query to `$..keywords[-1:]` and obtain the same result, but that may not be possible in all cases. We do realise that this removes some functionality that previously worked but closing this remote code execution vulnerability is the top priority, especially since there will be workarounds in many cases. + +### Implementation Quirks + +Historically, every JSONPath implementation has had its own idiosyncrasies. While most simple and common queries will behave the same way across different implementations, switching to another library will mean that some subset of queries may not work or produce different results. + +One interesting thing that has happened in the world of JSONPath lately is RFC 9535 https://www.rfc-editor.org/rfc/rfc9535 which is an attempt to standardise JSONPath. As part of this mitigation, we did look at whether it would be possible to migrate to something that is RFC9535-compliant. However it is our assessment that the JavaScript community does not yet have a sufficiently mature/supported RFC9535-compliant JSONPath implementation. This means we are switching from one quirky implementation to another implementation with different quirks. + +Again, this represents an unfortunate break in backwards-compatibility. However, it was necessary to prioritise closing off this remote code execution vulnerability over backwards-compatibility. + +Although we can not provide a precise migration guide, here is a table of query types where https://github.com/dchester/jsonpath and https://github.com/JSONPath-Plus/JSONPath are known to diverge from the consensus implementation. This is sourced from the excellent https://cburgmer.github.io/json-path-comparison/ While this is a long list, many of these inputs represent edge cases or pathological inputs rather than common usage. + +
+ Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Query TypeExample Query
Array slice with large number for end and negative step$[2:-113667776004:-1]
Array slice with large number for start end negative step$[113667776004:2:-1]
Array slice with negative step$[3:0:-2]
Array slice with negative step on partially overlapping array$[7:3:-1]
Array slice with negative step only$[::-2]
Array slice with open end and negative step$[3::-1]
Array slice with open start and negative step$[:2:-1]
Array slice with range of 0$[0:0]
Array slice with step 0$[0:3:0]
Array slice with step and leading zeros$[010:024:010]
Bracket notation with empty path$[]
Bracket notation with number on object$[0]
Bracket notation with number on string$[0]
Bracket notation with number -1$[-1]
Bracket notation with quoted array slice literal$[':']
Bracket notation with quoted closing bracket literal$[']']
Bracket notation with quoted current object literal$['@']
Bracket notation with quoted escaped backslash$['\\']
Bracket notation with quoted escaped single quote$['\'']
Bracket notation with quoted root literal$['$']
Bracket notation with quoted special characters combined$[':@."$,*\'\\']
Bracket notation with quoted string and unescaped single quote$['single'quote']
Bracket notation with quoted union literal$[',']
Bracket notation with quoted wildcard literal ?$['*']
Bracket notation with quoted wildcard literal on object without key$['*']
Bracket notation with spaces$[ 'a' ]
Bracket notation with two literals separated by dot$['two'.'some']
Bracket notation with two literals separated by dot without quotes$[two.some]
Bracket notation without quotes$[key]
Current with dot notation@.a
Dot bracket notation$.['key']
Dot bracket notation with double quotes$.["key"]
Dot bracket notation without quotes$.[key]
Dot notation after recursive descent with extra dot ?$...key
Dot notation after union with keys$['one','three'].key
Dot notation with dash$.key-dash
Dot notation with double quotes$."key"
Dot notation with double quotes after recursive descent ?$.."key"
Dot notation with empty path$.
Dot notation with key named length on array$.length
Dot notation with key root literal$.$
Dot notation with non ASCII key$.??
Dot notation with number$.2
Dot notation with number -1$.-1
Dot notation with single quotes$.'key'
Dot notation with single quotes after recursive descent ?$..'key'
Dot notation with single quotes and dot$.'some.key'
Dot notation with space padded key$. a
Dot notation with wildcard after recursive descent on scalar ?$..*
Dot notation without dot$a
Dot notation without root.key
Dot notation without root and dotkey
Emptyn/a
Filter expression on object$[?(@.key)]
Filter expression after dot notation with wildcard after recursive descent ?$..*[?(@.id>2)]
Filter expression after recursive descent ?$..[?(@.id==2)]
Filter expression with addition$[?(@.key+50==100)]
Filter expression with boolean and operator and value false$[?(@.key>0 && false)]
Filter expression with boolean and operator and value true$[?(@.key>0 && true)]
Filter expression with boolean or operator and value false$[?(@.key>0 &#124;&#124; false)]
Filter expression with boolean or operator and value true$[?(@.key>0 &#124;&#124; true)]
Filter expression with bracket notation with -1$[?(@[-1]==2)]
Filter expression with bracket notation with number on object$[?(@[1]=='b')]
Filter expression with current object$[?(@)]
Filter expression with different ungrouped operators$[?(@.a && @.b &#124;&#124; @.c)]
Filter expression with division$[?(@.key/10==5)]
Filter expression with dot notation with dash$[?(@.key-dash == 'value')]
Filter expression with dot notation with number$[?(@.2 == 'second')]
Filter expression with dot notation with number on array$[?(@.2 == 'third')]
Filter expression with empty expression$[?()]
Filter expression with equals$[?(@.key==42)]
Filter expression with equals on array of numbers$[?(@==42)]
Filter expression with equals on object$[?(@.key==42)]
Filter expression with equals array$[?(@.d==["v1","v2"])]
Filter expression with equals array for array slice with range 1$[?(@[0:1]==[1])]
Filter expression with equals array for dot notation with star$[?(@.*==[1,2])]
Filter expression with equals array or equals true$[?(@.d==["v1","v2"] &#124;&#124; (@.d == true))]
Filter expression with equals array with single quotes$[?(@.d==['v1','v2'])]
Filter expression with equals boolean expression value$[?((@.key<44)==false)]
Filter expression with equals false$[?(@.key==false)]
Filter expression with equals null$[?(@.key==null)]
Filter expression with equals number for array slice with range 1$[?(@[0:1]==1)]
Filter expression with equals number for bracket notation with star$[?(@[*]==2)]
Filter expression with equals number for dot notation with star$[?(@.*==2)]
Filter expression with equals number with fraction$[?(@.key==-0.123e2)]
Filter expression with equals number with leading zeros$[?(@.key==010)]
Filter expression with equals object$[?(@.d=={"k":"v"})]
Filter expression with equals string$[?(@.key=="value")]
Filter expression with equals string with unicode character escape$[?(@.key=="Mot\u00f6rhead")]
Filter expression with equals true$[?(@.key==true)]
Filter expression with equals with path and path$[?(@.key1==@.key2)]
Filter expression with equals with root reference$.items[?(@.key==$.value)]
Filter expression with greater than$[?(@.key>42)]
Filter expression with greater than or equal$[?(@.key>=42)]
Filter expression with in array of values$[?(@.d in [2, 3])]
Filter expression with in current object$[?(2 in @.d)]
Filter expression with length free function$[?(length(@) == 4)]
Filter expression with length function$[?(@.length() == 4)]
Filter expression with length property$[?(@.length == 4)]
Filter expression with less than$[?(@.key<42)]
Filter expression with less than or equal$[?(@.key<=42)]
Filter expression with local dot key and null in data$[?(@.key='value')]
Filter expression with multiplication$[?(@.key*2==100)]
Filter expression with negation and equals$[?(!(@.key==42))]
Filter expression with negation and equals array or equals true$[?(!(@.d==["v1","v2"]) &#124;&#124; (@.d == true))]
Filter expression with negation and less than$[?(!(@.key<42))]
Filter expression with negation and without value$[?(!@.key)]
Filter expression with non singular existence test$[?(@.a.*)]
Filter expression with not equals$[?(@.key!=42)]
Filter expression with not equals array or equals true$[?((@.d!=["v1","v2"]) &#124;&#124; (@.d == true))]
Filter expression with parent axis operator$[*].bookmarks[?(@.page == 45)]^^^
Filter expression with regular expression$[?(@.name=~/hello.*/)]
Filter expression with regular expression from member$[?(@.name=~/@.pattern/)]
Filter expression with set wise comparison to scalar$[?(@[*]>=4)]
Filter expression with set wise comparison to set$.x[?(@[*]>=$.y[*])]
Filter expression with single equal$[?(@.key=42)]
Filter expression with subfilter$[?(@.a[?(@.price>10)])]
Filter expression with subpaths deeply nested$[?(@.a.b.c==3)]
Filter expression with subtraction$[?(@.key-50==-100)]
Filter expression with triple equal$[?(@.key===42)]
Filter expression with value$[?(@.key)]
Filter expression with value after recursive descent ?$..[?(@.id)]
Filter expression with value false$[?(false)]
Filter expression with value from recursive descent$[?(@..child)]
Filter expression with value null$[?(null)]
Filter expression with value true$[?(true)]
Filter expression without parens$[?@.key==42]
Filter expression without value$[?(@.key)]
Function sum$.data.sum()
Parens notation$(key,more)
Recursive descent ?$..
Recursive descent after dot notation ?$.key..
Root on scalar$
Root on scalar false$
Root on scalar true$
Script expression$[(@.length-1)]
Union with duplication from array$[0,0]
Union with duplication from object$['a','a']
Union with filter$[?(@.key<3),?(@.key>6)]
Union with keys$['key','another']
Union with keys on object without key$['missing','key']
Union with keys after array slice$[:]['c','d']
Union with keys after bracket notation$[0]['c','d']
Union with keys after dot notation with wildcard$.*['c','d']
Union with keys after recursive descent ?$..['c','d']
Union with repeated matches after dot notation with wildcard$.*[0,:5]
Union with slice and number$[1:3,4]
Union with spaces$[ 0 , 1 ]
Union with wildcard and number$[*,1]
+
diff --git a/frontend/blog/2024-11-14-token-pool.md b/frontend/blog/2024-11-14-token-pool.md new file mode 100644 index 0000000000000..9198b1e86379d --- /dev/null +++ b/frontend/blog/2024-11-14-token-pool.md @@ -0,0 +1,28 @@ +--- +slug: token-pool +title: How shields.io uses the GitHub API +authors: + name: chris48s + title: Shields.io Core Team + url: https://github.com/chris48s + image_url: https://avatars.githubusercontent.com/u/6025893 +tags: [] +--- + +We serve a lot of badges which display information fetched from the GitHub API. When I say a lot, this varies a bit but in a typical hour we make hundreds of thousands of calls to the GitHub API. + +But hang on. GitHub's API has rate limits. + +Specifically, users can make up to [5,000 requests per hour](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#primary-rate-limit-for-authenticated-users) to GitHub's v3/REST API. The v4/GraphQL also applies rate limits, but it is based on a slightly more complicated [points-based system](https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api#primary-rate-limit). + +In any case, we are clearly making many times more requests to GitHub's API than would be allowed with a single token. + +So how are we doing that? Well, we have lots of tokens. To elaborate on that slightly, as a user of shields.io you can choose to share a token with us to help increase our rate limit. Here's how it works: + +- Authorize our [OAuth Application](https://img.shields.io/github-auth). +- This shares with us a GitHub token which has read-only access to public data. We only ask for the minimum permissions necessary. Authorizing the OAuth app doesn't allow us access to your private data or allow us to perform any actions on your behalf. +- Your token is added to a pool of tokens shared by other users like you. +- When we need to make a request to the GitHub API, we pick one of the tokens from our pool. We only make a handful of requests with each token before picking another from the pool. +- If you ever decide you would not like to continue sharing a token with us, you can revoke the Shields.io OAuth app at https://github.com/settings/applications. You can do this at any time. This will de-activate the token you have shared with us and we'll remove it from the pool. + +This method allows us (with your help) to make hundreds of thousands of request per hour to the GitHub API. Because we have thousands of tokens in the pool and we only make a few requests with each one before picking another token from the pool, most users don't notice any meaningful impact on their available rate limit as a result of authorizing our app. diff --git a/frontend/blog/2024-12-27-simpleicons14.md b/frontend/blog/2024-12-27-simpleicons14.md new file mode 100644 index 0000000000000..9302cbda4942e --- /dev/null +++ b/frontend/blog/2024-12-27-simpleicons14.md @@ -0,0 +1,81 @@ +--- +slug: simple-icons-14 +title: Simple Icons 14 +authors: + name: jNullj + title: Shields.io Core Team + url: https://github.com/jNullj + image_url: https://avatars.githubusercontent.com/u/15849761 +tags: [] +--- + +Logos on Shields.io are provided by SimpleIcons. We've recently upgraded to SimpleIcons 14. This release removes 53 icons and renames 6: + +Renames: + +- D3.js to D3 +- Tencent QQ to QQ +- T-Mobile to Deutsche Telekom +- Nuxt.js to Nuxt +- smash.gg start.gg +- Tutanota to Tuta + +Removals: + +- Adobe +- Adobe Acrobat Reader +- Adobe After Effects +- Adobe Audition +- Adobe Creative Cloud +- Adobe Dreamweaver +- Adobe Fonts +- Adobe Illustrator +- Adobe InDesign +- Adobe Lightroom +- Adobe Lightroom Classic +- Adobe Photoshop +- Adobe Premiere Pro +- Adobe XD +- ASKfm +- Caffeine +- CKEditor 4 +- Cliqz +- Coil +- del.icio.us +- El Jueves +- Ello +- FeatHub +- Fluxus +- Foursquare City Guide +- Funimation +- Game & Watch +- Géant +- Katacoda +- LinkedIn +- Magento +- Marketo +- Microgenetics +- Nintendo +- Nintendo 3DS +- Nintendo DS +- Nintendo GameCube +- Nintendo Switch +- Oracle +- Pokémon +- RadioPublic +- Realm +- Revue +- Skyrock +- Spinrilla +- StackPath +- Stitcher +- Studyverse +- Tableau +- Uptobox +- Wii +- Wii U +- Zerply + +More detail can be found in the [release notes](https://github.com/simple-icons/simple-icons/releases/tag/14.0.0) + +Please remember that we are just consumers of SimpleIcons. Decisions about changes and removals are made by the [SimpleIcons](https://github.com/simple-icons/simple-icons) project. diff --git a/frontend/blog/2025-06-06-simpleicons15.md b/frontend/blog/2025-06-06-simpleicons15.md new file mode 100644 index 0000000000000..7698cb9517ed0 --- /dev/null +++ b/frontend/blog/2025-06-06-simpleicons15.md @@ -0,0 +1,14 @@ +--- +slug: simple-icons-15 +title: Simple Icons 15 +authors: + name: chris48s + title: Shields.io Core Team + url: https://github.com/chris48s + image_url: https://avatars.githubusercontent.com/u/6025893 +tags: [] +--- + +Logos on Shields.io are provided by SimpleIcons. We've recently upgraded to SimpleIcons 15. This release removes 50 icons and renames 7. A full list of the changes can be found in the [release notes](https://github.com/simple-icons/simple-icons/releases/tag/15.0.0). + +Please remember that we are just consumers of SimpleIcons. Decisions about changes and removals are made by the [SimpleIcons](https://github.com/simple-icons/simple-icons) project. diff --git a/frontend/blog/2025-07-26-CVE-2025-54313.md b/frontend/blog/2025-07-26-CVE-2025-54313.md new file mode 100644 index 0000000000000..597c78d891947 --- /dev/null +++ b/frontend/blog/2025-07-26-CVE-2025-54313.md @@ -0,0 +1,25 @@ +--- +slug: CVE-2025-54313 +title: Supply chain vulnerability in eslint-config-prettier +authors: + name: jNullj + title: Shields.io Core Team + url: https://github.com/jNullj + image_url: https://avatars.githubusercontent.com/u/15849761 +tags: [] +--- + +A recent vulnerability in the `eslint-config-prettier` package, identified as [CVE-2025-54313](https://www.endorlabs.com/learn/cve-2025-54313-eslint-config-prettier-compromise----high-severity-but-windows-only), has raised concerns about potential remote code execution (RCE) attacks. This vulnerability specifically affects Windows users and has been classified as high severity. + +Other packages vulnerable to this issue that shields uses in our supply chain include: + +- `eslint-plugin-prettier` +- `synckit` +- `@pkgr/core` +- `napi-postinstall` + +None of the vulnerable versions seems to be used in our codebase, and to date, upstream removed vulnerable versions from npm. However, as we used `^` prefix for these package versions, anyone running `npm install` on a Windows machine before the fix was applied could have been affected. For example, contributors & devs of forks. With the risk being malicious code executed on the user's machine, we recommend that anyone who has run `npm install` on a Windows machine with these packages check their systems for any signs of compromise. + +The issue does not seem to affect our CI environment, as our only Windows job `test-main` uses `npm ci` which does not install packages with the `^` prefix, and thus does not install vulnerable versions. + +Currently, we are not aware of any issues in our production environment related to this vulnerability. We will continue to monitor the situation. diff --git a/frontend/blog/2025-12-03-simple-icons-16.md b/frontend/blog/2025-12-03-simple-icons-16.md new file mode 100644 index 0000000000000..c193c458220a9 --- /dev/null +++ b/frontend/blog/2025-12-03-simple-icons-16.md @@ -0,0 +1,14 @@ +--- +slug: simple-icons-16 +title: Simple Icons 16 +authors: + name: LitoMore + title: Shields.io Core Team + url: https://github.com/LitoMore + image_url: https://avatars.githubusercontent.com/u/8186898 +tags: [] +--- + +Logos on Shields.io are provided by Simple Icons. We've recently upgraded to Simple Icons 16. This release removes 44 icons and renames 2. A full list of the changes can be found in the [release notes](https://github.com/simple-icons/simple-icons/releases/tag/16.0.0). + +Please remember that we are just consumers of Simple Icons. Decisions about changes and removals are made by the [SimpleIcons](https://github.com/simple-icons/simple-icons) project. diff --git a/frontend/blog/2026-06-21-mit-apache-license.md b/frontend/blog/2026-06-21-mit-apache-license.md new file mode 100644 index 0000000000000..0b6e51d3f0b18 --- /dev/null +++ b/frontend/blog/2026-06-21-mit-apache-license.md @@ -0,0 +1,18 @@ +--- +slug: mit-apache-license +title: Switching to MIT and Apache 2.0 licenses +authors: + name: PyvesB + title: Shields.io Core Team + url: https://github.com/PyvesB + image_url: https://avatars.githubusercontent.com/u/10694593 +tags: [] +--- + +Since the [project's inception](https://github.com/badges/shields/commit/3f3f1b30bb8aade8ab6907fab3b7f889dfb3e882#diff-37854d19817c792316d481f5beb93cc7), Shields.io has been licensed under the CC0 public domain license. + +Today, we’re adopting a dual-license model, re-licensing the project under both the MIT and Apache 2.0 licenses. This change means that new revisions of Shields will now be considered copyrighted work that builds upon and modifies the public domain code from earlier versions. + +Shields has always embraced the public domain through the openness of its CC0 license, a spirit that has defined the project over the years. This licensing change isn’t about moving away from that ethos, but rather about strengthening the foundation for maintainers and contributors. The MIT and Apache 2.0 licenses provide clear warranty disclaimers and limitations of liability, offering a more reliable and legally safe framework for ongoing development. + +For more context and discussion, see [this issue](https://github.com/badges/shields/issues/3664). diff --git a/frontend/categories/.gitkeep b/frontend/categories/.gitkeep new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/frontend/components/badge-examples.tsx b/frontend/components/badge-examples.tsx deleted file mode 100644 index 0a3229d5b8e4f..0000000000000 --- a/frontend/components/badge-examples.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import React from 'react' -import styled from 'styled-components' -import { - badgeUrlFromPath, - badgeUrlFromPattern, - staticBadgeUrl, -} from '../../core/badge-urls/make-badge-url' -import { removeRegexpFromPattern } from '../lib/pattern-helpers' -import { - Example as ExampleData, - Suggestion, - RenderableExample, -} from '../lib/service-definitions' -import { Badge } from './common' -import { StyledCode } from './snippet' - -const ExampleTable = styled.table` - min-width: 50%; - margin: auto; - - th, - td { - text-align: left; - } -` - -const ClickableTh = styled.th` - cursor: pointer; -` - -const ClickableCode = styled(StyledCode)` - cursor: pointer; -` - -function Example({ - baseUrl, - onClick, - exampleData, - isBadgeSuggestion, -}: { - baseUrl?: string - onClick: (example: RenderableExample, isSuggestion: boolean) => void - exampleData: RenderableExample - isBadgeSuggestion: boolean -}): JSX.Element { - function handleClick(): void { - onClick(exampleData, isBadgeSuggestion) - } - - let exampleUrl, previewUrl - if (isBadgeSuggestion) { - const { - example: { pattern, namedParams, queryParams }, - } = exampleData as Suggestion - exampleUrl = previewUrl = badgeUrlFromPattern({ - baseUrl, - pattern, - namedParams, - queryParams, - }) - } else { - const { - example: { pattern, queryParams }, - preview: { label, message, color, style, namedLogo }, - } = exampleData as ExampleData - previewUrl = staticBadgeUrl({ - baseUrl, - label: label || '', - message, - color, - style, - namedLogo, - }) - exampleUrl = badgeUrlFromPath({ - path: removeRegexpFromPattern(pattern), - queryParams, - }) - } - - const { title } = exampleData - return ( - - {title}: - - - - - {exampleUrl} - - - ) -} - -export function BadgeExamples({ - examples, - areBadgeSuggestions, - baseUrl, - onClick, -}: { - examples: RenderableExample[] - areBadgeSuggestions: boolean - baseUrl?: string - onClick: (exampleData: RenderableExample, isSuggestion: boolean) => void -}): JSX.Element { - return ( - - - {examples.map(exampleData => ( - - ))} - - - ) -} diff --git a/frontend/components/category-headings.tsx b/frontend/components/category-headings.tsx deleted file mode 100644 index 2f1d03c9a512a..0000000000000 --- a/frontend/components/category-headings.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React from 'react' -import styled from 'styled-components' -import { Link } from 'gatsby' -import { H3 } from './common' - -export interface Category { - id: string - name: string -} - -export function CategoryHeading({ - category: { id, name }, -}: { - category: Category -}): JSX.Element { - return ( - -

{name}

- - ) -} - -export function CategoryHeadings({ - categories, -}: { - categories: Category[] -}): JSX.Element { - return ( -
- {categories.map(category => ( - - ))} -
- ) -} - -const StyledNav = styled.nav` - ul { - display: flex; - - min-width: 50%; - max-width: 500px; - - margin: 0 auto 20px; - padding-inline-start: 0; - - flex-wrap: wrap; - justify-content: center; - - list-style-type: none; - } - - @media screen and (max-width: 768px) { - ul { - display: none; - } - } - - li { - margin: 4px 10px; - } - - .active { - font-weight: 900; - } -` - -export function CategoryNav({ - categories, -}: { - categories: Category[] -}): JSX.Element { - return ( - -
    - {categories.map(({ id, name }) => ( -
  • - {name} -
  • - ))} -
-
- ) -} diff --git a/frontend/components/common.tsx b/frontend/components/common.tsx deleted file mode 100644 index e878e6792be5c..0000000000000 --- a/frontend/components/common.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import React from 'react' -import styled, { css, createGlobalStyle } from 'styled-components' - -export const noAutocorrect = Object.freeze({ - autoComplete: 'off', - autoCorrect: 'off', - autoCapitalize: 'off', - spellcheck: 'false', -}) - -export const nonBreakingSpace = '\u00a0' - -export const GlobalStyle = createGlobalStyle` - * { - box-sizing: border-box; - } -` - -export const BaseFont = styled.div` - font-family: Lekton, sans-serif; - color: #534; -` - -export const H2 = styled.h2` - font-style: italic; - - margin-top: 12mm; - font-variant: small-caps; - - ::before { - content: '☙ '; - } - - ::after { - content: ' ❧'; - } -` - -export const H3 = styled.h3` - font-style: italic; -` - -interface BadgeWrapperProps { - height: string - display: string - clickable: boolean -} - -const BadgeWrapper = styled.span` - padding: 2px; - height: ${({ height }) => height}; - vertical-align: middle; - display: ${({ display }) => display}; - - ${({ clickable }) => - clickable && - css` - cursor: pointer; - `}; -` - -interface BadgeProps extends React.HTMLAttributes { - src: string - alt?: string - display?: 'inline' | 'block' | 'inline-block' - height?: string - clickable?: boolean - object?: boolean -} - -export function Badge({ - src, - alt = '', - display = 'inline', - height = '20px', - clickable = false, - object = false, - ...rest -}: BadgeProps): JSX.Element { - return ( - - {src ? ( - object ? ( - alt - ) : ( - {alt} - ) - ) : ( - nonBreakingSpace - )} - - ) -} - -export const StyledInput = styled.input` - height: 15px; - border: solid #b9a; - border-width: 0 0 1px 0; - padding: 0; - - text-align: center; - - color: #534; - - :focus { - outline: 0; - } -` - -export const InlineInput = styled(StyledInput)` - width: 70px; - margin-left: 5px; - margin-right: 5px; -` - -export const BlockInput = styled(StyledInput)` - width: 40%; - background-color: transparent; -` - -export const VerticalSpace = styled.hr` - border: 0; - display: block; - height: 3mm; -` diff --git a/frontend/components/customizer/builder-common.tsx b/frontend/components/customizer/builder-common.tsx deleted file mode 100644 index 939184028728f..0000000000000 --- a/frontend/components/customizer/builder-common.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import React from 'react' -import styled from 'styled-components' - -const BuilderOuterContainer = styled.div` - margin-top: 10px; - margin-bottom: 10px; -` - -// The inner container is inline-block so that its width matches its columns. -const BuilderInnerContainer = styled.div` - display: inline-block; - - padding: 1px 14px 10px; - - border-radius: 4px; - background: #eef; -` - -export function BuilderContainer({ - children, -}: { - children: JSX.Element[] | JSX.Element -}): JSX.Element { - return ( - - {children} - - ) -} - -const labelFont = ` - font-family: system-ui; - font-size: 11px; -` - -export const BuilderLabel = styled.label` - ${labelFont} - - text-transform: lowercase; -` - -export const BuilderCaption = styled.span` - ${labelFont} - - color: #999; -` diff --git a/frontend/components/customizer/copied-content-indicator.tsx b/frontend/components/customizer/copied-content-indicator.tsx deleted file mode 100644 index 560fe33872a6d..0000000000000 --- a/frontend/components/customizer/copied-content-indicator.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React, { useState, useImperativeHandle, forwardRef } from 'react' -import posed from 'react-pose' -import styled from 'styled-components' - -const ContentAnchor = styled.span` - position: relative; - display: inline-block; -` - -// 100vw allows providing styled content which is wider than its container. -const ContentContainer = styled.span` - width: 100vw; - - position: absolute; - left: 50%; - transform: translateX(-50%); - - will-change: opacity, top; - - pointer-events: none; -` - -const PosedContentContainer = posed(ContentContainer)({ - hidden: { opacity: 0, transition: { duration: 100 } }, - effectStart: { top: '-10px', opacity: 1.0, transition: { duration: 0 } }, - effectEnd: { top: '-75px', opacity: 0.5 }, -}) - -export interface CopiedContentIndicatorHandle { - trigger: () => void -} - -// When `trigger()` is called, render copied content that floats up, then -// disappears. -function _CopiedContentIndicator( - { - copiedContent, - children, - }: { - copiedContent: JSX.Element | string - children: JSX.Element | JSX.Element[] - }, - ref: React.Ref -): JSX.Element { - const [pose, setPose] = useState('hidden') - - useImperativeHandle(ref, () => ({ - trigger() { - setPose('effectStart') - }, - })) - - function handlePoseComplete(): void { - if (pose === 'effectStart') { - setPose('effectEnd') - } else { - setPose('hidden') - } - } - - return ( - - - {copiedContent} - - {children} - - ) -} -export const CopiedContentIndicator = forwardRef(_CopiedContentIndicator) diff --git a/frontend/components/customizer/customizer.tsx b/frontend/components/customizer/customizer.tsx deleted file mode 100644 index 93a926cc9eb2a..0000000000000 --- a/frontend/components/customizer/customizer.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import React, { useRef, useState } from 'react' -import clipboardCopy from 'clipboard-copy' -import { staticBadgeUrl } from '../../../core/badge-urls/make-badge-url' -import { generateMarkup, MarkupFormat } from '../../lib/generate-image-markup' -import { Badge } from '../common' -import PathBuilder from './path-builder' -import QueryStringBuilder from './query-string-builder' -import RequestMarkupButtom from './request-markup-button' -import { - CopiedContentIndicator, - CopiedContentIndicatorHandle, -} from './copied-content-indicator' - -export default function Customizer({ - baseUrl, - title, - pattern, - exampleNamedParams, - exampleQueryParams, - initialStyle, - isPrefilled, - link = '', -}: { - baseUrl: string - title: string - pattern: string - exampleNamedParams: { [k: string]: string } - exampleQueryParams: { [k: string]: string } - initialStyle?: string - isPrefilled: boolean - link?: string -}): JSX.Element { - // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/35572 - // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/28884#issuecomment-471341041 - const indicatorRef = - useRef() as React.MutableRefObject - const [path, setPath] = useState('') - const [queryString, setQueryString] = useState() - const [pathIsComplete, setPathIsComplete] = useState() - const [markup, setMarkup] = useState() - const [message, setMessage] = useState() - - function generateBuiltBadgeUrl(): string { - const suffix = queryString ? `?${queryString}` : '' - return `${baseUrl}${path}${suffix}` - } - - function renderLivePreview(): JSX.Element { - // There are some usability issues here. It would be better if the message - // changed from a validation error to a loading message once the - // parameters were filled in, and also switched back to loading when the - // parameters changed. - let src - if (pathIsComplete) { - src = generateBuiltBadgeUrl() - } else { - src = staticBadgeUrl({ - baseUrl, - label: 'preview', - message: 'some parameters missing', - }) - } - return ( -

- -

- ) - } - - async function copyMarkup(markupFormat: MarkupFormat): Promise { - const builtBadgeUrl = generateBuiltBadgeUrl() - const markup = generateMarkup({ - badgeUrl: builtBadgeUrl, - link, - title, - markupFormat, - }) - - try { - await clipboardCopy(markup) - } catch (e) { - setMessage('Copy failed') - setMarkup(markup) - return - } - - setMarkup(markup) - if (indicatorRef.current) { - indicatorRef.current.trigger() - } - } - - function renderMarkupAndLivePreview(): JSX.Element { - return ( -
- {renderLivePreview()} - - - - {message && ( -
-

{message}

-

Markup: {markup}

-
- )} -
- ) - } - - function handlePathChange({ - path, - isComplete, - }: { - path: string - isComplete: boolean - }): void { - setPath(path) - setPathIsComplete(isComplete) - } - - function handleQueryStringChange({ - queryString, - isComplete, - }: { - queryString: string - isComplete: boolean - }): void { - setQueryString(queryString) - } - - return ( -
- - -
{renderMarkupAndLivePreview()}
- - ) -} diff --git a/frontend/components/customizer/path-builder.tsx b/frontend/components/customizer/path-builder.tsx deleted file mode 100644 index a5f97e700beb2..0000000000000 --- a/frontend/components/customizer/path-builder.tsx +++ /dev/null @@ -1,262 +0,0 @@ -import React, { useState, useEffect, ChangeEvent } from 'react' -import styled, { css } from 'styled-components' -import { Token, Key, parse } from 'path-to-regexp' -import humanizeString from 'humanize-string' -import { patternToOptions } from '../../lib/pattern-helpers' -import { noAutocorrect, StyledInput } from '../common' -import { - BuilderContainer, - BuilderLabel, - BuilderCaption, -} from './builder-common' - -interface PathBuilderColumnProps { - pathContainsOnlyLiterals: boolean - withHorizPadding?: boolean -} - -const PathBuilderColumn = styled.span` - height: ${({ pathContainsOnlyLiterals }) => - pathContainsOnlyLiterals ? '18px' : '78px'}; - - float: left; - display: flex; - flex-direction: column; - - margin: 0; - - ${({ withHorizPadding }) => - withHorizPadding && - css` - padding: 0 8px; - `}; -` - -interface PathLiteralProps { - isFirstToken: boolean - pathContainsOnlyLiterals: boolean -} - -const PathLiteral = styled.div` - margin-top: ${({ pathContainsOnlyLiterals }) => - pathContainsOnlyLiterals ? '0px' : '39px'}; - ${({ isFirstToken }) => - isFirstToken && - css` - margin-left: 3px; - `}; -` - -const NamedParamLabelContainer = styled.span` - display: flex; - flex-direction: column; - height: 37px; - width: 100%; - justify-content: center; -` - -const inputStyling = ` - width: 100%; - text-align: center; -` - -// 2px to align with input boxes alongside. -const NamedParamInput = styled(StyledInput)` - ${inputStyling} - margin-top: 2px; - margin-bottom: 10px; -` - -const NamedParamSelect = styled.select` - ${inputStyling} - margin-bottom: 9px; - font-size: 10px; -` - -const NamedParamCaption = styled(BuilderCaption)` - width: 100%; - text-align: center; -` - -export function constructPath({ - tokens, - namedParams, -}: { - tokens: Token[] - namedParams: { [k: string]: string } -}): { path: string; isComplete: boolean } { - let isComplete = true - let path = tokens - .map(token => { - if (typeof token === 'string') { - return token.trim() - } else { - const { prefix, name, modifier } = token - const value = namedParams[name] - if (value) { - return `${prefix}${value.trim()}` - } else if (modifier === '?' || modifier === '*') { - return '' - } else { - isComplete = false - return `${prefix}:${name}` - } - } - }) - .join('') - path = encodeURI(path) - return { path, isComplete } -} - -export default function PathBuilder({ - pattern, - exampleParams, - onChange, - isPrefilled, -}: { - pattern: string - exampleParams: { [k: string]: string } - onChange: ({ - path, - isComplete, - }: { - path: string - isComplete: boolean - }) => void - isPrefilled: boolean -}): JSX.Element { - const [tokens] = useState(() => parse(pattern)) - const [namedParams, setNamedParams] = useState(() => - isPrefilled - ? exampleParams - : // `pathToRegexp.parse()` returns a mixed array of strings for literals - // and objects for parameters. Filter out the literals and work with the - // objects. - tokens - .filter(t => typeof t !== 'string') - .map(t => t as Key) - .reduce((accum, { name }) => { - accum[name] = '' - return accum - }, {} as { [k: string]: string }) - ) - - useEffect(() => { - // Ensure the default style is applied right away. - if (onChange) { - const { path, isComplete } = constructPath({ tokens, namedParams }) - onChange({ path, isComplete }) - } - }, [tokens, namedParams, onChange]) - - function handleTokenChange({ - target: { name, value }, - }: ChangeEvent): void { - setNamedParams({ - ...namedParams, - [name]: value, - }) - } - - function renderLiteral( - literal: string, - tokenIndex: number, - pathContainsOnlyLiterals: boolean - ): JSX.Element { - return ( - - - {literal} - - - ) - } - - function renderNamedParamInput(token: Key): JSX.Element { - const { pattern } = token - const name = `${token.name}` - const options = patternToOptions(pattern) - - const value = namedParams[name] - - if (options) { - return ( - - - {options.map(option => ( - - ))} - - ) - } else { - return ( - - ) - } - } - - function renderNamedParam( - token: Key, - tokenIndex: number, - namedParamIndex: number - ): JSX.Element { - const { prefix, modifier } = token - const optional = modifier === '?' || modifier === '*' - const name = `${token.name}` - - const exampleValue = exampleParams[name] || '(not set)' - - return ( - - {renderLiteral(prefix, tokenIndex, false)} - - - {humanizeString(name)} - {optional ? (optional) : null} - - {renderNamedParamInput(token)} - {!isPrefilled && ( - - {namedParamIndex === 0 ? `e.g. ${exampleValue}` : exampleValue} - - )} - - - ) - } - - let namedParamIndex = 0 - const pathContainsOnlyLiterals = tokens.every( - token => typeof token === 'string' - ) - return ( - - {tokens.map((token, tokenIndex) => - typeof token === 'string' - ? renderLiteral(token, tokenIndex, pathContainsOnlyLiterals) - : renderNamedParam(token, tokenIndex, namedParamIndex++) - )} - - ) -} diff --git a/frontend/components/customizer/query-string-builder.tsx b/frontend/components/customizer/query-string-builder.tsx deleted file mode 100644 index 4b8ff970ec79e..0000000000000 --- a/frontend/components/customizer/query-string-builder.tsx +++ /dev/null @@ -1,342 +0,0 @@ -import React, { - useState, - useEffect, - ChangeEvent, - ChangeEventHandler, -} from 'react' -import styled from 'styled-components' -import humanizeString from 'humanize-string' -import { stringify as stringifyQueryString } from 'query-string' -import { advertisedStyles } from '../../lib/supported-features' -import { noAutocorrect, StyledInput } from '../common' -import { - BuilderContainer, - BuilderLabel, - BuilderCaption, -} from './builder-common' - -const QueryParamLabel = styled(BuilderLabel)` - margin: 5px; -` - -const QueryParamInput = styled(StyledInput)` - margin: 5px 10px; -` - -const QueryParamCaption = styled(BuilderCaption)` - margin: 5px; -` - -type BadgeOptionName = 'style' | 'label' | 'color' | 'logo' | 'logoColor' - -interface BadgeOptionInfo { - name: BadgeOptionName - label?: string - shieldsDefaultValue?: string -} - -const supportedBadgeOptions = [ - { name: 'style', shieldsDefaultValue: 'flat' }, - { name: 'label', label: 'override label' }, - { name: 'color', label: 'override color' }, - { name: 'logo', label: 'named logo' }, - { name: 'logoColor', label: 'override logo color' }, -] as BadgeOptionInfo[] - -function getBadgeOption(name: BadgeOptionName): BadgeOptionInfo { - const result = supportedBadgeOptions.find(opt => opt.name === name) - if (!result) { - throw Error(`Unknown badge option: ${name}`) - } - return result -} - -function getQueryString({ - queryParams, - badgeOptions, -}: { - queryParams: Record - badgeOptions: Record -}): { - queryString: string - isComplete: boolean -} { - // Use `string | null`, because `query-string` renders e.g. - // `{ compact_message: null }` as `?compact_message`. This is - // what we want for boolean params that are true (see below). - const outQuery = {} as Record - let isComplete = true - - Object.entries(queryParams).forEach(([name, value]) => { - // As above, there are two types of supported params: strings and - // booleans. - if (typeof value === 'string') { - if (value) { - outQuery[name] = value.trim() - } else { - // Skip empty params. - isComplete = false - } - } else { - // Generate empty query params for boolean parameters by translating - // `{ compact_message: true }` to `?compact_message`. When values are - // false, skip the param. - if (value) { - outQuery[name] = null - } - } - }) - - Object.entries(badgeOptions).forEach(([name, value]) => { - const { shieldsDefaultValue } = getBadgeOption(name as BadgeOptionName) - if (value && value !== shieldsDefaultValue) { - outQuery[name] = value - } - }) - - const queryString = stringifyQueryString(outQuery) - - return { queryString, isComplete } -} - -function ServiceQueryParam({ - name, - value, - exampleValue, - isStringParam, - stringParamCount, - handleServiceQueryParamChange, -}: { - name: string - value: string | boolean - exampleValue: string - isStringParam: boolean - stringParamCount?: number - handleServiceQueryParamChange: ChangeEventHandler -}): JSX.Element { - return ( - - - - {humanizeString(name).toLowerCase()} - - - - {isStringParam && ( - - {stringParamCount === 0 ? `e.g. ${exampleValue}` : exampleValue} - - )} - - - {isStringParam ? ( - - ) : ( - - )} - - - ) -} - -function BadgeOptionInput({ - name, - value, - handleBadgeOptionChange, -}: { - name: BadgeOptionName - value: string - handleBadgeOptionChange: ChangeEventHandler< - HTMLSelectElement | HTMLInputElement - > -}): JSX.Element { - if (name === 'style') { - return ( - - ) - } else { - return ( - - ) - } -} - -function BadgeOption({ - name, - value, - handleBadgeOptionChange, -}: { - name: BadgeOptionName - value: string - handleBadgeOptionChange: ChangeEventHandler -}): JSX.Element { - const { - label = humanizeString(name), - shieldsDefaultValue: hasShieldsDefaultValue, - } = getBadgeOption(name) - return ( - - - {label} - - - {!hasShieldsDefaultValue && ( - optional - )} - - - - - - ) -} - -// The UI for building the query string, which includes two kinds of settings: -// 1. Custom query params defined by the service, stored in -// `this.state.queryParams` -// 2. The standard badge options which apply to all badges, stored in -// `this.state.badgeOptions` -export default function QueryStringBuilder({ - exampleParams, - initialStyle = 'flat', - onChange, -}: { - exampleParams: { [k: string]: string } - initialStyle?: string - onChange: ({ - queryString, - isComplete, - }: { - queryString: string - isComplete: boolean - }) => void -}): JSX.Element { - 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 }) - ) - // For each of the standard badge options, create empty values in - // `badgeOptions`. When `initialStyle` has been provided, use it. - const [badgeOptions, setBadgeOptions] = useState(() => - supportedBadgeOptions.reduce((accum, { name }) => { - if (name === 'style') { - accum[name] = initialStyle - } else { - accum[name] = '' - } - return accum - }, {} as Record) - ) - - function handleServiceQueryParamChange({ - target: { name, type: targetType, checked, value }, - }: ChangeEvent): void { - const outValue = targetType === 'checkbox' ? checked : value - setQueryParams({ ...queryParams, [name]: outValue }) - } - - function handleBadgeOptionChange({ - target: { name, value }, - }: ChangeEvent): void { - setBadgeOptions({ ...badgeOptions, [name]: value }) - } - - useEffect(() => { - if (onChange) { - const { queryString, isComplete } = getQueryString({ - queryParams, - badgeOptions, - }) - onChange({ queryString, isComplete }) - } - }, [onChange, queryParams, badgeOptions]) - - const hasQueryParams = Boolean(Object.keys(queryParams).length) - let stringParamCount = 0 - return ( - <> - {hasQueryParams && ( - - - - {Object.entries(queryParams).map(([name, value]) => { - const isStringParam = typeof value === 'string' - return ( - - ) - })} - -
-
- )} - - - - {Object.entries(badgeOptions).map(([name, value]) => ( - - ))} - -
-
- - ) -} diff --git a/frontend/components/customizer/request-markup-button.tsx b/frontend/components/customizer/request-markup-button.tsx deleted file mode 100644 index 5223062e970c6..0000000000000 --- a/frontend/components/customizer/request-markup-button.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import React, { useRef } from 'react' -import styled from 'styled-components' -import Select, { components } from 'react-select' -import { MarkupFormat } from '../../lib/generate-image-markup' - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function ClickableControl(props: any): JSX.Element { - return ( - - ) -} - -interface Option { - value: MarkupFormat - label: string -} - -const MarkupFormatSelect = styled(Select)` - width: 200px; - - margin-left: auto; - margin-right: auto; - - font-family: 'Lato', sans-serif; - font-size: 12px; - - .markup-format__control { - background-image: linear-gradient(-180deg, #00aeff 0%, #0076ff 100%); - border: 1px solid rgba(238, 239, 241, 0.8); - border-width: 0; - box-shadow: unset; - cursor: copy; - } - - .markup-format__control--is-disabled { - background: rgba(0, 118, 255, 0.3); - cursor: none; - } - - .markup-format__placeholder { - color: #eeeff1; - } - - .markup-format__indicator { - color: rgba(238, 239, 241, 0.81); - cursor: pointer; - } - - .markup-format__indicator:hover { - color: #eeeff1; - } - - .markup-format__control--is-focused .markup-format__indicator, - .markup-format__control--is-focused .markup-format__indicator:hover { - color: #ffffff; - } - - .markup-format__option { - text-align: left; - cursor: copy; - } -` - -const markupOptions: Option[] = [ - { value: 'markdown', label: 'Copy Markdown' }, - { value: 'rst', label: 'Copy reStructuredText' }, - { value: 'asciidoc', label: 'Copy AsciiDoc' }, - { value: 'html', label: 'Copy HTML' }, -] - -export default function GetMarkupButton({ - onMarkupRequested, - isDisabled, -}: { - onMarkupRequested: (markupFormat: MarkupFormat) => Promise - isDisabled: boolean -}): JSX.Element { - // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/35572 - // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/28884#issuecomment-471341041 - const selectRef = useRef>() as React.MutableRefObject< - Select