diff --git a/.c8rc.json b/.c8rc.json new file mode 100644 index 0000000000000..9413073fbf999 --- /dev/null +++ b/.c8rc.json @@ -0,0 +1,28 @@ +{ + "reporter": ["lcov"], + "all": true, + "silent": true, + "clean": false, + "exclude": [ + "**/*.spec.js", + "**/*.integration.js", + "**/test-helpers.js", + "**/*-test-helpers.js", + "**/*-fixtures.js", + "**/*.test-d.ts", + "dangerfile.js", + "core/service-test-runner", + "core/got-test-client.js", + "services/**/*.tester.js", + "services/test-validators.js", + "services/tester.js", + "core/base-service/loader-test-fixtures", + "scripts", + "coverage", + ".github", + "**/public/", + "cypress", + "frontend", + "migrations" + ] +} diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 5ab2784b3408c..0000000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,383 +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 - -run_package_tests: &run_package_tests - when: always - command: | - # https://discuss.circleci.com/t/switch-nodejs-version-on-machine-executor-solved/26675/3 - set +e - export NVM_DIR="/opt/circleci/.nvm" - [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" - nvm install $NODE_VERSION - nvm use $NODE_VERSION - node --version - npm run test:package - -package_steps: &package_steps - steps: - - checkout - - - run: - name: Install dependencies - command: | - set +e - export NVM_DIR="/opt/circleci/.nvm" - [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" - nvm install v12 - nvm install v14 - nvm use v12 - npm install -g npm - npm ci - environment: - CYPRESS_INSTALL_BINARY: 0 - - # 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 - - - run: - <<: *run_package_tests - environment: - mocha_reporter: mocha-junit-reporter - MOCHA_FILE: junit/badge-maker/v10/results.xml - NODE_VERSION: v10 - name: Run package tests on Node 10 - - - run: - <<: *run_package_tests - environment: - mocha_reporter: mocha-junit-reporter - MOCHA_FILE: junit/badge-maker/v12/results.xml - NODE_VERSION: v12 - name: Run package tests on Node 12 - - - run: - <<: *run_package_tests - environment: - mocha_reporter: mocha-junit-reporter - MOCHA_FILE: junit/badge-maker/v14/results.xml - NODE_VERSION: v14 - name: Run package tests on Node 14 - - - store_test_results: - path: junit - -jobs: - npm-install: - docker: - - image: circleci/node:10 - steps: - - checkout - - - run: - name: Install dependencies - command: npm ci - environment: - CYPRESS_INSTALL_BINARY: 0 - - main: - docker: - - image: circleci/node:10 - - <<: *main_steps - - main@node-12: - docker: - - image: circleci/node:12 - - <<: *main_steps - - integration: - docker: - - image: circleci/node:10 - - image: redis - - <<: *integration_steps - - integration@node-12: - docker: - - image: circleci/node:12 - - image: redis - - <<: *integration_steps - - danger: - docker: - - image: circleci/node:10 - 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:10 - 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:10 - - <<: *services_steps - - services@node-12: - docker: - - image: circleci/node:12 - - <<: *services_steps - - e2e: - docker: - - image: cypress/base:8 - 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-12: - filters: - branches: - ignore: gh-pages - - integration@node-12: - 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-12: - 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/.dependabot/config.yml b/.dependabot/config.yml deleted file mode 100644 index b2042dda94617..0000000000000 --- a/.dependabot/config.yml +++ /dev/null @@ -1,109 +0,0 @@ -version: 1 -update_configs: - # shields.io dependencies - - package_manager: 'javascript' - directory: '/' - update_schedule: 'weekly' - automerged_updates: - - match: - dependency_name: 'chai*' - update_type: 'semver:minor' - - match: - dependency_name: 'cypress' - update_type: 'semver:minor' - - match: - dependency_name: 'eslint*' - update_type: 'semver:minor' - - match: - dependency_name: 'enzyme*' - update_type: 'semver:minor' - - match: - dependency_name: 'mocha*' - update_type: 'semver:minor' - - match: - dependency_name: 'sazerac' - update_type: 'semver:minor' - - match: - dependency_name: 'sinon*' - update_type: 'semver:minor' - - match: - dependency_name: 'snap-shot-it' - update_type: 'semver:minor' - ignored_updates: - - match: - dependency_name: babel-preset-gatsby - version_requirement: '>=0.3.0' - - match: - dependency_name: camelcase - version_requirement: '>=6.0.0' - - match: - dependency_name: chalk - version_requirement: '>=4.0.0' - - match: - dependency_name: cross-env - version_requirement: '>=7.0.0' - - match: - dependency_name: decamelize - version_requirement: '>=4.0.0' - - match: - dependency_name: escape-string-regexp - version_requirement: '>=3.0.0' - - match: - dependency_name: eslint-plugin-jsdoc - version_requirement: '>=21.0.0' - - match: - dependency_name: gatsby - version_requirement: '>=2.19.50' - - match: - dependency_name: gatsby-plugin-catch-links - version_requirement: '>=2.2.0' - - match: - dependency_name: gatsby-plugin-page-creator - version_requirement: '>=2.2.0' - - match: - dependency_name: gatsby-plugin-react-helmet - version_requirement: '>=3.2.0' - - match: - dependency_name: gatsby-plugin-remove-trailing-slashes - version_requirement: '>=2.2.0' - - match: - dependency_name: gatsby-plugin-styled-components - version_requirement: '>=3.2.0' - - match: - dependency_name: gatsby-plugin-typescript - version_requirement: '>=2.3.0' - - match: - dependency_name: got - version_requirement: '>=10.0.0' - - match: - dependency_name: '@hapi/joi' - version_requirement: '>=17.0.0' - - match: - dependency_name: husky - version_requirement: '>=4.0.0' - - match: - dependency_name: lint-staged - version_requirement: '>=10.0.0' - - match: - dependency_name: nock - version_requirement: '>=12.0.0' - - match: - dependency_name: prom-client - version_requirement: '>=12.0.0' - - match: - dependency_name: react-error-overlay - version_requirement: '>=3.0.0' - - match: - dependency_name: sinon - version_requirement: '>=9.0.0' - - match: - dependency_name: start-server-and-test - version_requirement: '>=1.10.8' - - match: - dependency_name: xmldom - version_requirement: '>=0.3.0' - - # badge-maker package dependencies - - package_manager: 'javascript' - directory: '/badge-maker' - update_schedule: 'weekly' diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000000..5bfc40c0c027d --- /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-24-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 d408453af5325..0000000000000 --- a/.eslintignore +++ /dev/null @@ -1,6 +0,0 @@ -/api-docs/ -/build -/coverage -/__snapshots__ -/public -badge-maker/node_modules/ diff --git a/.eslintrc.yml b/.eslintrc.yml deleted file mode 100644 index ad85c6aaf16b6..0000000000000 --- a/.eslintrc.yml +++ /dev/null @@ -1,177 +0,0 @@ -extends: - - standard - - standard-react - - plugin:@typescript-eslint/recommended - - prettier - - prettier/@typescript-eslint - - prettier/standard - - prettier/react - - eslint:recommended - -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' - -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' - - - 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' - - - 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' - - # These should be disabled by eslint-config-prettier, but are not. - no-extra-semi: 'off' - - # Shields additions. - no-var: 'error' - prefer-const: 'error' - strict: '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' }] - - # 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 reccomended 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.md b/.github/ISSUE_TEMPLATE/1_Bug_report.md deleted file mode 100644 index ba0eb9463a6e1..0000000000000 --- a/.github/ISSUE_TEMPLATE/1_Bug_report.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -name: 🐛 Bug Report -about: Report errors and problems -labels: 'question' ---- - -Are you experiencing an issue with... - -- [ ] [shields.io](https://shields.io/#/) -- [ ] My own instance -- [ ] [badge-maker NPM package](https://www.npmjs.com/package/badge-maker) - -:beetle: **Description** - - - -:link: **Link to the badge** - - - -:bulb: **Possible Solution** - - - - diff --git a/.github/ISSUE_TEMPLATE/1_Bug_report.yml b/.github/ISSUE_TEMPLATE/1_Bug_report.yml new file mode 100644 index 0000000000000..417bc782df20b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1_Bug_report.yml @@ -0,0 +1,44 @@ +name: '🐛 Bug Report' +description: Report errors and problems +labels: [question] +body: + - type: dropdown + id: product + attributes: + label: Are you experiencing an issue with... + options: + - shields.io + - My own instance of shields + - badge-maker NPM package + validations: + required: true + + - type: textarea + id: description + attributes: + label: '🐞 Description' + description: A clear and concise description of the problem. + validations: + required: true + + - type: textarea + id: link + attributes: + label: '🔗 Link to the badge' + description: If you are reporting a problem with a specific badge on shields.io, provide a link to a badge demonstrating the error + validations: + required: false + + - type: textarea + id: possible-solution + attributes: + label: '💡 Possible Solution' + description: 'Optional: only if you have suggestions on a fix/reason for the bug' + validations: + required: false + + - 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/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..9d15e93783f3a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/3_Badge_request.yml @@ -0,0 +1,57 @@ +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/ISSUE_TEMPLATE/5_Support_question.md b/.github/ISSUE_TEMPLATE/5_Support_question.md deleted file mode 100644 index a4ddc25c5f861..0000000000000 --- a/.github/ISSUE_TEMPLATE/5_Support_question.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: ❓ Support Question -about: Ask a question about shields.io -labels: 'question' ---- - -:question: **Question** - - - - diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000000..c9ddf847ed222 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,7 @@ +contact_links: + - name: 🎨 Simple Icons + url: https://github.com/badges/shields/discussions/5369 + about: Please read this before posting a question about SimpleIcons + - name: ❓ Support Question + url: https://github.com/badges/shields/discussions + about: Ask a question about Shields.io diff --git a/.github/actions/core-tests/action.yml b/.github/actions/core-tests/action.yml new file mode 100644 index 0000000000000..9f5f1e4d31488 --- /dev/null +++ b/.github/actions/core-tests/action.yml @@ -0,0 +1,21 @@ +name: 'Core tests' +description: 'Run core and entrypoint tests' +runs: + using: 'composite' + steps: + - name: Core tests + if: always() + run: npm run test:core -- --reporter json --reporter-option 'output=reports/core.json' + shell: bash + + - name: Entrypoint tests + if: always() + run: npm run test:entrypoint -- --reporter json --reporter-option 'output=reports/entrypoint.json' + shell: bash + + - name: Write Markdown Summary + if: always() + run: | + node scripts/mocha2md.js Core reports/core.json >> $GITHUB_STEP_SUMMARY + node scripts/mocha2md.js Entrypoint reports/entrypoint.json >> $GITHUB_STEP_SUMMARY + shell: bash diff --git a/.github/actions/docusaurus-swizzled-warning/action.yml b/.github/actions/docusaurus-swizzled-warning/action.yml new file mode 100644 index 0000000000000..200f118c132db --- /dev/null +++ b/.github/actions/docusaurus-swizzled-warning/action.yml @@ -0,0 +1,12 @@ +name: 'docusaurus-theme-openapi swizzled component changes warning' +description: 'Check for changes in docusaurus-theme-openapi components which are swizzled and prints out a warning' +branding: + icon: 'alert-triangle' + color: 'yellow' +inputs: + github-token: + description: 'The GITHUB_TOKEN secret' + required: true +runs: + using: 'node20' + main: 'index.js' diff --git a/.github/actions/docusaurus-swizzled-warning/helpers.js b/.github/actions/docusaurus-swizzled-warning/helpers.js new file mode 100644 index 0000000000000..de458d726e779 --- /dev/null +++ b/.github/actions/docusaurus-swizzled-warning/helpers.js @@ -0,0 +1,101 @@ +/** + * Returns info about all files changed in a PR (max 3000 results) + * + * @param {object} client hydrated octokit ready to use for GitHub Actions + * @param {string} owner repo owner + * @param {string} repo repo name + * @param {number} pullNumber pull request number + * @returns {object[]} array of object that describe pr changed files - see https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#list-pull-requests-files + */ +export async function getAllFilesForPullRequest( + client, + owner, + repo, + pullNumber, +) { + const perPage = 100 // Max number of items per page + let page = 1 // Start with the first page + let allFiles = [] + while (true) { + const response = await client.rest.pulls.listFiles({ + owner, + repo, + pull_number: pullNumber, + per_page: perPage, + page, + }) + + if (response.data.length === 0) { + // Break the loop if no more results + break + } + + allFiles = allFiles.concat(response.data) + page++ // Move to the next page + } + return allFiles +} + +/** + * Get a list of files changed betwen two tags for a github repo + * + * @param {object} client hydrated octokit ready to use for GitHub Actions + * @param {string} owner repo owner + * @param {string} repo repo name + * @param {string} baseTag base tag + * @param {string} headTag head tag + * @returns {string[]} Array listing all changed files betwen the base tag and the head tag + */ +export async function getChangedFilesBetweenTags( + client, + owner, + repo, + baseTag, + headTag, +) { + const response = await client.rest.repos.compareCommits({ + owner, + repo, + base: baseTag, + head: headTag, + }) + + return response.data.files.map(file => file.filename) +} + +export function findKeyEndingWith(obj, ending) { + for (const key in obj) { + if (key.endsWith(ending)) { + return key + } + } +} + +/** + * Get large (>1MB) JSON file from git repo on at ref as a json object + * + * @param {object} client Hydrated octokit ready to use for GitHub Actions + * @param {string} owner Repo owner + * @param {string} repo Repo name + * @param {string} path Path of the file in repo relative to root directory + * @param {string} ref Git refrence (commit, branch, tag) + * @returns {string[]} Array listing all changed files betwen the base tag and the head tag + */ +export async function getLargeJsonAtRef(client, owner, repo, path, ref) { + const fileSha = ( + await client.rest.repos.getContent({ + owner, + repo, + path, + ref, + }) + ).data.sha + const fileBlob = ( + await client.rest.git.getBlob({ + owner, + repo, + file_sha: fileSha, + }) + ).data.content + return JSON.parse(Buffer.from(fileBlob, 'base64').toString()) +} diff --git a/.github/actions/docusaurus-swizzled-warning/index.js b/.github/actions/docusaurus-swizzled-warning/index.js new file mode 100644 index 0000000000000..53848070abdc3 --- /dev/null +++ b/.github/actions/docusaurus-swizzled-warning/index.js @@ -0,0 +1,146 @@ +import { getInput, info, setFailed } from '@actions/core' +import { getOctokit, context } from '@actions/github' +import { + getAllFilesForPullRequest, + getChangedFilesBetweenTags, + findKeyEndingWith, + getLargeJsonAtRef, +} from './helpers.js' + +async function run() { + try { + const token = getInput('github-token', { required: true }) + + const { pull_request: pr } = context.payload + if (!pr) { + throw new Error('Event payload missing `pull_request`') + } + + const client = getOctokit(token) + const packageName = 'docusaurus-theme-openapi' + const packageParentName = 'docusaurus-preset-openapi' + const overideComponents = ['Curl', 'Response'] + const messageTemplate = ` + + ` + + if ( + !['dependabot[bot]', 'dependabot-preview[bot]'].includes(pr.user.login) + ) { + return + } + const files = await getAllFilesForPullRequest( + client, + context.repo.owner, + context.repo.repo, + pr.number, + ) + + const file = files.filter(f => f.filename === 'package-lock.json')[0] + if (file === undefined) { + return + } + + const prCommitRefForFile = file.contents_url.split('ref=')[1] + const pkgLockNewJson = await getLargeJsonAtRef( + client, + context.repo.owner, + context.repo.repo, + file.filename, + prCommitRefForFile, + ) + const pkgLockOldJson = await getLargeJsonAtRef( + client, + context.repo.owner, + context.repo.repo, + file.filename, + 'master', + ) + + const oldVesionModuleKey = findKeyEndingWith( + pkgLockOldJson.packages, + `node_modules/${packageName}`, + ) + const newVesionModuleKey = findKeyEndingWith( + pkgLockNewJson.packages, + `node_modules/${packageName}`, + ) + let oldVersion = pkgLockOldJson.packages[oldVesionModuleKey].version + let newVersion = pkgLockNewJson.packages[newVesionModuleKey].version + + const oldVesionModuleKeyParent = findKeyEndingWith( + pkgLockOldJson.packages, + `node_modules/${packageParentName}`, + ) + const newVesionModuleKeyParent = findKeyEndingWith( + pkgLockNewJson.packages, + `node_modules/${packageParentName}`, + ) + const oldVersionParent = + pkgLockOldJson.packages[oldVesionModuleKeyParent].dependencies[ + packageName + ].substring(1) + const newVersionParent = + pkgLockNewJson.packages[newVesionModuleKeyParent].dependencies[ + packageName + ].substring(1) + + // if parent dependency is higher version then existing + // npm install will retrive the newer version from the parent dependency + if (oldVersionParent > oldVersion) { + oldVersion = oldVersionParent + } + if (newVersionParent > newVersion) { + newVersion = newVersionParent + } + info(`oldVersion=${oldVersion}`) + info(`newVersion=${newVersion}`) + + if (newVersion !== oldVersion) { + const pkgChangedFiles = await getChangedFilesBetweenTags( + client, + 'cloud-annotations', + 'docusaurus-openapi', + `v${oldVersion}`, + `v${newVersion}`, + ) + const changedComponents = overideComponents.filter( + componenet => + pkgChangedFiles.filter( + path => + path.includes('docusaurus-theme-openapi/src/theme') && + path.includes(componenet), + ).length > 0, + ) + const versionReport = ` + + ` + const changedComponentsReport = `
+ ⚠️ This PR contains changes to components of ${packageName} we've overridden +
+ We need to watch out for changes to the ${overideComponents.join( + ', ', + )} components +
Old version ${oldVersion}
New version ${newVersion}
Overide components changed ${changedComponents.join( + ', ', + )}
+ ` + const body = messageTemplate + versionReport + changedComponentsReport + await client.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body, + }) + + info('Found changes and posted comment, done.') + return + } + + info('No changes found, done.') + } catch (error) { + setFailed(error.message) + } +} + +run() diff --git a/.github/actions/docusaurus-swizzled-warning/package-lock.json b/.github/actions/docusaurus-swizzled-warning/package-lock.json new file mode 100644 index 0000000000000..4d05f48d3bb6f --- /dev/null +++ b/.github/actions/docusaurus-swizzled-warning/package-lock.json @@ -0,0 +1,249 @@ +{ + "name": "docusaurus-swizzled-warning", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "docusaurus-swizzled-warning", + "version": "0.0.0", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@actions/core": "^3.0.1", + "@actions/github": "^9.1.1" + } + }, + "node_modules/@actions/core": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.1.tgz", + "integrity": "sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA==", + "license": "MIT", + "dependencies": { + "@actions/exec": "^3.0.0", + "@actions/http-client": "^4.0.0" + } + }, + "node_modules/@actions/core/node_modules/@actions/http-client": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.0.tgz", + "integrity": "sha512-QuwPsgVMsD6qaPD57GLZi9sqzAZCtiJT8kVBCDpLtxhL5MydQ4gS+DrejtZZPdIYyB1e95uCK9Luyds7ybHI3g==", + "license": "MIT", + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^6.23.0" + } + }, + "node_modules/@actions/exec": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz", + "integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==", + "license": "MIT", + "dependencies": { + "@actions/io": "^3.0.2" + } + }, + "node_modules/@actions/github": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@actions/github/-/github-9.1.1.tgz", + "integrity": "sha512-tL5JbYOBZHc0ngEnCsaDcryUizIUIlQyIMwy1Wkx93H5HzbBJ7TbiPx2PnFjBwZW0Vh05JmfFZhecE6gglYegA==", + "license": "MIT", + "dependencies": { + "@actions/http-client": "^3.0.2", + "@octokit/core": "^7.0.6", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0", + "@octokit/request": "^10.0.7", + "@octokit/request-error": "^7.1.0", + "undici": "^6.23.0" + } + }, + "node_modules/@actions/http-client": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-3.0.2.tgz", + "integrity": "sha512-JP38FYYpyqvUsz+Igqlc/JG6YO9PaKuvqjM3iGvaLqFnJ7TFmcLyy2IDrY0bI0qCQug8E9K+elv5ZNfw62ZJzA==", + "license": "MIT", + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^6.23.0" + } + }, + "node_modules/@actions/io": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz", + "integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==", + "license": "MIT" + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.2.tgz", + "integrity": "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.7", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.7.tgz", + "integrity": "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.2", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "fast-content-type-parse": "^3.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", + "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "license": "Apache-2.0" + }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/undici": { + "version": "6.24.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.0.tgz", + "integrity": "sha512-lVLNosgqo5EkGqh5XUDhGfsMSoO8K0BAN0TyJLvwNRSl4xWGZlCVYsAIpa/OpA3TvmnM01GWcoKmc3ZWo5wKKA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" + } + } +} diff --git a/.github/actions/docusaurus-swizzled-warning/package.json b/.github/actions/docusaurus-swizzled-warning/package.json new file mode 100644 index 0000000000000..edd96f79d6c11 --- /dev/null +++ b/.github/actions/docusaurus-swizzled-warning/package.json @@ -0,0 +1,17 @@ +{ + "name": "docusaurus-swizzled-warning", + "version": "0.0.0", + "description": "", + "exports": "./index.js", + "type": "module", + "private": true, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "jNullj", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@actions/core": "^3.0.1", + "@actions/github": "^9.1.1" + } +} diff --git a/.github/actions/draft-release/Dockerfile b/.github/actions/draft-release/Dockerfile new file mode 100644 index 0000000000000..7df79800e709f --- /dev/null +++ b/.github/actions/draft-release/Dockerfile @@ -0,0 +1,8 @@ +FROM node:24-bullseye + +RUN apt-get update +RUN apt-get install -y jq +RUN apt-get install -y uuid-runtime +COPY entrypoint.sh /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/.github/actions/draft-release/action.yml b/.github/actions/draft-release/action.yml new file mode 100644 index 0000000000000..18b1f8dc4f8be --- /dev/null +++ b/.github/actions/draft-release/action.yml @@ -0,0 +1,5 @@ +name: 'draft-release' +description: 'Generate a changelog and propose a release PR' +runs: + using: 'docker' + image: 'Dockerfile' diff --git a/.github/actions/draft-release/entrypoint.sh b/.github/actions/draft-release/entrypoint.sh new file mode 100755 index 0000000000000..389099e5fd502 --- /dev/null +++ b/.github/actions/draft-release/entrypoint.sh @@ -0,0 +1,68 @@ +#!/bin/bash + +set -euxo pipefail + +# mark workspace dir as 'safe' +git config --system --add safe.directory '/github/workspace' + +# Find last server-YYYY-MM-DD tag +git fetch --unshallow --tags +LAST_TAG=$(git tag | grep server | tail -n 1) + +# Set up a git user +git config user.name "github-actions[bot]" +git config user.email "github-actions[bot]@users.noreply.github.com" + +# Find the marker in CHANGELOG.md +INSERT_POINT=$(grep -n "^\-\-\-$" CHANGELOG.md | cut -f1 -d:) +INSERT_POINT=$((INSERT_POINT+1)) + +# Generate a release name +RELEASE_NAME="server-$(date --rfc-3339=date)" + +# Assemble changelog entry +rm -f temp-changes.txt +touch temp-changes.txt +{ + echo "## $RELEASE_NAME" + echo "" + git log "$LAST_TAG"..HEAD --no-merges --oneline --pretty="format:- %s" --perl-regexp --author='^((?!dependabot).*)$' + echo $'\n- Dependency updates\n' +} >> temp-changes.txt +BASE_URL="https:\/\/github.com\/badges\/shields\/issues\/" +sed -r -i "s/\((\#)([0-9]+)\)$/\[\1\2\]\($BASE_URL\2\)/g" temp-changes.txt + +# Write the changelog +sed -i "${INSERT_POINT} r temp-changes.txt" CHANGELOG.md + +# Cleanup +rm temp-changes.txt + +# Run prettier (to ensure the markdown file doesn't fail CI) +npx prettier@$(cat package.json | jq -r .devDependencies.prettier) --write "CHANGELOG.md" + +# Generate a unique branch name +BRANCH_NAME="$RELEASE_NAME"-$(uuidgen | head -c 8) +git checkout -b "$BRANCH_NAME" + +# Commit + push changelog +git add CHANGELOG.md +git commit -m "Update Changelog" +git push origin "$BRANCH_NAME" + +# Submit a PR +TITLE="Changelog for Release $RELEASE_NAME" +PR_RESP=$(curl https://api.github.com/repos/"$REPO_NAME"/pulls \ + -X POST \ + -H "Authorization: token $GITHUB_TOKEN" \ + --data '{"title": "'"$TITLE"'", "body": "'"$TITLE"'", "head": "'"$BRANCH_NAME"'", "base": "master"}') + +# Add the 'release' label to the PR +PR_API_URL=$(echo "$PR_RESP" | jq -r ._links.issue.href) +curl -L \ + -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$PR_API_URL/labels" \ + -d '{"labels":["release"]}' diff --git a/.github/actions/integration-tests/action.yml b/.github/actions/integration-tests/action.yml new file mode 100644 index 0000000000000..9214aa6650265 --- /dev/null +++ b/.github/actions/integration-tests/action.yml @@ -0,0 +1,28 @@ +name: 'Integration tests' +description: 'Run integration tests' +inputs: + github-token: + description: 'The GITHUB_TOKEN secret' + required: true +runs: + using: 'composite' + steps: + - name: Migrate DB + if: always() + run: npm run migrate up + env: + POSTGRES_URL: postgresql://postgres:postgres@localhost:5432/ci_test + shell: bash + + - name: Integration Tests + if: always() + run: npm run test:integration -- --reporter json --reporter-option 'output=reports/integration-tests.json' + env: + GH_TOKEN: '${{ inputs.github-token }}' + POSTGRES_URL: postgresql://postgres:postgres@localhost:5432/ci_test + shell: bash + + - name: Write Markdown Summary + if: always() + run: node scripts/mocha2md.js Integration reports/integration-tests.json >> $GITHUB_STEP_SUMMARY + shell: bash diff --git a/.github/actions/package-tests/action.yml b/.github/actions/package-tests/action.yml new file mode 100644 index 0000000000000..8ddedc3d09cc3 --- /dev/null +++ b/.github/actions/package-tests/action.yml @@ -0,0 +1,26 @@ +name: 'Package tests' +description: 'Run package tests and check types' +runs: + using: 'composite' + steps: + - name: Tests + if: always() + run: npm run test:package -- --reporter json --reporter-option 'output=reports/package-tests.json' + shell: bash + + - name: Type Checks + if: always() + run: | + set -o pipefail + npm run check-types:package 2>&1 | tee reports/package-types.txt + shell: bash + + - name: Write Markdown Summary + if: always() + run: | + node scripts/mocha2md.js 'Package Tests' reports/package-tests.json >> $GITHUB_STEP_SUMMARY + echo '# Package Types' >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + cat reports/package-types.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + shell: bash diff --git a/.github/actions/service-tests/action.yml b/.github/actions/service-tests/action.yml new file mode 100644 index 0000000000000..91d3083a67250 --- /dev/null +++ b/.github/actions/service-tests/action.yml @@ -0,0 +1,98 @@ +name: 'Service tests' +description: 'Run tests for selected services' +inputs: + github-token: + description: 'The GITHUB_TOKEN secret' + required: true + curseforge-api-key: + description: 'The SERVICETESTS_CURSEFORGE_API_KEY secret' + required: false + default: '' + librariesio-tokens: + description: 'The SERVICETESTS_LIBRARIESIO_TOKENS secret' + required: false + default: '' + obs-user: + description: 'The SERVICETESTS_OBS_USER secret' + required: false + default: '' + obs-pass: + description: 'The SERVICETESTS_OBS_PASS secret' + required: false + default: '' + pepy-key: + description: 'The SERVICETESTS_PEPY_KEY secret' + required: false + default: '' + sl-insight-user-uuid: + description: 'The SERVICETESTS_SL_INSIGHT_USER_UUID secret' + required: false + default: '' + sl-insight-api-token: + description: 'The SERVICETESTS_SL_INSIGHT_API_TOKEN secret' + required: false + default: '' + twitch-client-id: + description: 'The SERVICETESTS_TWITCH_CLIENT_ID secret' + required: false + default: '' + twitch-client-secret: + description: 'The SERVICETESTS_TWITCH_CLIENT_SECRET secret' + required: false + default: '' + weblate-api-key: + description: 'The SERVICETESTS_WEBLATE_API_KEY secret' + required: false + default: '' + youtube-api-key: + description: 'The SERVICETESTS_YOUTUBE_API_KEY secret' + required: false + default: '' + +runs: + using: 'composite' + steps: + - name: Derive list of service tests to run + # Note: In this step we are using an intermediate env var instead of + # passing github.event.pull_request.title as an argument + # to prevent a shell injection attack. Further reading: + # https://securitylab.github.com/research/github-actions-untrusted-input/#exploitability-and-impact + # https://securitylab.github.com/research/github-actions-untrusted-input/#remediation + if: always() + env: + TITLE: ${{ github.event.pull_request.title }} + run: npm run test:services:pr:prepare "$TITLE" + shell: bash + + - name: Run service tests + if: always() + run: npm run test:services:pr:run -- --reporter json --reporter-option 'output=reports/service-tests.json' + shell: bash + env: + RETRY_COUNT: 3 + GH_TOKEN: '${{ inputs.github-token }}' + CURSEFORGE_API_KEY: '${{ inputs.curseforge-api-key }}' + LIBRARIESIO_TOKENS: '${{ inputs.librariesio-tokens }}' + OBS_USER: '${{ inputs.obs-user }}' + OBS_PASS: '${{ inputs.obs-pass }}' + PEPY_KEY: '${{ inputs.pepy-key }}' + REDDIT_CLIENT_ID: '${{ inputs.reddit-client-id }}' + REDDIT_CLIENT_SECRET: '${{ inputs.reddit-client-secret }}' + SL_INSIGHT_USER_UUID: '${{ inputs.sl-insight-user-uuid }}' + SL_INSIGHT_API_TOKEN: '${{ inputs.sl-insight-api-token }}' + TWITCH_CLIENT_ID: '${{ inputs.twitch-client-id }}' + TWITCH_CLIENT_SECRET: '${{ inputs.twitch-client-secret }}' + WEBLATE_API_KEY: '${{ inputs.weblate-api-key }}' + YOUTUBE_API_KEY: '${{ inputs.youtube-api-key }}' + + - name: Write Markdown Summary + if: always() + run: | + if test -f 'reports/service-tests.json'; then + echo '# Services' >> $GITHUB_STEP_SUMMARY + sed -e 's/^/- /' pull-request-services.log >> $GITHUB_STEP_SUMMARY + node scripts/mocha2md.js Report reports/service-tests.json >> $GITHUB_STEP_SUMMARY + else + echo 'No services found. Nothing to do.' >> $GITHUB_STEP_SUMMARY + fi + shell: bash diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 0000000000000..684c98b3c657e --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,49 @@ +name: 'Set up project' +description: 'Set up project' +inputs: + node-version: + description: 'Version Spec of the node version to use. Examples: 12.x, 10.15.1, >=10.15.0.' + required: false + node-version-file: + description: 'Path to a file containing the node version to use. Examples: .nvmrc, .node-version.' + required: false + default: '.node-version' + npm-version: + description: 'Version Spec of the npm version to use. Examples: 9.x, 10.2.3, >=10.1.0.' + required: false + default: '^11' + cypress: + description: 'Install Cypress binary (boolean)' + type: boolean + # 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. + required: false + default: false +runs: + using: 'composite' + steps: + - name: Install Node JS ${{ inputs.node-version }} + uses: actions/setup-node@v7 + with: + node-version: ${{ inputs.node-version }} + node-version-file: ${{ inputs.node-version-file }} + + - name: Install NPM ${{ inputs.npm-version }} + run: npm install -g npm@${{ inputs.npm-version }} + shell: bash + + - name: Install dependencies + if: ${{ inputs.cypress == 'false' }} + env: + CYPRESS_INSTALL_BINARY: 0 + run: | + echo "skipping cypress binary" + npm ci + shell: bash + + - name: Install dependencies (including cypress binary) + if: ${{ inputs.cypress == 'true' }} + run: | + echo "installing cypress binary" + npm ci + shell: bash diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000000..bebccbd5595da --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,74 @@ +version: 2 +updates: + # shields.io dependencies + - package-ecosystem: npm + directory: '/' + schedule: + interval: monthly + time: '12:00' + open-pull-requests-limit: 99 + rebase-strategy: disabled + ignore: + - dependency-name: 'simple-icons' + update-types: + - 'version-update:semver-major' + - 'version-update:semver-minor' + - 'version-update:semver-patch' + groups: + # All official @docusaurus/* packages should have the exact same version as @docusaurus/core. + # From https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#groups: + # "You cannot apply a single grouping set of rules to both version updates and security + # updates [...] you must define two, separately named, grouping sets of rules" + # See https://github.com/badges/shields/issues/10242 for more information. + docusaurus-version-updates: + applies-to: version-updates + patterns: + - '@docusaurus/*' + docusaurus-security-updates: + applies-to: security-updates + patterns: + - '@docusaurus/*' + react-version-updates: + applies-to: version-updates + patterns: + - 'react' + - 'react-dom' + react-security-updates: + applies-to: security-updates + patterns: + - 'react' + - 'react-dom' + + # badge-maker package dependencies + - package-ecosystem: npm + directory: '/badge-maker' + schedule: + interval: monthly + time: '12:00' + open-pull-requests-limit: 99 + rebase-strategy: disabled + + # GH actions + - package-ecosystem: 'github-actions' + # all composite actions must be individually listed here + # https://github.com/dependabot/dependabot-core/issues/6704 + directories: + - '/' + - '/.github/actions/core-tests' + - '/.github/actions/integration-tests' + - '/.github/actions/package-tests' + - '/.github/actions/service-tests' + - '/.github/actions/setup' + schedule: + interval: monthly + open-pull-requests-limit: 99 + rebase-strategy: disabled + + # docusaurus-swizzled-warning package dependencies + - package-ecosystem: npm + directory: '/.github/actions/docusaurus-swizzled-warning' + schedule: + interval: monthly + time: '12:00' + open-pull-requests-limit: 99 + rebase-strategy: disabled diff --git a/.github/probot.js b/.github/probot.js deleted file mode 100644 index 42e5e2e0839eb..0000000000000 --- a/.github/probot.js +++ /dev/null @@ -1,10 +0,0 @@ -on('pull_request.closed') - .filter(context => context.payload.pull_request.merged) - .filter( - context => - context.payload.pull_request.head.ref.slice(0, 11) !== 'dependabot/' - ) - .filter(context => context.payload.pull_request.base.ref === 'master') - .comment(`This pull request was merged to [{{ pull_request.base.ref }}]({{ repository.html_url }}/tree/{{ pull_request.base.ref }}) branch. This change is now waiting for deployment, which will usually happen within a few days. Stay tuned by joining our \`#ops\` channel on [Discord](https://discordapp.com/invite/HjJCwm5)! - -After deployment, changes are copied to [gh-pages]({{ repository.html_url }}/tree/gh-pages) branch: ![](https://img.shields.io/github/commit-status/{{ repository.full_name }}/gh-pages/{{ pull_request.merge_commit_sha }}.svg?label=deploy%20status)`) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000000..d0753d4a5810e --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,7 @@ + diff --git a/.github/scripts/cleanup-review-apps.sh b/.github/scripts/cleanup-review-apps.sh new file mode 100755 index 0000000000000..dd438aa00c558 --- /dev/null +++ b/.github/scripts/cleanup-review-apps.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +set -euxo pipefail + +apps=$(flyctl apps list --json | jq -r .[].ID | grep -E "pr-[0-9]+-badges-shields") || exit 0 + +for app in $apps +do + flyctl apps destroy "$app" -y +done diff --git a/.github/scripts/deploy-review-app.sh b/.github/scripts/deploy-review-app.sh new file mode 100755 index 0000000000000..51bc117c6c219 --- /dev/null +++ b/.github/scripts/deploy-review-app.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +set -euxo pipefail + +app="pr-$PR_NUMBER-badges-shields" +region="ewr" +org="shields-io" + +# Get PR JSON from the API +# This will fail if $PR_NUMBER is not a valid PR +pr_json=$(curl --fail "https://api.github.com/repos/badges/shields/pulls/$PR_NUMBER") + +# Checkout the PR branch +git config user.name "github-actions[bot]" +git config user.email "github-actions[bot]@users.noreply.github.com" +git fetch origin "pull/$PR_NUMBER/head:pr-$PR_NUMBER" +git checkout "pr-$PR_NUMBER" + +# If the app does not already exist, create it +if ! flyctl status --app "$app"; then + flyctl launch --no-deploy --copy-config --name "$app" --region "$region" --org "$org" --dockerfile ./Dockerfile + echo $SECRETS | tr " " "\n" | flyctl secrets import --app "$app" +fi + +# Deploy +flyctl deploy --app "$app" --regions "$region" +flyctl scale count 1 --app "$app" --yes + +# Post a comment on the PR +app_url=$(flyctl status --app "$app" --json | jq -r .Hostname) +comment_url=$(echo "$pr_json" | jq .comments_url -r) +curl "$comment_url" \ + -X POST \ + -H "Authorization: token $GITHUB_TOKEN" \ + --data "{\"body\":\"🚀 Updated review app: https://$app_url\"}" diff --git a/.github/workflows/auto-approve.yml b/.github/workflows/auto-approve.yml deleted file mode 100644 index b18c78296e36d..0000000000000 --- a/.github/workflows/auto-approve.yml +++ /dev/null @@ -1,10 +0,0 @@ -name: Auto approve -on: pull_request - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: chris48s/approve-bot@1.0.0 - with: - github-token: '${{ secrets.GITHUB_TOKEN }}' diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml new file mode 100644 index 0000000000000..307c250dc4a38 --- /dev/null +++ b/.github/workflows/build-docker-image.yml @@ -0,0 +1,28 @@ +name: Build Docker Image +on: + pull_request: + push: + branches: + - 'gh-readonly-queue/**' + +jobs: + build-docker-image: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Set Git Short SHA + run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV + + - name: Build + uses: docker/build-push-action@v7 + with: + context: . + push: false + tags: ghcr.io/badges/shields:pr-validation + build-args: | + version=${{ env.SHORT_SHA }} diff --git a/.github/workflows/cleanup-review-apps.yml b/.github/workflows/cleanup-review-apps.yml new file mode 100644 index 0000000000000..a37f5cd938c8e --- /dev/null +++ b/.github/workflows/cleanup-review-apps.yml @@ -0,0 +1,24 @@ +name: Cleanup Review Apps +on: + schedule: + - cron: '0 7 * * *' + # At 07:00, daily + workflow_dispatch: + +jobs: + cleanup-review-apps: + runs-on: ubuntu-latest + environment: 'Review Apps' + steps: + - uses: actions/checkout@v7 + - uses: superfly/flyctl-actions/setup-flyctl@master + + - name: install jq + run: | + sudo apt-get -qq update + sudo apt-get install -y jq + + - run: .github/scripts/cleanup-review-apps.sh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/.github/workflows/coveralls-code-coverage.yml b/.github/workflows/coveralls-code-coverage.yml new file mode 100644 index 0000000000000..eae8adf1b74b9 --- /dev/null +++ b/.github/workflows/coveralls-code-coverage.yml @@ -0,0 +1,69 @@ +name: Coveralls Code Coverage +on: + schedule: + - cron: '10 7 * * *' + # At 07:10, daily + workflow_dispatch: + +jobs: + coveralls-code-coverage: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: ci_test + options: >- + --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + + ports: + - 5432:5432 + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup + uses: ./.github/actions/setup + env: + NPM_CONFIG_ENGINE_STRICT: 'false' + + - name: Migrate DB + run: npm run migrate up + env: + POSTGRES_URL: postgresql://postgres:postgres@localhost:5432/ci_test + shell: bash + + - name: Coverage for Main Tests + run: npm run coverage:test + env: + GH_TOKEN: '${{ secrets.GH_PAT }}' + POSTGRES_URL: postgresql://postgres:postgres@localhost:5432/ci_test + shell: bash + + - name: Coverage for Service Tests + run: npm run coverage:test:services + continue-on-error: true + env: + RETRY_COUNT: 3 + GH_TOKEN: '${{ secrets.GH_PAT }}' + CURSEFORGE_API_KEY: '${{ secrets.SERVICETESTS_CURSEFORGE_API_KEY }}' + LIBRARIESIO_TOKENS: '${{ secrets.SERVICETESTS_LIBRARIESIO_TOKENS }}' + OBS_USER: '${{ secrets.SERVICETESTS_OBS_USER }}' + OBS_PASS: '${{ secrets.SERVICETESTS_OBS_PASS }}' + PEPY_KEY: '${{ secrets.SERVICETESTS_PEPY_KEY }}' + REDDIT_CLIENT_ID: '${{ secrets.SERVICETESTS_REDDIT_CLIENT_ID }}' + REDDIT_CLIENT_SECRET: '${{ secrets.SERVICETESTS_REDDIT_CLIENT_SECRET }}' + SL_INSIGHT_USER_UUID: '${{ secrets.SERVICETESTS_SL_INSIGHT_USER_UUID }}' + SL_INSIGHT_API_TOKEN: '${{ secrets.SERVICETESTS_SL_INSIGHT_API_TOKEN }}' + TWITCH_CLIENT_ID: '${{ secrets.SERVICETESTS_TWITCH_CLIENT_ID }}' + TWITCH_CLIENT_SECRET: '${{ secrets.SERVICETESTS_TWITCH_CLIENT_SECRET }}' + WEBLATE_API_KEY: '${{ secrets.SERVICETESTS_WEBLATE_API_KEY }}' + YOUTUBE_API_KEY: '${{ secrets.SERVICETESTS_YOUTUBE_API_KEY }}' + shell: bash + + - name: Coveralls GitHub Action + uses: coverallsapp/github-action@v2 diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml new file mode 100644 index 0000000000000..486ae10219bec --- /dev/null +++ b/.github/workflows/create-release.yml @@ -0,0 +1,73 @@ +name: Create Release + +on: + pull_request: + types: [closed] + +permissions: + contents: write + packages: write + +jobs: + create-release: + if: | + github.event_name == 'pull_request' && + github.event.action == 'closed' && + github.event.pull_request.merged == true && + contains(github.event.pull_request.labels.*.name, 'release') + + runs-on: ubuntu-latest + + steps: + - name: Get current date + id: date + run: echo "::set-output name=date::$(date --rfc-3339=date)" + + - name: Checkout branch "master" + uses: actions/checkout@v7 + with: + ref: 'master' + + - name: Tag release in GitHub + uses: tvdias/github-tagger@v0.0.2 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + tag: server-${{ steps.date.outputs.date }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + with: + platforms: linux/amd64,linux/arm64 + + - name: Login to DockerHub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push snapshot release to DockerHub + uses: docker/build-push-action@v7 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + tags: shieldsio/shields:server-${{ steps.date.outputs.date }} + build-args: | + version=server-${{ steps.date.outputs.date }} + + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push snapshot release to GHCR + uses: docker/build-push-action@v7 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + tags: ghcr.io/badges/shields:server-${{ steps.date.outputs.date }} + build-args: | + version=server-${{ steps.date.outputs.date }} diff --git a/.github/workflows/daily-tests.yml b/.github/workflows/daily-tests.yml new file mode 100644 index 0000000000000..28d7a1f50bfc4 --- /dev/null +++ b/.github/workflows/daily-tests.yml @@ -0,0 +1,70 @@ +name: Run Daily Tests +on: + schedule: + - cron: '45 3 * * *' + # At 03:45, daily + workflow_dispatch: + +jobs: + daily-tests: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: ci_test + options: >- + --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + + ports: + - 5432:5432 + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup + uses: ./.github/actions/setup + + - name: Core tests + if: always() + uses: ./.github/actions/core-tests + + - name: Package tests + if: always() + uses: ./.github/actions/package-tests + + - name: Integration Tests (with PAT) + if: always() + uses: ./.github/actions/integration-tests + with: + github-token: '${{ secrets.GH_PAT }}' + + - name: Run Service tests + run: npm run test:services -- --reporter json --reporter-option 'output=reports/service-tests.json' + if: always() + env: + RETRY_COUNT: 3 + GH_TOKEN: '${{ secrets.GH_PAT }}' + CURSEFORGE_API_KEY: '${{ secrets.SERVICETESTS_CURSEFORGE_API_KEY }}' + LIBRARIESIO_TOKENS: '${{ secrets.SERVICETESTS_LIBRARIESIO_TOKENS }}' + OBS_USER: '${{ secrets.SERVICETESTS_OBS_USER }}' + OBS_PASS: '${{ secrets.SERVICETESTS_OBS_PASS }}' + PEPY_KEY: '${{ secrets.SERVICETESTS_PEPY_KEY }}' + REDDIT_CLIENT_ID: '${{ secrets.SERVICETESTS_REDDIT_CLIENT_ID }}' + REDDIT_CLIENT_SECRET: '${{ secrets.SERVICETESTS_REDDIT_CLIENT_SECRET }}' + SL_INSIGHT_USER_UUID: '${{ secrets.SERVICETESTS_SL_INSIGHT_USER_UUID }}' + SL_INSIGHT_API_TOKEN: '${{ secrets.SERVICETESTS_SL_INSIGHT_API_TOKEN }}' + TWITCH_CLIENT_ID: '${{ secrets.SERVICETESTS_TWITCH_CLIENT_ID }}' + TWITCH_CLIENT_SECRET: '${{ secrets.SERVICETESTS_TWITCH_CLIENT_SECRET }}' + WEBLATE_API_KEY: '${{ secrets.SERVICETESTS_WEBLATE_API_KEY }}' + YOUTUBE_API_KEY: '${{ secrets.SERVICETESTS_YOUTUBE_API_KEY }}' + + - name: Write Service Tests Markdown Summary + if: always() + run: | + echo '# Services' >> $GITHUB_STEP_SUMMARY + node scripts/mocha2md.js Report reports/service-tests.json >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml new file mode 100644 index 0000000000000..95c1ba03df9a2 --- /dev/null +++ b/.github/workflows/danger.yml @@ -0,0 +1,27 @@ +name: Danger +on: + pull_request_target: + types: [opened, reopened, synchronize] + +permissions: + checks: write + pull-requests: write + statuses: write + +jobs: + danger: + runs-on: ubuntu-latest + if: github.actor != 'dependabot[bot]' && github.actor != 'repo-ranger[bot]' + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup + uses: ./.github/actions/setup + + - name: Danger + run: npm run danger ci + env: + # https://github.com/gatsbyjs/gatsby/pull/11555 + NODE_ENV: test + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 0000000000000..307ac6b125684 --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,30 @@ +name: Deploy Documentation +on: + push: + branches: + - master + +permissions: + contents: write + +jobs: + deploy-docs: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Setup + uses: ./.github/actions/setup + + - name: Build + run: npm run build-docs + + - name: Deploy + uses: JamesIves/github-pages-deploy-action@v4 + with: + branch: gh-pages + folder: api-docs + clean: true diff --git a/.github/workflows/deploy-review-app.yml b/.github/workflows/deploy-review-app.yml new file mode 100644 index 0000000000000..51646df2b58ca --- /dev/null +++ b/.github/workflows/deploy-review-app.yml @@ -0,0 +1,47 @@ +name: Create/Update Review App +on: + workflow_dispatch: + inputs: + pr_number: + description: 'PR Number to deploy e.g: 1234' + required: true + +permissions: + pull-requests: write + +jobs: + deploy-review-app: + runs-on: ubuntu-latest + environment: 'Review Apps' + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: superfly/flyctl-actions/setup-flyctl@master + + - name: install jq + run: | + sudo apt-get -qq update + sudo apt-get install -y jq + + - run: .github/scripts/deploy-review-app.sh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + PR_NUMBER: ${{ github.event.inputs.pr_number }} + # credentials to set when we create the review app + SECRETS: | + GH_TOKEN=${{ secrets.GH_PAT }} + CURSEFORGE_API_KEY=${{ secrets.SERVICETESTS_CURSEFORGE_API_KEY }} + LIBRARIESIO_TOKENS=${{ secrets.SERVICETESTS_LIBRARIESIO_TOKENS }} + OBS_USER=${{ secrets.SERVICETESTS_OBS_USER }} + OBS_PASS=${{ secrets.SERVICETESTS_OBS_PASS }} + PEPY_KEY=${{ secrets.SERVICETESTS_PEPY_KEY }} + REDDIT_CLIENT_ID=${{ secrets.SERVICETESTS_REDDIT_CLIENT_ID }} + REDDIT_CLIENT_SECRET=${{ secrets.SERVICETESTS_REDDIT_CLIENT_SECRET }} + SL_INSIGHT_API_TOKEN=${{ secrets.SERVICETESTS_SL_INSIGHT_USER_UUID }} + SL_INSIGHT_USER_UUID=${{ secrets.SERVICETESTS_SL_INSIGHT_API_TOKEN }} + TWITCH_CLIENT_ID=${{ secrets.SERVICETESTS_TWITCH_CLIENT_ID }} + TWITCH_CLIENT_SECRET=${{ secrets.SERVICETESTS_TWITCH_CLIENT_SECRET }} + WEBLATE_API_KEY=${{ secrets.SERVICETESTS_WEBLATE_API_KEY }} + YOUTUBE_API_KEY=${{ secrets.SERVICETESTS_YOUTUBE_API_KEY }} diff --git a/.github/workflows/docusaurus-swizzled-warning.yml b/.github/workflows/docusaurus-swizzled-warning.yml new file mode 100644 index 0000000000000..a3422d40c37b6 --- /dev/null +++ b/.github/workflows/docusaurus-swizzled-warning.yml @@ -0,0 +1,22 @@ +name: Docusaurus swizzled component changes warning +on: + pull_request: + types: [opened] + +permissions: + pull-requests: write + +jobs: + docusaurus-swizzled-warning: + runs-on: ubuntu-latest + if: github.actor == 'dependabot[bot]' + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Install action dependencies + run: cd .github/actions/docusaurus-swizzled-warning && npm ci + + - uses: ./.github/actions/docusaurus-swizzled-warning + with: + github-token: '${{ secrets.GITHUB_TOKEN }}' diff --git a/.github/workflows/draft-release.yml b/.github/workflows/draft-release.yml new file mode 100644 index 0000000000000..4382d1b86c1c1 --- /dev/null +++ b/.github/workflows/draft-release.yml @@ -0,0 +1,23 @@ +name: Draft Release +on: + schedule: + - cron: '0 1 15 * *' + # At 01:00 on the 15th day of every month (~two-week delay after dependabot) + workflow_dispatch: + +permissions: + pull-requests: write + contents: write + +jobs: + draft-release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Draft Release + uses: ./.github/actions/draft-release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/enforce-dependency-review.yml b/.github/workflows/enforce-dependency-review.yml new file mode 100644 index 0000000000000..39b5e88391a3e --- /dev/null +++ b/.github/workflows/enforce-dependency-review.yml @@ -0,0 +1,13 @@ +name: 'Dependency Review' +on: + pull_request: + types: [opened, reopened, synchronize] + +jobs: + enforce-dependency-review: + runs-on: ubuntu-latest + steps: + - name: 'Checkout Repository' + uses: actions/checkout@v7 + - name: 'Dependency Review' + uses: actions/dependency-review-action@v5 diff --git a/.github/workflows/publish-docker-next.yml b/.github/workflows/publish-docker-next.yml new file mode 100644 index 0000000000000..f45484194417b --- /dev/null +++ b/.github/workflows/publish-docker-next.yml @@ -0,0 +1,56 @@ +name: Build and Publish Next Docker Image +on: + push: + branches: + - master + +permissions: + packages: write + +jobs: + publish-docker-next: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to DockerHub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set Git Short SHA + run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV + + - name: Build and push to DockerHub + id: docker_build_push + uses: docker/build-push-action@v7 + with: + context: . + push: true + tags: shieldsio/shields:next + build-args: | + version=${{ env.SHORT_SHA }} + + - name: Output Image Digest + run: echo ${{ steps.docker_build_push.outputs.digest }} >> $GITHUB_STEP_SUMMARY + + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push to GHCR + uses: docker/build-push-action@v7 + with: + context: . + push: true + tags: ghcr.io/badges/shields:next + build-args: | + version=${{ env.SHORT_SHA }} diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml new file mode 100644 index 0000000000000..13a0b074bdd97 --- /dev/null +++ b/.github/workflows/test-docs.yml @@ -0,0 +1,17 @@ +name: Test Documentation +on: + pull_request: + types: [opened, reopened, synchronize] + +jobs: + test-docs: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup + uses: ./.github/actions/setup + + - name: Build + run: npm run build-docs diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml new file mode 100644 index 0000000000000..b85a78c7f7e66 --- /dev/null +++ b/.github/workflows/test-e2e.yml @@ -0,0 +1,48 @@ +name: E2E +on: + pull_request: + types: [opened, reopened, synchronize] + push: + branches-ignore: + - 'gh-pages' + - 'dependabot/**' + +jobs: + test-e2e: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Cache Cypress binary + id: cache-cypress + uses: actions/cache@v6 + env: + cache-name: cache-cypress + with: + path: ~/.cache/Cypress + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json', 'cypress/**') }} + + - name: Setup + uses: ./.github/actions/setup + with: + cypress: true + + - name: Run tests + env: + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + run: npm run e2e + + - name: Archive videos + if: always() + uses: actions/upload-artifact@v7 + with: + name: videos + path: cypress/videos + + - name: Archive screenshots + if: always() + uses: actions/upload-artifact@v7 + with: + name: screenshots + path: cypress/screenshots diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml new file mode 100644 index 0000000000000..363c0df9ccb91 --- /dev/null +++ b/.github/workflows/test-integration.yml @@ -0,0 +1,46 @@ +name: Integration +on: + pull_request: + types: [opened, reopened, synchronize] + push: + branches-ignore: + - 'gh-pages' + - 'dependabot/**' + +jobs: + test-integration: + runs-on: ubuntu-latest + env: + PAT_EXISTS: ${{ secrets.GH_PAT != '' }} + + services: + postgres: + image: postgres + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: ci_test + options: >- + --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + + ports: + - 5432:5432 + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup + uses: ./.github/actions/setup + + - name: Integration Tests (with PAT) + if: ${{ env.PAT_EXISTS == 'true' }} + uses: ./.github/actions/integration-tests + with: + github-token: '${{ secrets.GH_PAT }}' + + - name: Integration Tests (with workflow token) + if: ${{ env.PAT_EXISTS == 'false' }} + uses: ./.github/actions/integration-tests + with: + github-token: '${{ secrets.GITHUB_TOKEN }}' diff --git a/.github/workflows/test-lint.yml b/.github/workflows/test-lint.yml new file mode 100644 index 0000000000000..ef4c1fe3944f1 --- /dev/null +++ b/.github/workflows/test-lint.yml @@ -0,0 +1,26 @@ +name: Lint +on: + pull_request: + types: [opened, reopened, synchronize] + push: + branches-ignore: + - 'gh-pages' + - 'dependabot/**' + +jobs: + test-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup + uses: ./.github/actions/setup + + - name: ESLint + if: always() + run: npm run lint + + - name: 'Prettier check (quick fix: `npm run prettier`)' + if: always() + run: npm run prettier:check diff --git a/.github/workflows/test-main.yml b/.github/workflows/test-main.yml new file mode 100644 index 0000000000000..e0857a1354c17 --- /dev/null +++ b/.github/workflows/test-main.yml @@ -0,0 +1,26 @@ +name: Main +on: + pull_request: + types: [opened, reopened, synchronize] + push: + branches-ignore: + - 'gh-pages' + - 'dependabot/**' + +jobs: + test-main: + strategy: + matrix: + os: ['ubuntu-latest', 'windows-latest'] + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup + uses: ./.github/actions/setup + + - name: Core tests + uses: ./.github/actions/core-tests diff --git a/.github/workflows/test-package-cli.yml b/.github/workflows/test-package-cli.yml new file mode 100644 index 0000000000000..b9bddaf78355d --- /dev/null +++ b/.github/workflows/test-package-cli.yml @@ -0,0 +1,40 @@ +name: Package CLI +on: + pull_request: + types: [opened, reopened, synchronize] + push: + branches-ignore: + - 'gh-pages' + - 'dependabot/**' + +# Smoke test (render a badge with the CLI) with only the package +# dependencies installed. + +jobs: + test-package-cli: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - node: '22' + - node: '24' + - node: '26' + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Install Node JS ${{ inputs.node-version }} + uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node }} + + - name: Install dependencies + run: | + cd badge-maker + npm install + npm link + + - name: Render a badge with the CLI + run: | + cd badge-maker + badge cactus grown :green @flat diff --git a/.github/workflows/test-package-lib.yml b/.github/workflows/test-package-lib.yml new file mode 100644 index 0000000000000..e6a3666b40f77 --- /dev/null +++ b/.github/workflows/test-package-lib.yml @@ -0,0 +1,38 @@ +name: Package Library +on: + pull_request: + types: [opened, reopened, synchronize] + push: + branches-ignore: + - 'gh-pages' + - 'dependabot/**' + +jobs: + test-package-lib: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - node: '22' + npm: '^11' + engine-strict: 'false' + - node: '24' + npm: '^11' + engine-strict: 'true' + - node: '26' + npm: '^12' + engine-strict: 'false' + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup + uses: ./.github/actions/setup + with: + node-version: ${{ matrix.node }} + npm-version: ${{ matrix.npm }} + env: + NPM_CONFIG_ENGINE_STRICT: ${{ matrix.engine-strict }} + + - name: Package tests + uses: ./.github/actions/package-tests diff --git a/.github/workflows/test-services.yml b/.github/workflows/test-services.yml new file mode 100644 index 0000000000000..104aa348e6162 --- /dev/null +++ b/.github/workflows/test-services.yml @@ -0,0 +1,47 @@ +name: Services +on: + pull_request: + types: [opened, edited, reopened, synchronize] + push: + branches: + - 'gh-readonly-queue/**' + +concurrency: + group: services-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test-services: + if: github.event_name == 'push' || github.event.action != 'edited' || github.event.changes.title + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup + uses: ./.github/actions/setup + + - name: Service tests (triggered from local branch) + if: github.event.pull_request.head.repo.full_name == github.repository + uses: ./.github/actions/service-tests + with: + github-token: '${{ secrets.GH_PAT }}' + curseforge-api-key: '${{ secrets.SERVICETESTS_CURSEFORGE_API_KEY }}' + librariesio-tokens: '${{ secrets.SERVICETESTS_LIBRARIESIO_TOKENS }}' + obs-user: '${{ secrets.SERVICETESTS_OBS_USER }}' + obs-pass: '${{ secrets.SERVICETESTS_OBS_PASS }}' + pepy-key: '${{ secrets.SERVICETESTS_PEPY_KEY }}' + reddit-client-id: '${{ secrets.SERVICETESTS_REDDIT_CLIENT_ID }}' + reddit-client-secret: '${{ secrets.SERVICETESTS_REDDIT_CLIENT_SECRET }}' + sl-insight-user-uuid: '${{ secrets.SERVICETESTS_SL_INSIGHT_USER_UUID }}' + sl-insight-api-token: '${{ secrets.SERVICETESTS_SL_INSIGHT_API_TOKEN }}' + twitch-client-id: '${{ secrets.SERVICETESTS_TWITCH_CLIENT_ID }}' + twitch-client-secret: '${{ secrets.SERVICETESTS_TWITCH_CLIENT_SECRET }}' + weblate-api-key: '${{ secrets.SERVICETESTS_WEBLATE_API_KEY }}' + youtube-api-key: '${{ secrets.SERVICETESTS_YOUTUBE_API_KEY }}' + + - name: Service tests (triggered from fork) + if: github.event.pull_request.head.repo.full_name != github.repository + uses: ./.github/actions/service-tests + with: + github-token: '${{ secrets.GITHUB_TOKEN }}' diff --git a/.github/workflows/update-github-api.yml b/.github/workflows/update-github-api.yml new file mode 100644 index 0000000000000..f91f4a71f0931 --- /dev/null +++ b/.github/workflows/update-github-api.yml @@ -0,0 +1,31 @@ +name: Update GitHub API Version +on: + schedule: + - cron: '0 7 * * 6' + # At 07:00 on Saturday + workflow_dispatch: + +permissions: + pull-requests: write + contents: write + +jobs: + update-github-api: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup + uses: ./.github/actions/setup + + - name: Check for new GitHub API version + run: node scripts/update-github-api.js + + - name: Create Pull Request if config has changed + uses: peter-evans/create-pull-request@v8 + with: + token: '${{ secrets.GITHUB_TOKEN }}' + commit-message: Update GitHub API Version + title: Update [GitHub] API Version + branch-suffix: random diff --git a/.github/workflows/update-simple-icons.yml b/.github/workflows/update-simple-icons.yml new file mode 100644 index 0000000000000..1bf51dbe7eea4 --- /dev/null +++ b/.github/workflows/update-simple-icons.yml @@ -0,0 +1,44 @@ +name: Update simple-icons +on: + schedule: + - cron: '0 12 * * 5' + # At 12:00 on Friday + workflow_dispatch: + +permissions: + pull-requests: write + contents: write + +jobs: + update-simple-icons: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup + uses: ./.github/actions/setup + with: + node-version-file: .node-version + + - name: Update simple-icons + run: npm install simple-icons@latest --save-exact + + - name: Read simple-icons version + id: simple_icons_version + run: | + version=$(node -p "require('./package-lock.json').packages['node_modules/simple-icons'].version") + echo "version=${version}" >> "$GITHUB_OUTPUT" + + - name: Create Pull Request if simple-icons has changed + # create-pull-request only opens or updates a PR when the install leaves a git diff. + uses: peter-evans/create-pull-request@v8 + with: + token: '${{ secrets.GITHUB_TOKEN }}' + commit-message: Update simple-icons to ${{ steps.simple_icons_version.outputs.version }} + title: Update simple-icons to ${{ steps.simple_icons_version.outputs.version }} + body: | + Release: https://github.com/simple-icons/simple-icons/releases/tag/${{ steps.simple_icons_version.outputs.version }} + branch: update-simple-icons + draft: always-true + delete-branch: true diff --git a/.gitignore b/.gitignore index 4f1faad2b1729..047d375f25a19 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,10 @@ -# Project-specific files -/Verdana.ttf -/analytics.json -/coverage.html -/redis -/ServerScript -/private -/index.html -/shields.env -badge-maker/package-lock.json - # Folder view configuration files .DS_Store Desktop.ini -# Thumbnail cache files -._* -Thumbs.db - # Visual Studio Code User Settings .vscode/settings.json +*.code-workspace # eclipse .project @@ -31,71 +17,21 @@ Thumbs.db # Jetbrains /.idea -# Created by https://www.gitignore.io/api/node - -### Node ### -# Logs -logs -*.log - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - # Coverage directory used by tools like istanbul coverage -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (http://nodejs.org/api/addons.html) -build/Release - # Dependency directories node_modules/ -jspm_packages/ -# Typescript v1 declaration files -typings/ - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity +# Node logs produced by profiling +logs +*.log -# dotenv environment variables file -.env +# Lock file for badge-maker +badge-maker/package-lock.json # Temporary build artifacts. -/build -.next -badge-examples.json -supported-features.json -service-definitions.yml +frontend/categories/*.yaml # Local runtime configuration. /config/local*.yml @@ -103,10 +39,6 @@ service-definitions.yml # Template for the local runtime configuration. !/config/local*.template.yml -# Gatsby -/.cache -/public - # Cypress /cypress/videos/ /cypress/screenshots/ @@ -116,3 +48,11 @@ service-definitions.yml # Flamebearer flamegraph.html + +# config file for node-pg-migrate +migrations-config.json + +# Frontend/Docusaurus +frontend/.docusaurus +frontend/.cache-loader +/public diff --git a/.gitpod.yml b/.gitpod.yml deleted file mode 100644 index f583f6fb7c96c..0000000000000 --- a/.gitpod.yml +++ /dev/null @@ -1,27 +0,0 @@ -ports: - - port: 8080 - onOpen: open-preview -tasks: - - init: npm ci && npm run build - command: node server 8080 0.0.0.0 -github: - prebuilds: - # enable for the master/default branch (defaults to true) - master: true - # enable for all branches in this repo (defaults to false) - branches: false - # enable for pull requests coming from this repo (defaults to true) - pullRequests: true - # enable for pull requests coming from forks (defaults to false) - pullRequestsFromForks: true - # add a check to pull requests (defaults to true) - addCheck: true - # add a "Review in Gitpod" button as a comment to pull requests (defaults to false) - addComment: false - # add a "Review in Gitpod" button to the pull request's description (defaults to false) - addBadge: false - # add a label once the prebuild is ready to pull requests (defaults to false) - addLabel: false -vscode: - extensions: - - esbenp.prettier-vscode@1.7.0:EbyqFvwe32qQBptkOgfQpQ== diff --git a/.mocharc-frontend.yml b/.mocharc-frontend.yml deleted file mode 100644 index 8a85e65d938f1..0000000000000 --- a/.mocharc-frontend.yml +++ /dev/null @@ -1,6 +0,0 @@ -reporter: mocha-env-reporter -require: - - '@babel/polyfill' - - '@babel/register' - - mocha-yaml-loader - - frontend/mocha-ignore-pngs diff --git a/.mocharc.yml b/.mocharc.yml deleted file mode 100644 index 41348198be1e5..0000000000000 --- a/.mocharc.yml +++ /dev/null @@ -1 +0,0 @@ -reporter: mocha-env-reporter diff --git a/.node-version b/.node-version new file mode 100644 index 0000000000000..a45fd52cc5891 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24 diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000000000..7dc0c227e25a1 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +engine-strict=true +strict-peer-deps=true diff --git a/.nycrc-frontend.json b/.nycrc-frontend.json deleted file mode 100644 index 447812c5504b5..0000000000000 --- a/.nycrc-frontend.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "reporter": ["lcov"], - "all": false, - "silent": true, - "clean": false, - "sourceMap": false, - "instrument": false, - "include": ["frontend/**/*.js"], - "exclude": ["**/*.spec.js", "**/mocha-*.js"] -} diff --git a/.nycrc.json b/.nycrc.json deleted file mode 100644 index 844132eb864f7..0000000000000 --- a/.nycrc.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "reporter": ["lcov"], - "all": true, - "silent": true, - "clean": false, - "exclude": [ - "**/*.spec.js", - "**/*.integration.js", - "**/test-helpers.js", - "**/*-test-helpers.js", - "**/*-fixtures.js", - "**/mocha-*.js", - "dangerfile.js", - "gatsby-*.js", - "core/service-test-runner", - "core/got-test-client.js", - "services/**/*.tester.js", - "services/test-validators.js", - "services/tester.js", - "core/base-service/loader-test-fixtures", - "scripts", - "coverage", - "build", - ".github" - ] -} diff --git a/.prettierignore b/.prettierignore index 66e7a5e5bf2eb..4f53862f51df1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,14 +1,7 @@ -package.json package-lock.json /__snapshots__ -/.next -/.cache /api-docs -/build -/public +public /coverage -private/*.json -/.nyc_output -analytics.json -supported-features.json -service-definitions.yml +frontend/.docusaurus +frontend/categories diff --git a/.prettierrc.yml b/.prettierrc.yml index d894f63007abe..94038ccb9ce86 100644 --- a/.prettierrc.yml +++ b/.prettierrc.yml @@ -1,5 +1,6 @@ semi: false singleQuote: true -trailingComma: es5 bracketSpacing: true endOfLine: lf +arrowParens: avoid +proseWrap: never diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 561abc5bb0180..d7df89c9cd0d8 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,7 +1,3 @@ { - "recommendations": [ - "esbenp.prettier-vscode", - "EditorConfig.EditorConfig", - "dbaeumer.vscode-eslint" - ] + "recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] } diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000000..1aa74790e8bf7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,947 @@ +# Changelog + +Note: this changelog is for the shields.io server. The changelog for the badge-maker NPM package is at https://github.com/badges/shields/blob/master/badge-maker/CHANGELOG.md + +--- + +## server-2026-07-23 + +- [readthedocs] Move to readthedocs API v3 [#11995](https://github.com/badges/shields/issues/11995) +- Remove decamelize and humanize-string Dependabot exclusions [#12032](https://github.com/badges/shields/issues/12032) +- [GithubIssues GithubPullRequests] Add draft filters without changing unfiltered counts [#12026](https://github.com/badges/shields/issues/12026) +- [ChromeWebStoreUsers] Fix 1M+ counts rendering as "12000,000" not "12M" [#12028](https://github.com/badges/shields/issues/12028) +- [GitHub] Store accepted token scopes [#11915](https://github.com/badges/shields/issues/11915) +- Migrate [Jenkins] tests and examples from Eclipse to FreeBSD [#12016](https://github.com/badges/shields/issues/12016) +- [Buildbot] Add Buildbot build status badge [#11938](https://github.com/badges/shields/issues/11938) +- Fix [Symfony] tests [#12017](https://github.com/badges/shields/issues/12017) +- Fix [Codecov] tests [#12019](https://github.com/badges/shields/issues/12019) +- Fix [Codacy] tests [#12018](https://github.com/badges/shields/issues/12018) +- Update simple-icons to 16.26.0 [#12020](https://github.com/badges/shields/issues/12020) +- Dependency updates + +## server-2026-07-15 + +**Important notice:** + +- The project was switched to MIT and Apache 2.0 licenses. See our [blog post](https://shields.io/blog/mit-apache-license) for more information. +- Node.js 22 is now the minimum supported version to run Shields.io. + +**Retired badges:** + +- Retire [Cirrus] badges [#11924](https://github.com/badges/shields/issues/11924) + +Other changes: + +- Add [GithubCheckSuites] badge and update [GithubCheckRuns] [#11839](https://github.com/badges/shields/issues/11839) +- Add an answered/unanswered variant of [GitHubDiscussions] [#11951](https://github.com/badges/shields/issues/11951) +- Make the [AzureDevops] build badge send the PAT so it works on private projects [#11945](https://github.com/badges/shields/issues/11945) +- Support multiple licenses in [NpmLicense] [#11956](https://github.com/badges/shields/issues/11956) +- Add [Macports] package version badge [#11936](https://github.com/badges/shields/issues/11936) +- Add [WingetReleaseDate] badge [#11919](https://github.com/badges/shields/issues/11919) +- Add OpenGraph image [#11987](https://github.com/badges/shields/issues/11987) +- Remake Shields.io favicon [#11947](https://github.com/badges/shields/issues/11947) +- Stop disallowing unknown keys for [Endpoint] badges [#11942](https://github.com/badges/shields/issues/11942) +- Dependency updates + +## server-2026-06-15 + +- Optimise SVG badge sizes [#11860](https://github.com/badges/shields/issues/11860) +- Add [GnomeExtensions] version badges [#11875](https://github.com/badges/shields/issues/11875) +- Add [Cangjie] version badges [#11789](https://github.com/badges/shields/issues/11789) +- Streamline [JenkinsCoverage] support [#11867](https://github.com/badges/shields/issues/11867) +- Delete old retired services [#11863](https://github.com/badges/shields/issues/11863) +- Update simple-icons to 16.21.0 [#11880](https://github.com/badges/shields/issues/11880) +- Other dependency updates + +## server-2026-05-15 + +- feat: add [SdkmanVersion] badge [#11806](https://github.com/badges/shields/issues/11806) +- feat: improve badges color contrast [#11783](https://github.com/badges/shields/issues/11783) +- Dependency updates + +## server-2026-04-15 + +**Noteable changes:** + +- change deprecated badge messages to 'retired badge' [Bit Codeclimate Codeship Coincap DockerCloud GithubWorkflowStatus Hackage NostrBand Pub Security-Headers VisualStudioAppCenter VisualStudioMarketplace Wikiapiary] [#11798](https://github.com/badges/shields/issues/11798) +- Migrate [Polymart] to [VoxelShop] [#11786](https://github.com/badges/shields/issues/11786) + +**Deprecation:** + +- deprecate [VisualStudioMarketplace] [#11792](https://github.com/badges/shields/issues/11792) + +Other changes: + +- fix(nuget): fix double URL encoding for non-ASCII package names; test [Nuget MyGet] [#11795](https://github.com/badges/shields/issues/11795) +- Validate [WordPress] versions schema [#11787](https://github.com/badges/shields/issues/11787) +- fix: strip build metadata suffix from packageManager field (fixes #9514) [GithubPackageJson] [#11782](https://github.com/badges/shields/issues/11782) +- feat: add GitHub branch count badge (fixes #11283) [#11781](https://github.com/badges/shields/issues/11781) +- production-hosting: Update Sentry [#11778](https://github.com/badges/shields/issues/11778) +- Update [Cookbook] host [#11735](https://github.com/badges/shields/issues/11735) +- Tests updates +- Dependency updates + +## server-2026-03-15 + +**Important notice:** + +- The `cors.allowedOrigin` and `fetchLimit` configuration options have been be removed. Please make sure they're no longer present in your configuration. + +**Deprecation:** + +- Drop [Nexus] 2 support [#11728](https://github.com/badges/shields/issues/11728) +- Deprecate [Codeship] service [#11724](https://github.com/badges/shields/issues/11724) + +Other changes: + +- Replace @sentry/node with @sentry/node-core/light [#11731](https://github.com/badges/shields/issues/11731) +- Fix [Reuse] unregistered handling [#11729](https://github.com/badges/shields/issues/11729) +- Dependency updates + +## server-2026-02-15 + +- Add not-planned and duplicate to issue state to [GithubIssueDetail] [#11666](https://github.com/badges/shields/issues/11666) +- Fix: remove null allowance from schema in [GitLabIssues] [#11665](https://github.com/badges/shields/issues/11665) +- Used the package version list to get version from [Feedz] [#11660](https://github.com/badges/shields/issues/11660) +- Fix [GitHub] issue detail merged state for PRs [#11642](https://github.com/badges/shields/issues/11642) +- Add subpath exports support for [bundlejs] [#11639](https://github.com/badges/shields/issues/11639) +- Update [crates] dependents badge description to include timeout issue [#11635](https://github.com/badges/shields/issues/11635) +- Increase cache time for [crates] dependents badge [#11636](https://github.com/badges/shields/issues/11636) +- Dependency updates + +## server-2026-01-15 + +**Important notice:** + +- The `cors.allowedOrigin` configuration option is no longer supported and will soon be removed. Please remove it from your configuration. +- Several badges URLs have been updated, see [#11584](https://github.com/badges/shields/issues/11584) for more information. + +Other changes: + +- Add link to [GithubFollowers] badge [#11590](https://github.com/badges/shields/issues/11590) +- Improve GitHub check runs documentation [#11612](https://github.com/badges/shields/issues/11612) +- Encourage repository stars [#11613](https://github.com/badges/shields/issues/11613) +- Adapt [YouTube] channel not found handling [#11615](https://github.com/badges/shields/issues/11615) +- Remove [Reuse] 'Not a Git repository' handling [#11614](https://github.com/badges/shields/issues/11614) +- Increase PyPI caches [#11621](https://github.com/badges/shields/issues/11621) +- Prefer `published_at` in github-release-date service [#11616](https://github.com/badges/shields/issues/11616) +- Make badge builder sticky [#11593](https://github.com/badges/shields/issues/11593) +- Cache some badges for longer [#11588](https://github.com/badges/shields/issues/11588) +- Cache deprecated badges for a day [#11582](https://github.com/badges/shields/issues/11582) +- Fix [Symfony] schema when no violations are found [#11578](https://github.com/badges/shields/issues/11578) +- Handle [ClearlyDefined] empty response [#11573](https://github.com/badges/shields/issues/11573) +- Treat `img.shields.io/favicon.ico` as 404 resource [#11576](https://github.com/badges/shields/issues/11576) +- Set checking-in-out attribute to `text=auto` [#11574](https://github.com/badges/shields/issues/11574) +- Fix [CondaLicense, GreasyForkInstalls, MyGet, mbin] examples +- Dependency updates + +## server-2025-12-01 + +**Important notice:** The `fetchLimit` configuration option is being replaced by `fetchLimitBytes`. If you were overriding the default value of `fetchLimit`, please update your configuration accordingly. See [#11430](https://github.com/badges/shields/issues/11430) for more information. + +**Deprecation:** + +- Deprecate [NostrBand] bagdes [#11519](https://github.com/badges/shields/issues/11519) +- Deprecate [DockerCloud] badges [#11501](https://github.com/badges/shields/issues/11501) +- Deprecate [CodeClimate] service [#11480](https://github.com/badges/shields/issues/11480) +- Deprecate [BitComponents] service [#11446](https://github.com/badges/shields/issues/11446) + +Other changes: + +- Return 200 status code for not found badges [#11479](https://github.com/badges/shields/issues/11479) +- Cache defaultpypiBaseUrl [PyPI] value [#11532](https://github.com/badges/shields/issues/11532) +- Fix [SwaggerValidator] badges by switching to HTTPS [#11518](https://github.com/badges/shields/issues/11518) +- Migrate [FlathubVersion] badge to v2 API [#11507](https://github.com/badges/shields/issues/11507) +- Enhance static badge documentation with examples for style, color, and logo options [#11263](https://github.com/badges/shields/issues/11263) +- Allow to run on Node.js 24 [#11505](https://github.com/badges/shields/issues/11505) +- Fix GitHub token pool debug logging and add metrics [#11500](https://github.com/badges/shields/issues/11500) +- Remove legacy configuration checks [#11424](https://github.com/badges/shields/issues/11424) +- Increase [NpmDownloads] cache [#11488](https://github.com/badges/shields/issues/11488) +- Dependency updates and removals + +## server-2025-11-01 + +- Add [GnomeExtensions] Downloads Badge [#11443](https://github.com/badges/shields/issues/11443) +- Fix [Sonar] examples [#11440](https://github.com/badges/shields/issues/11440) +- [BundleJS] Add format query parameter support [#11427](https://github.com/badges/shields/issues/11427) +- Remove footer dark style to enable theme switching [#11431](https://github.com/badges/shields/issues/11431) +- Replace query-string with qs [#11425](https://github.com/badges/shields/issues/11425) +- [BundleJS] Add externals query parameter support [#11400](https://github.com/badges/shields/issues/11400) +- Add displayAssetName option to [GitHubDownloads] badge [#11415](https://github.com/badges/shields/issues/11415) +- Increase [VisualStudioMarketplace] cache multiplier [#11413](https://github.com/badges/shields/issues/11413) +- Handle [Aur] empty license arrays [#11412](https://github.com/badges/shields/issues/11412) +- Fix [ChromeWebStore] add null check for extension not found in ChromeWebStore [#11414](https://github.com/badges/shields/issues/11414) +- [Bluesky] Add followers and posts badges [#11405](https://github.com/badges/shields/issues/11405) +- Replace all instances of twitter.com with x.com [#11409](https://github.com/badges/shields/issues/11409) +- Document [ChromeWebStore] region exclusion [#11407](https://github.com/badges/shields/issues/11407) +- Dependency updates and removals + +## server-2025-10-02 + +**Deprecation:** + +- Deprecate [JenkinsPluginInstalls] per version [#10747](https://github.com/badges/shields/issues/10747) + +Other changes: + +- update Mbin magazine param description [#11389](https://github.com/badges/shields/issues/11389) +- Update [Fedora] not found handling [#11385](https://github.com/badges/shields/issues/11385) +- respect arch param when sort=semver in [DockerVersion] badge [#10905](https://github.com/badges/shields/issues/10905) +- Handle invalid [Crates] version [#11384](https://github.com/badges/shields/issues/11384) +- Update [FreeCodeCamp] API [#11386](https://github.com/badges/shields/issues/11386) +- Update labels of deprecated services [GithubWorkflowStatus PubPopularity] [#11369](https://github.com/badges/shields/issues/11369) +- Add support for codecov components [#11370](https://github.com/badges/shields/issues/11370) +- Drop popout style fallback [#11367](https://github.com/badges/shields/issues/11367) +- [GitHubHacktoberfest] 2025 support [#11353](https://github.com/badges/shields/issues/11353) +- Delete old deprecated services [#11352](https://github.com/badges/shields/issues/11352) +- feat: Add GitLab go.mod Go version service [#11312](https://github.com/badges/shields/issues/11312) +- documentation updates [#10849](https://github.com/badges/shields/issues/10849), [#11324](https://github.com/badges/shields/issues/11324) +- devop updates [#11378](https://github.com/badges/shields/issues/11378), [#11379](https://github.com/badges/shields/issues/11379), [#11351](https://github.com/badges/shields/issues/11351), [#11365](https://github.com/badges/shields/issues/11365) +- CI tests fixes [#11387](https://github.com/badges/shields/issues/11387), [#11388](https://github.com/badges/shields/issues/11388), [#11381](https://github.com/badges/shields/issues/11381), [#11380](https://github.com/badges/shields/issues/11380), [#11366](https://github.com/badges/shields/issues/11366) +- Dependency updates + +## server-2025-09-02 + +- deprecate [VisualStudioAppCenter] [#11321](https://github.com/badges/shields/issues/11321) +- update [StackExchangeMonthlyQuestions] example and live test [#11320](https://github.com/badges/shields/issues/11320) +- increase cache length for [VisualStudioMarketplace] [#11311](https://github.com/badges/shields/issues/11311) +- Dependency updates + +## server-2025-09-01 + +- docs: add note to [GithubContributors] about co-authors limitations [#11260](https://github.com/badges/shields/issues/11260) +- Add [UptimeObserver] monitoring shields support [#11147](https://github.com/badges/shields/issues/11147) +- Dependency updates + +## server-2025-08-03 + +- increase [SourceforgeDownloads] cache time [#11226](https://github.com/badges/shields/issues/11226) +- Dependency updates + +## server-2025-07-01 + +**Important notice:** Updated node from 20 to 22. + +- fix/improve maven metadata version badges; affects [maven gradle] [#11077](https://github.com/badges/shields/issues/11077) +- fix PHP version parsing [#11128](https://github.com/badges/shields/issues/11128) +- update [polymart] schema [#11073](https://github.com/badges/shields/issues/11073) +- deploy on node 22 [#11070](https://github.com/badges/shields/issues/11070) +- improve performance of lookup simple-icons [#11129](https://github.com/badges/shields/issues/11129) +- [DepsRs] Add Deps.rs dependency status badges [#11107](https://github.com/badges/shields/issues/11107) +- add icon svg [#11126](https://github.com/badges/shields/issues/11126) +- Dependency updates + +## server-2025-06-01 + +- Remove Non-Functional SVG Stroke Attribute in Social Badge Renderer [#11095](https://github.com/badges/shields/issues/11095) +- deprecate [pub] popularity badge [#11074](https://github.com/badges/shields/issues/11074) +- deprecate [coincap] service [#11075](https://github.com/badges/shields/issues/11075) +- Support [F-Droid] custom repos [#10792](https://github.com/badges/shields/issues/10792) +- fix: use decimal rating for Firefox extension [#11068](https://github.com/badges/shields/issues/11068) +- set shorter upstream request timeout on [endpoint] and [dynamic] badges [#10996](https://github.com/badges/shields/issues/10996) +- Dependency updates + +## server-2025-05-01 + +- New experimental [DynamicRegex] badge [#10985](https://github.com/badges/shields/issues/10985) +- limit label and message to 255 chars [#10998](https://github.com/badges/shields/issues/10998) +- [PyPI] Fix license for packages following PEP 639 [#11001](https://github.com/badges/shields/issues/11001) +- Dependency updates + +## server-2025-04-01 + +- fix(#10970): codecov branch badge [#10971](https://github.com/badges/shields/issues/10971) +- docker: cleanup some building [#10957](https://github.com/badges/shields/issues/10957) +- graceful shutdown [#10958](https://github.com/badges/shields/issues/10958) +- Update [SourceForge] commit count badge to support repository parameter [#10954](https://github.com/badges/shields/issues/10954) +- feat: add new badges for new [ansible] collection APIs [#10938](https://github.com/badges/shields/issues/10938) +- [sourceforge] add repo param to last commit service [#10935](https://github.com/badges/shields/issues/10935) +- tweak default cache lengths by category [#10919](https://github.com/badges/shields/issues/10919) +- [Packagist] Update default domain for repo metadata [#10920](https://github.com/badges/shields/issues/10920) +- use SVG2 hrefs [#10918](https://github.com/badges/shields/issues/10918) +- remove `logoWidth` param [#10878](https://github.com/badges/shields/issues/10878) +- Dependency updates + +## server-2025-03-02 + +- time out long running requests more aggressively [#10833](https://github.com/badges/shields/issues/10833) +- Dependency updates + +## server-2025-02-02 + +- Mark Stubs-only packages with [PypiTypes] badge [#10864](https://github.com/badges/shields/issues/10864) +- fix badge style when logo only [#10794](https://github.com/badges/shields/issues/10794) +- pass matching mime type to xmldom; test [dynamicxml] [#10830](https://github.com/badges/shields/issues/10830) +- allow [chromewebstore] size to contain decimal point [#10812](https://github.com/badges/shields/issues/10812) +- Add auth support to [Reddit] badges [#10790](https://github.com/badges/shields/issues/10790) +- Fixed mixed up Code climate endpoints [#10813](https://github.com/badges/shields/issues/10813) +- feat: add terraform registry providers and modules downloads [#10793](https://github.com/badges/shields/issues/10793) +- Renew [Mastodon] docs and improve parameter handling [#10789](https://github.com/badges/shields/issues/10789) +- Support [Matrix] summary endpoint [#10782](https://github.com/badges/shields/issues/10782) +- use metric() in [coderabbit] badge [#10779](https://github.com/badges/shields/issues/10779) +- cache matrix badges for 4 hours [#10778](https://github.com/badges/shields/issues/10778) +- Dependency updates + +## server-2025-01-01 + +- Add [PypiTypes] badge [#10774](https://github.com/badges/shields/issues/10774) +- feat(endpoint-badge): add logoSize support [#10132](https://github.com/badges/shields/issues/10132) +- fix auto-sized logo sizes [#10764](https://github.com/badges/shields/issues/10764) +- Add [Coderabbit] PR Stats service and tests [#10749](https://github.com/badges/shields/issues/10749) +- add [PUB] downloads badge [#10745](https://github.com/badges/shields/issues/10745) +- Add [GitLab] Top Language Badge [#10750](https://github.com/badges/shields/issues/10750) +- provide a non-repository scoped version of [githubcodesearch] [#10733](https://github.com/badges/shields/issues/10733) +- [ReproducibleCentral] add Reproducible Central in Dependencies [#10705](https://github.com/badges/shields/issues/10705) +- Add ability to format bytes as metric or IEC; affects [bundlejs bundlephobia ChromeWebStoreSize CratesSize DockerSize GithubRepoSize GithubCodeSize GithubSize NpmUnpackedSize SpigetDownloadSize steam VisualStudioAppCenterReleasesSize whatpulse] [#10547](https://github.com/badges/shields/issues/10547) +- Dependency updates + +## server-2024-12-01 + +- add [WingetVersion] Badge [#10245](https://github.com/badges/shields/issues/10245) +- Fix broken URL for pingpong.one [#10655](https://github.com/badges/shields/issues/10655) +- [npm] - Last update badge added [#10641](https://github.com/badges/shields/issues/10641) +- reduce overhead of NPM Last Update badge; test [npm] [#10666](https://github.com/badges/shields/issues/10666) +- Add YouTube-specific privacy notes [#10646](https://github.com/badges/shields/issues/10646) +- Dependency updates + +## server-2024-11-02 + +- cleanly handle null or undefined result from jsonpath-plus [#10645](https://github.com/badges/shields/issues/10645) +- add content security policy header to SVG responses [#10642](https://github.com/badges/shields/issues/10642) +- [Scoop] Added scoop-license badge. [#10627](https://github.com/badges/shields/issues/10627) +- [Chromewebstore] Extension size & last updated [#10613](https://github.com/badges/shields/issues/10613) +- Deprecate HackageDeps service [#10618](https://github.com/badges/shields/issues/10618) +- Add [CratesUserDownloads] service and tester [#10619](https://github.com/badges/shields/issues/10619) +- [Snapcraft] - Added snapcraft last update badge [#10610](https://github.com/badges/shields/issues/10610) +- [GitHubHacktoberfest] 2024 support [#10612](https://github.com/badges/shields/issues/10612) +- add [homebrew] cask download badge [#10595](https://github.com/badges/shields/issues/10595) +- remove prefix v for commit hash version [#10597](https://github.com/badges/shields/issues/10597) +- [Maven] Added badge for Maven-Cenral last-update (#10301) [#10585](https://github.com/badges/shields/issues/10585) +- [DynamicXml] parse doc as html if served with text/html content type [#10607](https://github.com/badges/shields/issues/10607) +- Revert "Use old.stats.jenkins.io for JSON data (#10522)" [#10537](https://github.com/badges/shields/issues/10537) +- catch queries that cause TypeError [#10556](https://github.com/badges/shields/issues/10556) +- Dependency updates + +## server-2024-09-25 + +This release includes an important security fix. See + +- https://github.com/badges/shields/security/advisories/GHSA-rxvx-x284-4445 +- https://github.com/badges/shields/issues/10553 + +for more details + +- [dynamicjson dynamicyaml dynamictoml] switch to jsonpath-plus [#10551](https://github.com/badges/shields/issues/10551) +- [Snapcraft] license [#10520](https://github.com/badges/shields/issues/10520) +- deprecate [wheelmap] service [#10538](https://github.com/badges/shields/issues/10538) +- Use old.stats.jenkins.io for JSON data [#10522](https://github.com/badges/shields/issues/10522) +- catch xml ParseError [#10516](https://github.com/badges/shields/issues/10516) +- migrate [MozillaObservatory] to /scan endpoint [#10491](https://github.com/badges/shields/issues/10491) +- fix incorrect codecov config link [#10511](https://github.com/badges/shields/issues/10511) +- [OSSLifecycle OSSLifecycleRedirect] Add file_url param to pull from non-github sources [#10489](https://github.com/badges/shields/issues/10489) +- perf: improve logoSize performance [#10488](https://github.com/badges/shields/issues/10488) +- perf: faster `resetIconPosition` avoiding to parse path twice [#10497](https://github.com/badges/shields/issues/10497) +- perf: limit logoSize precision to 3 [#10521](https://github.com/badges/shields/issues/10521) +- Dependency updates + +## server-2024-09-02 + +- Publish linux/amd64 docker images for snapshot builds [#10476](https://github.com/badges/shields/issues/10476) +- Fix Gitea not having credentials/authorizedOrigins in Docker environments [#10486](https://github.com/badges/shields/issues/10486) +- fix typo in pepy downloads [#10475](https://github.com/badges/shields/issues/10475) +- ignore a couple of docusaurus warnings [#10469](https://github.com/badges/shields/issues/10469) +- Use Ecologi API to power Treeware badges [#10467](https://github.com/badges/shields/issues/10467) +- move go version badge to platform support category [#10444](https://github.com/badges/shields/issues/10444) +- [Crates] Implement Dependents Badge [#10438](https://github.com/badges/shields/issues/10438) +- [Crates] Added crate size badge [#10421](https://github.com/badges/shields/issues/10421) +- Dependency updates + +## server-2024-08-01 + +- send Cross-Origin-Resource-Policy header on all responses [#10420](https://github.com/badges/shields/issues/10420) +- migrate [MozillaObservatory] to new API [#10402](https://github.com/badges/shields/issues/10402) +- use metric() for [discord] and [revolt] badges [#10406](https://github.com/badges/shields/issues/10406) +- Cache text only static badges for longer [#10403](https://github.com/badges/shields/issues/10403) +- Fix [FreeCodeCampPoints] not found handling [#10377](https://github.com/badges/shields/issues/10377) +- Fix [Gitea] not found message [#10373](https://github.com/badges/shields/issues/10373) +- Deprecate [Bountysource] service [#10371](https://github.com/badges/shields/issues/10371) +- Sunset Shields custom logos [#10347](https://github.com/badges/shields/issues/10347) +- Use ellipsis when many versions returned for [ModrinthGameVersions] [#10350](https://github.com/badges/shields/issues/10350) +- deprecate [tokei] service [#9581](https://github.com/badges/shields/issues/9581) +- Add CF-Ray header value to Sentry errors if available [#10339](https://github.com/badges/shields/issues/10339) +- Use XML for Chocolatey, affects [Chocolatey Resharper PowershellGallery] [#10344](https://github.com/badges/shields/issues/10344) +- include github contributors badge in docs site [#10337](https://github.com/badges/shields/issues/10337) +- Dependency updates + +## server-2024-07-01 + +- Add [AUR] Popularity Badge [#10304](https://github.com/badges/shields/issues/10304) +- fix npm badges when `maintainers` not in response [#10286](https://github.com/badges/shields/issues/10286) +- Expose `logoBase64` and `links` in badge-maker NPM package [#10283](https://github.com/badges/shields/issues/10283) +- Remove `logoPosition` [#10284](https://github.com/badges/shields/issues/10284) +- [MBIN] Add subscribers badge [#10270](https://github.com/badges/shields/issues/10270) +- Add [Docker] support for loong64 arch [#10241](https://github.com/badges/shields/issues/10241) +- Add puppetforge quality score badges [#10201](https://github.com/badges/shields/issues/10201) +- Dependency updates + +## server-2024-06-01 + +- Remove namedLogo from defaultBadgeData of non-social badges [#10195](https://github.com/badges/shields/issues/10195) +- Update number of badges served each month [#10197](https://github.com/badges/shields/issues/10197) +- Delete old deprecated services [#10196](https://github.com/badges/shields/issues/10196) +- handle [BitbucketPipelines] responses with missing result key [#10163](https://github.com/badges/shields/issues/10163) +- Update description of GitHub commit status badge [#10198](https://github.com/badges/shields/issues/10198) +- chore: fix spelling of GitHub in badge descriptions [#10199](https://github.com/badges/shields/issues/10199) +- Add [GithubCheckRuns] service [#7759](https://github.com/badges/shields/issues/7759) +- feat: add Revolt badge [#10093](https://github.com/badges/shields/issues/10093) +- ensure color is string before calling toLowerCase() [#10129](https://github.com/badges/shields/issues/10129) +- instruct dependabot to monitor composite actions [#10139](https://github.com/badges/shields/issues/10139) +- run tests on node 22 [#10127](https://github.com/badges/shields/issues/10127) +- tweaks to libraries.io token pooling code [#10074](https://github.com/badges/shields/issues/10074) +- fix [pypi] status badge when package has no 'Development Status' classifier [#10107](https://github.com/badges/shields/issues/10107) +- clarify yml paths in server-secrets docs [#10106](https://github.com/badges/shields/issues/10106) +- Update region flag name in flyctl deploy command [#10134](https://github.com/badges/shields/issues/10134) +- Dependency updates + +## server-2024-05-01 + +- [Hexpm] Fix badges for pre-release only versions [#10112](https://github.com/badges/shields/issues/10112) +- feat(logos): support auto-sizing mode [#9191](https://github.com/badges/shields/issues/9191) [#10110](https://github.com/badges/shields/issues/10110) [#10125](https://github.com/badges/shields/issues/10125) +- support setting pypiBaseUrl by environment variables and queryParameters; affects [pypi] [#10044](https://github.com/badges/shields/issues/10044) +- Add 0BSD license to licenseTypes and [PypiLicense] [#10092](https://github.com/badges/shields/issues/10092) +- Update Mastodon profile URL [#10082](https://github.com/badges/shields/issues/10082) +- [GitHubGoMod] Ignore comment after version (fixes #10079) [#10080](https://github.com/badges/shields/issues/10080) +- Perf: Librariesio repo dependencies [#10062](https://github.com/badges/shields/issues/10062) +- [Chocolatey Nuget] Fix "not found" error for chocolatey badge [#10060](https://github.com/badges/shields/issues/10060) +- Dependency updates + +## server-2024-04-01 + +- improve performance of [GithubLastCommit] [GitlabLastCommit] [GiteaLastCommit] [#10046](https://github.com/badges/shields/issues/10046) +- [BitbucketLastCommit] Add Bitbucket last commit [#10043](https://github.com/badges/shields/issues/10043) +- [GithubLastCommit] [GitlabLastCommit] [GiteaLastCommit] Support file path for last commit [#10041](https://github.com/badges/shields/issues/10041) +- upgrade to docusaurus 3 [#9820](https://github.com/badges/shields/issues/9820) +- redirect [npm] /dt to /d18m [#10033](https://github.com/badges/shields/issues/10033) +- Add [JSR] version service [#10030](https://github.com/badges/shields/issues/10030) +- Add [snapcraft] version badge [#9976](https://github.com/badges/shields/issues/9976) +- Dependency updates + +## server-2024-03-01 + +- feat(gitea): add last commit badge [#9995](https://github.com/badges/shields/issues/9995) +- [GithubCreatedAt] Add Created At Badge for Github [#9981](https://github.com/badges/shields/issues/9981) +- Added custom bucket url support for [Scoop] [#9984](https://github.com/badges/shields/issues/9984) +- [NpmUnpackedSize] Unpacked Size Badge [#9954](https://github.com/badges/shields/issues/9954) +- [Website] Render `status: down` badge if website is unresponsive [#9966](https://github.com/badges/shields/issues/9966) +- deprecate TAS [#9932](https://github.com/badges/shields/issues/9932) +- [GITEA] add forks, stars, issues and pr badges [#9923](https://github.com/badges/shields/issues/9923) +- tolerate missing short_version in [visualstudioappcenter] [#9951](https://github.com/badges/shields/issues/9951) +- [Crates] Only use non-yanked crate versions (ready for merge) [#9949](https://github.com/badges/shields/issues/9949) +- Dependency updates + +## server-2024-02-01 + +- feat: added up_message and down_message to [uptimerobotstatus] [#9662](https://github.com/badges/shields/issues/9662) +- Add [Hangar] Badges [#9800](https://github.com/badges/shields/issues/9800) +- sort categories by title (except core) [#9888](https://github.com/badges/shields/issues/9888) +- Add Support for [Nostr] Followers [#9870](https://github.com/badges/shields/issues/9870) +- [thunderstore] replace experimental API usage with newly available v1 API [#9886](https://github.com/badges/shields/issues/9886) +- Update [Gitea] defaults to gitea.com [#9872](https://github.com/badges/shields/issues/9872) +- [crates] MSRV Badge [#9871](https://github.com/badges/shields/issues/9871) +- Add [galaxytoolshed] Version [#8249](https://github.com/badges/shields/issues/8249) +- fix default style docs for social badges [#9869](https://github.com/badges/shields/issues/9869) +- Dependency updates + +## server-2024-01-01 + +The most important changes in this release for users hosting their own instance are: + +The shields docker image is now based on node 20: + +- deploy on node 20 [#9799](https://github.com/badges/shields/issues/9799) + +It is now possible to use [authentication for DockerHub](https://github.com/badges/shields/blob/master/doc/server-secrets.md#dockerhub) to allow higher API rate limit or access to private repos: + +- call [docker] with auth [#9803](https://github.com/badges/shields/issues/9803) + +### New Badges + +- [Thunderstore] Add Thunderstore Badges [#9782](https://github.com/badges/shields/issues/9782) +- Add [Raycast] Badge [#9801](https://github.com/badges/shields/issues/9801) +- [GITEA] add new gitea service (release/languages) [#9781](https://github.com/badges/shields/issues/9781) +- Add [NpmStatDownloads] Badge [#9783](https://github.com/badges/shields/issues/9783) + +### Frontend Changes + +- improve documentation for [dynamicxml] service [#9798](https://github.com/badges/shields/issues/9798) +- add description to interval enums [#9854](https://github.com/badges/shields/issues/9854) +- convert 'style' param to enum [#9853](https://github.com/badges/shields/issues/9853) +- Ensure social category badges are rendered with social style and logo; affects [gitlab keybase lemmy modrinth thunderstore twitch] gist github reddit [#9859](https://github.com/badges/shields/issues/9859) + +### Fixes + +- [pub] Use official version endpoint for pub-service [#9802](https://github.com/badges/shields/issues/9802) +- cache weblate badges for longer [#9786](https://github.com/badges/shields/issues/9786) +- [Discourse] Update schema keys to use plural form (`topic_count` -> `topics_count`) [#9778](https://github.com/badges/shields/issues/9778) +- cache some badges for longer [#9785](https://github.com/badges/shields/issues/9785) +- increase page size for github release badge by semver [#9818](https://github.com/badges/shields/issues/9818) +- Dependency updates + +## server-2023-12-04 + +- move from @renovate/pep440 to @renovatebot/pep440 [#9614](https://github.com/badges/shields/issues/9614) +- deprecate/fix [ansible] galaxy services [#9648](https://github.com/badges/shields/issues/9648) +- call [pepy] with auth [#9748](https://github.com/badges/shields/issues/9748) +- add meaningful descriptions including keywords [#9715](https://github.com/badges/shields/issues/9715) +- Dependency updates + +## server-2023-11-01 + +- fix greasyfork 404 bug [#9632](https://github.com/badges/shields/issues/9632) +- Hacktoberfest 2023 support - resolves #9636 [#9637](https://github.com/badges/shields/issues/9637) +- switch to fixed OpenCollective images [#9615](https://github.com/badges/shields/issues/9615) +- Dependency updates + +## server-2023-10-02 + +- add python package total downloads from [pepy] badge [#9564](https://github.com/badges/shields/issues/9564) +- deprecate [redmine] plugin rating badges [#9568](https://github.com/badges/shields/issues/9568) +- fix [bower] version badge [#9567](https://github.com/badges/shields/issues/9567) +- Add [PythonVersionFromToml] shield [#9516](https://github.com/badges/shields/issues/9516) +- Add [dub] score badge service [#9549](https://github.com/badges/shields/issues/9549) +- Dependency updates + +## server-2023-09-04 + +- Fix [testspace] badges [#9525](https://github.com/badges/shields/issues/9525) +- fix rSt code example [#9528](https://github.com/badges/shields/issues/9528) +- Add dynamic TOML support via [DynamicToml] Service [#9517](https://github.com/badges/shields/issues/9517) +- cache [pypi] downloads for longer [#9522](https://github.com/badges/shields/issues/9522) +- [twitter] --> 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) +- remove disableStrictSsl param from [jenkins] [#6887](https://github.com/badges/shields/issues/6887) +- refactor(GitHubCommitActivity): switch to v4/GraphQL API [#6959](https://github.com/badges/shields/issues/6959) +- feat: add freecodecamp badge [#6958](https://github.com/badges/shields/issues/6958) +- use the right version of NPM in docker build [#6941](https://github.com/badges/shields/issues/6941) +- [TwitchExtensionVersion] New badge [#6900](https://github.com/badges/shields/issues/6900) +- enforce strict SSL checking for [coverity] [#6886](https://github.com/badges/shields/issues/6886) +- Update self hosting docs [#6877](https://github.com/badges/shields/issues/6877) +- Support optionalDependencies in [GithubPackageJson] [#6749](https://github.com/badges/shields/issues/6749) +- Dependency updates + +## server-2021-08-01 + +- use v5 API for [AUR] badges [#6836](https://github.com/badges/shields/issues/6836) +- [Sonar] Fix invalid fetch query to sonarqube >=6.6 [#6636](https://github.com/badges/shields/issues/6636) +- Delegate discord logo to simple-icons, which matches the current branding [#6764](https://github.com/badges/shields/issues/6764) +- Re-apply 'Migrate request to got (part 1)' [#6755](https://github.com/badges/shields/issues/6755) +- Delete old deprecated badges [#6756](https://github.com/badges/shields/issues/6756) +- Replace opn-cli with open-cli [#6747](https://github.com/badges/shields/issues/6747) +- Verify that Node 14 is installed in development [#6748](https://github.com/badges/shields/issues/6748) +- Migrate from CommonJS to ESM [#6651](https://github.com/badges/shields/issues/6651) +- Add Wikiapiary Extension Badge [WikiapiaryInstalls] [#6678](https://github.com/badges/shields/issues/6678) +- deprecate [beerpay] [#6708](https://github.com/badges/shields/issues/6708) +- deprecate [microbadger] [#6709](https://github.com/badges/shields/issues/6709) +- [npmsioscore] Support npm score [#6630](https://github.com/badges/shields/issues/6630) +- Add [Weblate] badges [#6677](https://github.com/badges/shields/issues/6677) +- Dependency updates + +## server-2021-07-01 + +- improve [MavenCentral], [MavenMetadata], and [GradlePluginPortal] [#6628](https://github.com/badges/shields/issues/6628) +- fix: fix regex to match [codecov]'s flags [#6655](https://github.com/badges/shields/issues/6655) +- fix usage style [#6638](https://github.com/badges/shields/issues/6638) +- update simple-icons to v5 with by-name lookup backwards compatibility [#6591](https://github.com/badges/shields/issues/6591) +- [GradlePluginPortal] add gradle plugin portal [#6449](https://github.com/badges/shields/issues/6449) +- upgrade some vulnerable packages [#6569](https://github.com/badges/shields/issues/6569) +- increase max-age for download and social badges [#6567](https://github.com/badges/shields/issues/6567) +- Dependency updates + +## server-2021-06-01 + +- Changed creating badges to open a new Window/Tab [#6536](https://github.com/badges/shields/issues/6536) +- Make for-the-badge letter spacing more predictable, and rewrite layout logic [#5754](https://github.com/badges/shields/issues/5754) +- deprecate DockerBuild service [#6529](https://github.com/badges/shields/issues/6529) +- Remove rate limiting functionality [#6513](https://github.com/badges/shields/issues/6513) +- [GitHub] Move to 'funding' category [#5846](https://github.com/badges/shields/issues/5846) +- Add GitHub discussions total badge [GithubTotalDiscussions] [#6472](https://github.com/badges/shields/issues/6472) +- Add optional query parameter (include_prereleases) to [GemVersion] [#6451](https://github.com/badges/shields/issues/6451) +- Add [PingPong] Service [#6327](https://github.com/badges/shields/issues/6327) +- Dependency updates + +## server-2021-05-01 + +- Add setting which allows setting a timeout on HTTP requests This is configured with the new `REQUEST_TIMEOUT_SECONDS` setting. If a request takes longer than this number of seconds a `408 Request Timeout` response will be returned. +- Deprecate [Bintray] service [#6423](https://github.com/badges/shields/issues/6423) +- Support git hash in [nexus] SNAPSHOT version [#6369](https://github.com/badges/shields/issues/6369) +- Replace 4183C4 with blue [#6366](https://github.com/badges/shields/issues/6366) +- [Youtube] Added channel view count and subscriber count badges [#6333](https://github.com/badges/shields/issues/6333) +- Fix Netlify badge by adding new color schema [#6340](https://github.com/badges/shields/issues/6340) +- [REUSE] Add service badges [#6330](https://github.com/badges/shields/issues/6330) +- Dependency updates + +## server-2021-04-01 + +- Use NPM packages to provide fonts instead of Google Fonts [#6274](https://github.com/badges/shields/issues/6274) +- Prevent duplication of parameters in badge examples [#6272](https://github.com/badges/shields/issues/6272) +- Add docs for all types of releases [#6210](https://github.com/badges/shields/issues/6210) +- refresh self-hosting docs [#6273](https://github.com/badges/shields/issues/6273) +- allow missing 'goal' key in [liberapay] badges [#6258](https://github.com/badges/shields/issues/6258) +- use got to push influx metrics [#6257](https://github.com/badges/shields/issues/6257) +- Dependency updates + +## server-2021-03-01 + +- ensure redirect target path is correctly encoded [#6229](https://github.com/badges/shields/issues/6229) +- [SecurityHeaders] Added a possibility for no follow redirects [#6212](https://github.com/badges/shields/issues/6212) +- catch URL parse error in [endpoint] badge [#6214](https://github.com/badges/shields/issues/6214) +- [Homebrew] Add homebrew downloads badge [#6209](https://github.com/badges/shields/issues/6209) +- update [pkgreview] url [#6189](https://github.com/badges/shields/issues/6189) +- Make [Twitch] a social badge [#6183](https://github.com/badges/shields/issues/6183) +- update [flathub] error handling [#6185](https://github.com/badges/shields/issues/6185) +- Add [Testspace] badges [#6162](https://github.com/badges/shields/issues/6162) +- Dependency updates + +## server-2021-02-01 + +- Docs.rs badge (#6098) +- Fix feedz service in case the package page gets paginated (#6101) +- [Bitbucket] Server Adding Auth Tokens and Resolving Pull Request api … (#6076) +- Dependency updates + +## server-2021-01-18 + +- Gotta start somewhere diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000000..0a6cd13cb5537 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,81 @@ +# Contributor Covenant Code of Conduct + +## 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 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: + +- 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 + +Examples of unacceptable behavior include: + +- 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 + +## 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 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. + +## 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) + +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. + +## 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: + +### 1. Correction + +**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. + +### 2. Warning + +**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. + +### 3. Temporary Ban + +**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. + +### 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. + +**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. + +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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 47ebe3f1d489b..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,80 +21,71 @@ 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][] 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 [issues]: https://github.com/badges/shields/issues +[discussions]: https://github.com/badges/shields/discussions [chat room]: https://discordapp.com/invite/HjJCwm5 [maintainers]: https://github.com/badges/shields#project-leaders ### 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, [open an issue][] - 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. +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 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. ## Badge URLs @@ -111,29 +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 -### Tests +### Coding style -When adding or changing a service [please write tests][service-tests]. +This project uses lower camelCase for variable names and function names and client facing parameters. We use PascalCase for class names and service names. -When opening a pull request, include your service name in brackets in the pull -request title. That way, those service tests will run in CI. +### Tests -e.g. **[Travis] Fix timeout issues** +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 changing other code, please add unit tests. +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 redis installed and in your PATH. -Use `brew install redis`, `yum install redis`, etc. The test runner will -start the server automatically. +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 @@ -141,6 +130,31 @@ start the server automatically. There is a [High-level code walkthrough](doc/code-walkthrough.md) describing the layout of the project. -### Logos +## Pull Requests + +All code changes are incorporated via pull requests, and pull requests are always squashed into a single commit on merging. Therefore there's no requirement to squash commits within your PR, but feel free to squash or restructure the commits on your PR branch if you think it will be helpful. PRs with well structured commits are always easier to review! + +Because all changes are pulled into the main branch via squash merges from PRs, we do **not** support overwriting any aspects of the git history once it hits our main branch. Notably this means we do not support amending commit messages, nor adjusting commit author information once merged. + +Accordingly, it is the responsibility of contributors to review this type of information and adjust as needed before marking PRs as ready for review and merging. + +You can review and modify your local [git configuration][git-config] via `git config`, and also find more information about amending your commit messages [here][amending-commits]. + +[git-config]: https://git-scm.com/book/en/v2/Customizing-Git-Git-Configuration +[amending-commits]: https://docs.github.com/en/github/committing-changes-to-your-project/changing-a-commit-message#rewriting-the-most-recent-commit-message + +### Running service tests in pull requests + +The affected service names must be included in square brackets in the pull request title so that the CI engine will run those service tests. When a pull request affects multiple services, they should be separated with spaces. The test runner is case-insensitive, so they should be capitalized for readability. + +For example: + +- **[Travis] Fix timeout issues** +- **[Travis Sonar] Support user token authentication** +- **Add tests for [CRAN] and [CPAN]** + +Note that many services are part of a "family" of related services. Depending on the changes in your PR you may need to run the tests for just a single service, or for _all_ the services within a family. + +For example, a PR title of **[GitHubForks] Foo** will only run the service tests specifically for the GitHub Forks badge, whereas a title of **[GitHub] Foo** will run the service tests for all of the GitHub badges. -We have [documentation for logo usage](doc/logos.md) which includes [contribution guidance](doc/logos.md#contributing-logos) +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 5f535e56fcfd5..989daa96ed539 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,6 @@ -FROM node:8-alpine +FROM node:24-alpine AS builder + +RUN npm install -g "npm@^11" RUN mkdir -p /usr/src/app RUN mkdir /usr/src/app/private @@ -12,13 +14,27 @@ COPY badge-maker /usr/src/app/badge-maker/ 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:24-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 --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/Makefile b/Makefile deleted file mode 100644 index 2c3c7bd91684a..0000000000000 --- a/Makefile +++ /dev/null @@ -1,70 +0,0 @@ -SHELL:=/bin/bash - -SERVER_TMP=${TMPDIR}shields-server-deploy -FRONTEND_TMP=${TMPDIR}shields-frontend-deploy - -# This branch is reserved for the deploy process and should not be used for -# development. The deploy script will clobber it. To avoid accidentally -# pushing secrets to GitHub, this branch is configured to reject pushes. -WORKING_BRANCH=server-deploy-working-branch - -all: test - -deploy: deploy-s0 deploy-s1 deploy-s2 clean-server-deploy deploy-gh-pages deploy-gh-pages-clean - -deploy-s0: prepare-server-deploy push-s0 -deploy-s1: prepare-server-deploy push-s1 -deploy-s2: prepare-server-deploy push-s2 - -prepare-server-deploy: - # Ship a copy of the front end to each server for debugging. - # https://github.com/badges/shields/issues/1220 - INCLUDE_DEV_PAGES=false \ - npm run build - rm -rf ${SERVER_TMP} - git worktree prune - git worktree add -B ${WORKING_BRANCH} ${SERVER_TMP} - cp -r public ${SERVER_TMP} - git -C ${SERVER_TMP} add -f public/ - git -C ${SERVER_TMP} commit --no-verify -m '[DEPLOY] Add frontend for debugging' - cp config/local-shields-io-production.yml ${SERVER_TMP}/config/ - git -C ${SERVER_TMP} add -f config/local-shields-io-production.yml - git -C ${SERVER_TMP} commit --no-verify -m '[DEPLOY] MUST NOT BE ON GITHUB' - -clean-server-deploy: - rm -rf ${SERVER_TMP} - git worktree prune - -push-s0: - git push -f s0 ${WORKING_BRANCH}:master - -push-s1: - git push -f s1 ${WORKING_BRANCH}:master - -push-s2: - git push -f s2 ${WORKING_BRANCH}:master - -deploy-gh-pages: - rm -rf ${FRONTEND_TMP} - git worktree prune - GATSBY_BASE_URL=https://img.shields.io \ - INCLUDE_DEV_PAGES=false \ - npm run build - git worktree add -B gh-pages ${FRONTEND_TMP} - git -C ${FRONTEND_TMP} ls-files | xargs git -C ${FRONTEND_TMP} rm - git -C ${FRONTEND_TMP} commit --no-verify -m '[DEPLOY] Completely clean the index' - cp -r public/* ${FRONTEND_TMP} - echo shields.io > ${FRONTEND_TMP}/CNAME - touch ${FRONTEND_TMP}/.nojekyll - git -C ${FRONTEND_TMP} add . - git -C ${FRONTEND_TMP} commit --no-verify -m '[DEPLOY] Add built site' - git push -f origin gh-pages - -deploy-gh-pages-clean: - rm -rf ${FRONTEND_TMP} - git worktree prune - -test: - npm test - -.PHONY: all deploy prepare-server-deploy clean-server-deploy deploy-s0 deploy-s1 deploy-s2 push-s0 push-s1 push-s2 deploy-gh-pages deploy-gh-pages-clean deploy-heroku setup redis test diff --git a/README.md b/README.md index 41ff58ce5233f..09b1f93ced19b 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,32 @@

-

- - - + - + - - build status - - service-test status + + + + Daily Tests Status coverage - - Total alerts - - commits to be deployed + 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 470 million images. +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 +[bootstrap]: https://github.com/twbs/bootstrap This repo hosts: @@ -65,177 +53,124 @@ 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://img.shields.io/badges/static-badge -Browse a [complete list of badges][shields.io]. +### Quickstart -[custom badges]: http://shields.io/#your-badge +Browse a [complete list of badges][shields.io] and locate a particular badge by using the search bar or by browsing the categories. Click on the badge to fill in required data elements for that badge type (like your username or repo) and optionally customize (label, colors etc.). And it's ready for use! + +Use the button at the bottom to copy your badge url or snippet, which can then be added to places like your GitHub readme files or other web pages. ## 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) -You can read a [tutorial on how to add a badge][tutorial]. +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]: doc/TUTORIAL.md -[contributing]: CONTRIBUTING.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 8 or later. You can use the [package manager][] of your choice. - Tests need to pass in Node 8 and 10. +[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/badges/shields?quickstart=1) + +1. Install Node 24. 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. +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. -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`. +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`. -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][]. +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][]. -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). - -[![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:js:server` to preview changes to the saved -snapshots, and `SNAPSHOT_UPDATE=1 npm run test:js:server` 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]: doc/self-hosting.md#prometheus +[prometheus configuration]: https://github.com/badges/shields/blob/master/doc/self-hosting.md#prometheus [sentry]: https://sentry.io/ -[sentry configuration]: doc/self-hosting.md#sentry -[daily-tests]: https://github.com/badges/daily-tests +[sentry configuration]: https://github.com/badges/shields/blob/master/doc/self-hosting.md#sentry [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 There is documentation about [hosting your own server][self-hosting]. -[self-hosting]: doc/self-hosting.md +[self-hosting]: https://github.com/badges/shields/blob/master/doc/self-hosting.md + +## Related projects + +[![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) + +[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 [old-gh-badges]: https://github.com/badges/gh-badges -[motivation]: spec/motivation.md -[spec]: spec/SPECIFICATION.md +[motivation]: https://github.com/badges/shields/blob/master/spec/motivation.md +[spec]: https://github.com/badges/shields/blob/master/spec/SPECIFICATION.md [thread]: https://github.com/h5bp/lazyweb-requests/issues/150 ## Project leaders Maintainers: -- [calebcartwright](https://github.com/calebcartwright) (core team) -- [chris48s](https://github.com/chris48s) (core team) -- [Daniel15](https://github.com/Daniel15) (core team) -- [espadrine](https://github.com/espadrine) (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: - -- [espadrine](https://github.com/espadrine) (sysadmin) -- [paulmelnikow](https://github.com/paulmelnikow) (limited access) +- [calebcartwright](https://github.com/calebcartwright) +- [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) - -## Related projects - -- [badgerbadgerbadger gem][gem] -- [pybadges python library][pybadges] - -[gem]: https://github.com/badges/badgerbadgerbadger -[pybadges]: https://github.com/google/pybadges +- [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. - -## Contributors - -This project exists thanks to all the people who contribute. [[Contribute](CONTRIBUTING.md)]. - - -## Backers - -Thank you to all our backers! 🙏 [[Become a backer](https://opencollective.com/shields#backer)] - - - -## Sponsors +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). -Support this project by becoming a sponsor. Your logo will show up here with a link to your website. [[Become a sponsor](https://opencollective.com/shields#sponsor)] +## Community - - - - - - - - - - +Thanks to the people and companies who donate money, services or time to keep the project running. [https://shields.io/community](https://shields.io/community) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000000..435da11190632 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,29 @@ +# Security Policy + +## Supported Projects + +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 [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) 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 + +If you find a security vulnerability affecting any of our supported projects, please email [security@shields.io](mailto:security@shields.io), rather than opening a public issue on GitHub. After receiving the initial report, we will endeavor to keep you informed of the progress towards a fix and full announcement. We may ask you for additional information. You are also welcome to propose a patch or solution. + +Report security bugs in third-party modules to the person or team maintaining the module. + +## Coordinated Disclosure + +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 43ce0ff6e526c..34daf28d46fc4 100644 --- a/__snapshots__/make-badge.spec.js +++ b/__snapshots__/make-badge.spec.js @@ -1,127 +1,2712 @@ exports['The badge generator SVG should match snapshot 1'] = ` -cactuscactusgrowngrown + + cactus: grown + + + + + + + + + + + + + + + cactus + + + + grown + + + + ` exports['The badge generator "flat" template badge generation should match snapshots: message/label, no logo 1'] = ` -cactuscactusgrowngrown + + cactus: grown + + + + + + + + + + + + + + + cactus + + + + grown + + + + ` exports['The badge generator "flat" template badge generation should match snapshots: message/label, with logo 1'] = ` -cactuscactusgrowngrown + + cactus: grown + + + + + + + + + + + + + + + + cactus + + + + grown + + + + ` exports['The badge generator "flat" template badge generation should match snapshots: message only, no logo 1'] = ` -growngrown + + grown + + + + + + + + + + + + + + + grown + + + + ` exports['The badge generator "flat" template badge generation should match snapshots: message only, with logo 1'] = ` -growngrown + + grown + + + + + + + + + + + + + + + + grown + + + + ` exports['The badge generator "flat" template badge generation should match snapshots: message only, with logo and labelColor 1'] = ` -growngrown + + grown + + + + + + + + + + + + + + + + grown + + + + ` exports['The badge generator "flat" template badge generation should match snapshots: message/label, with links 1'] = ` -cactuscactusgrowngrown + + + + + + + + + + + + + + + + + + cactus + + + + + + + grown + + + + + +` + +exports['The badge generator "flat" template badge generation should match snapshots: black text when the label color is light 1'] = ` + + cactus: grown + + + + + + + + + + + + + + + cactus + + + + grown + + + + +` + +exports['The badge generator "flat" template badge generation should match snapshots: black text when the message color is light 1'] = ` + + cactus: grown + + + + + + + + + + + + + + + cactus + + + + grown + + + + ` exports['The badge generator "flat-square" template badge generation should match snapshots: message/label, no logo 1'] = ` -cactusgrown + + cactus: grown + + + + + + cactus + grown + + + ` exports['The badge generator "flat-square" template badge generation should match snapshots: message/label, with logo 1'] = ` -cactusgrown + + cactus: grown + + + + + + + cactus + grown + + + ` exports['The badge generator "flat-square" template badge generation should match snapshots: message only, no logo 1'] = ` -grown + + grown + + + + + + grown + + + ` exports['The badge generator "flat-square" template badge generation should match snapshots: message only, with logo 1'] = ` -grown + + grown + + + + + + + grown + + + ` exports['The badge generator "flat-square" template badge generation should match snapshots: message only, with logo and labelColor 1'] = ` -grown + + grown + + + + + + + grown + + + ` exports['The badge generator "flat-square" template badge generation should match snapshots: message/label, with links 1'] = ` -cactusgrown + + + + + + + + + cactus + + + + grown + + + + +` + +exports['The badge generator "flat-square" template badge generation should match snapshots: black text when the label color is light 1'] = ` + + cactus: grown + + + + + + + cactus + + grown + + + +` + +exports['The badge generator "flat-square" template badge generation should match snapshots: black text when the message color is light 1'] = ` + + cactus: grown + + + + + + cactus + + grown + + + + ` exports['The badge generator "plastic" template badge generation should match snapshots: message/label, no logo 1'] = ` -cactuscactusgrowngrown + + cactus: grown + + + + + + + + + + + + + + + + + cactus + + + + grown + + + + ` exports['The badge generator "plastic" template badge generation should match snapshots: message/label, with logo 1'] = ` -cactuscactusgrowngrown + + cactus: grown + + + + + + + + + + + + + + + + + + cactus + + + + grown + + + + ` exports['The badge generator "plastic" template badge generation should match snapshots: message only, no logo 1'] = ` -growngrown + + grown + + + + + + + + + + + + + + + + + grown + + + + ` exports['The badge generator "plastic" template badge generation should match snapshots: message only, with logo 1'] = ` -growngrown + + grown + + + + + + + + + + + + + + + + + + grown + + + + ` exports['The badge generator "plastic" template badge generation should match snapshots: message only, with logo and labelColor 1'] = ` -growngrown + + grown + + + + + + + + + + + + + + + + + + grown + + + + ` exports['The badge generator "plastic" template badge generation should match snapshots: message/label, with links 1'] = ` -cactuscactusgrowngrown + + + + + + + + + + + + + + + + + + + + cactus + + + + + + + grown + + + + + +` + +exports['The badge generator "plastic" template badge generation should match snapshots: black text when the label color is light 1'] = ` + + cactus: grown + + + + + + + + + + + + + + + + + cactus + + + + grown + + + + +` + +exports['The badge generator "plastic" template badge generation should match snapshots: black text when the message color is light 1'] = ` + + cactus: grown + + + + + + + + + + + + + + + + + cactus + + + + grown + + + + ` exports['The badge generator "for-the-badge" template badge generation should match snapshots: message/label, no logo 1'] = ` -CACTUSGROWN + + CACTUS: GROWN + + + + + + CACTUS + + GROWN + + + + ` exports['The badge generator "for-the-badge" template badge generation should match snapshots: message/label, with logo 1'] = ` -CACTUSGROWN + + CACTUS: GROWN + + + + + + + CACTUS + + GROWN + + + + ` exports['The badge generator "for-the-badge" template badge generation should match snapshots: message only, no logo 1'] = ` -GROWN + + GROWN + + + + + + GROWN + + + + ` exports['The badge generator "for-the-badge" template badge generation should match snapshots: message only, with logo 1'] = ` -GROWN + + GROWN + + + + + + + GROWN + + + + ` exports['The badge generator "for-the-badge" template badge generation should match snapshots: message only, with logo and labelColor 1'] = ` -GROWN + + GROWN + + + + + + + + GROWN + + + + ` exports['The badge generator "for-the-badge" template badge generation should match snapshots: message/label, with links 1'] = ` -CACTUSGROWN + + + + + + + + + + CACTUS + + + + + + GROWN + + + + + +` + +exports['The badge generator "for-the-badge" template badge generation should match snapshots: black text when the label color is light 1'] = ` + + CACTUS: GROWN + + + + + + + CACTUS + + + GROWN + + + + +` + +exports['The badge generator "for-the-badge" template badge generation should match snapshots: black text when the message color is light 1'] = ` + + CACTUS: GROWN + + + + + + CACTUS + + GROWN + + + + ` exports['The badge generator "social" template badge generation should match snapshots: message/label, no logo 1'] = ` -CactusCactusgrowngrown + + Cactus: grown + + + + + + + + + + + + + + + + + + ` exports['The badge generator "social" template badge generation should match snapshots: message/label, with logo 1'] = ` -CactusCactusgrowngrown + + Cactus: grown + + + + + + + + + + + + + + + + + + + ` exports['The badge generator "social" template badge generation should match snapshots: message only, no logo 1'] = ` -growngrown + + grown + + + + + + + + + + + + + + + + + + ` exports['The badge generator "social" template badge generation should match snapshots: message only, with logo 1'] = ` -growngrown + + grown + + + + + + + + + + + + + + + + + + + ` exports['The badge generator "social" template badge generation should match snapshots: message only, with logo and labelColor 1'] = ` -growngrown + + grown + + + + + + + + + + + + + + + + + + + ` exports['The badge generator "social" template badge generation should match snapshots: message/label, with links 1'] = ` -CactusCactusgrowngrown + + + + + + + + + + + + + + + + + + + + Cactus + + + + + + + grown + + + + + +` + +exports['The badge generator "flat" template badge generation should match snapshots: message with custom suffix 1'] = ` + + cactus: grown + + + + + + + + + + + + + + + + cactus + + + + grown + + + + ` -exports['The badge generator badges with logos should always produce the same badge badge with logo 1'] = ` -labellabelmessagemessage +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 d0ed5429ffbad..0000000000000 --- a/app.json +++ /dev/null @@ -1,42 +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": "http://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 - } - }, - "formation": { - "web": { - "quantity": 1, - "size": "free" - } - } -} diff --git a/badge-maker/.npmignore b/badge-maker/.npmignore index e31b519306080..e0c77d8e64400 100644 --- a/badge-maker/.npmignore +++ b/badge-maker/.npmignore @@ -1,2 +1,3 @@ lib/make-badge-test-helpers.js lib/**/*.spec.js +index.test-d.ts diff --git a/badge-maker/CHANGELOG.md b/badge-maker/CHANGELOG.md index ef2702077a2d5..2b5b275419784 100644 --- a/badge-maker/CHANGELOG.md +++ b/badge-maker/CHANGELOG.md @@ -1,5 +1,82 @@ # Changelog +## 6.0.0 + +### 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 + +- Improve font measuring in for-the-badge and social styles +- Make for-the-badge letter spacing more predictable + +## 3.3.0 + +- Readability improvements: a dark font color is automatically used when the badge's background is too light. For example: ![](https://img.shields.io/badge/hello-world-white) +- Better CSS color compliance: thanks to a switch from _is-css-color_ to _[css-color-converter](https://www.npmjs.com/package/css-color-converter)_, you can use a wider range of color formats from the latest CSS specification, for example `rgb(0 255 0)` +- Less dependencies: _badge-maker_ no longer depends on _camelcase_ + +## 3.2.0 + +- Accessibility improvements: Help users of assistive technologies to read the badges when used inline + +## 3.1.0 + +- Add TypeScript definitions + +## 3.0.1 + +- Fix missing dependency + ## 3.0.0 ### Breaking Changes @@ -10,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() @@ -74,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`) @@ -113,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 @@ -182,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') @@ -248,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 bb523ae554c29..1826b701c8ac6 100644 --- a/badge-maker/README.md +++ b/badge-maker/README.md @@ -1,7 +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 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 @@ -15,18 +14,20 @@ 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 ```js -const { makeBadge, ValidationError } = require('badge-maker') +import { makeBadge, ValidationError } from 'badge-maker' +``` +```js const format = { label: 'build', message: 'passed', - color: 'green', + color: 'brightgreen', } const svg = makeBadge(format) @@ -41,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 @@ -55,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' } ``` @@ -123,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 new file mode 100644 index 0000000000000..96ad5332a770c --- /dev/null +++ b/badge-maker/index.d.ts @@ -0,0 +1,14 @@ +interface Format { + label?: string + message: string + 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 {} + +export declare function makeBadge(format: Format): string diff --git a/badge-maker/index.test-d.ts b/badge-maker/index.test-d.ts new file mode 100644 index 0000000000000..7e9246cd975b3 --- /dev/null +++ b/badge-maker/index.test-d.ts @@ -0,0 +1,35 @@ +import { expectType, expectError, expectAssignable } from 'tsd' +import { makeBadge, ValidationError } from '.' + +expectError(makeBadge('string is invalid')) +expectError(makeBadge({})) +expectError( + makeBadge({ + message: 'passed', + style: 'invalid style', + }), +) + +expectType( + makeBadge({ + message: 'passed', + }), +) +expectType( + makeBadge({ + label: 'build', + message: 'passed', + }), +) +expectType( + makeBadge({ + label: 'build', + message: 'passed', + labelColor: 'green', + color: 'red', + style: 'flat', + }), +) + +const error = new ValidationError() +expectAssignable(error) 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 b7c7eac69bc69..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() { +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() { + 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) @@ -27,15 +30,15 @@ describe('The CLI', function() { .and.to.include('grown') }) - it('should produce colorschemed badges', async 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() { + 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') + 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 22adca8c2a6b4..a594dfe961ce2 100644 --- a/badge-maker/lib/badge-renderers.js +++ b/badge-maker/lib/badge-renderers.js @@ -1,147 +1,117 @@ -'use strict' +import anafanafo from 'anafanafo' +import { brightness } from './color.js' +import { XmlElement, ElementList } from './xml.js' -const anafanafo = require('anafanafo') +// https://github.com/badges/shields/pull/1132 +const FONT_SCALE_UP_FACTOR = 10 +const FONT_SCALE_DOWN_VALUE = 'scale(.1)' -const fontFamily = 'font-family="DejaVu Sans,Verdana,Geneva,sans-serif"' -const socialFontFamily = - 'font-family="Helvetica Neue,Helvetica,Arial,sans-serif"' +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)}` } -function escapeXml(s) { - if (s === undefined || typeof s !== 'string') { - return undefined +function colorsForBackground(color) { + const brightnessThreshold = 0.69 + if (brightness(color) <= brightnessThreshold) { + return { textColor: DEFAULT_TEXT_FILL, shadowColor: '#010101' } } else { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') + return { textColor: '#333', shadowColor: '#ccc' } } } -function roundUpToOdd(val) { - // Increase chances of pixel grid alignment. - return val % 2 === 0 ? val + 1 : val +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 preferredWidthOf(str) { - return roundUpToOdd((anafanafo(str) / 10) | 0) -} - -function computeWidths({ label, message }) { - return { - labelWidth: preferredWidthOf(label), - messageWidth: preferredWidthOf(message), - } +function roundUpToOdd(val) { + return val % 2 === 0 ? val + 1 : val } -function renderLogo({ - logo, - badgeHeight, - horizPadding, - logoWidth = 14, - logoPadding = 0, -}) { - if (!logo) { - return { - hasLogo: false, - totalLogoWidth: 0, - renderedLogo: '', - } - } - const logoHeight = 14 - const y = (badgeHeight - logoHeight) / 2 - const x = horizPadding - return { - hasLogo: true, - totalLogoWidth: logoWidth + logoPadding, - renderedLogo: ``, - } +function preferredWidthOf(str, options) { + // Increase chances of pixel grid alignment. + return roundUpToOdd(anafanafo(str, options) | 0) } -function renderText({ - leftMargin, - horizPadding = 0, - content, - verticalMargin = 0, - shadow = false, -}) { - if (!content.length) { - return { renderedText: '', width: 0 } - } - - const textLength = preferredWidthOf(content) - const escapedContent = escapeXml(content) - - const shadowMargin = 150 + verticalMargin - const textMargin = 140 + verticalMargin - - const outTextLength = 10 * textLength - const x = 10 * (leftMargin + 0.5 * textLength + horizPadding) - - let renderedText = '' - if (shadow) { - renderedText = `${escapedContent}` - } - renderedText += `${escapedContent}` - - return { - renderedText, - width: textLength, - } +function createAccessibleText({ label, message }) { + const labelPrefix = label ? `${label}: ` : '' + return labelPrefix + message } -function renderLinks({ - links: [leftLink, rightLink] = [], - labelWidth, - messageWidth, - height, -}) { - leftLink = escapeXml(leftLink) - rightLink = escapeXml(rightLink) +function hasLinks({ links }) { + const [leftLink, rightLink] = links || [] const hasLeftLink = leftLink && leftLink.length const hasRightLink = rightLink && rightLink.length - const leftLinkWidth = hasRightLink ? labelWidth : labelWidth + messageWidth + const hasLink = hasLeftLink && hasRightLink + return { hasLink, hasLeftLink, hasRightLink } +} - function render({ link, width }) { - return `` - } +function shouldWrapBodyWithLink({ links }) { + const { hasLeftLink, hasRightLink } = hasLinks({ links }) + return hasLeftLink && !hasRightLink +} - return ( - (hasRightLink - ? render({ link: rightLink, width: labelWidth + messageWidth }) - : '') + - (hasLeftLink ? render({ link: leftLink, width: leftLinkWidth }) : '') - ) +function getLogoElement({ logo, horizPadding, badgeHeight, logoWidth }) { + const logoHeight = 14 + if (!logo) return '' + return new XmlElement({ + name: 'image', + attrs: { + x: horizPadding, + y: 0.5 * (badgeHeight - logoHeight), + width: logoWidth, + height: logoHeight, + href: logo, + }, + }) } -function renderBadge({ links, leftWidth, rightWidth, height }, main) { +function renderBadge( + { links, leftWidth, rightWidth, height, accessibleText }, + content, +) { const width = leftWidth + rightWidth - return ` - - ${main} - ${renderLinks({ links, leftWidth, rightWidth, height })} - ` -} + const leftLink = links[0] + const { hasLink } = hasLinks({ links }) + + const title = hasLink + ? '' + : new XmlElement({ name: 'title', content: [accessibleText] }) + + const body = shouldWrapBodyWithLink({ links }) + ? new XmlElement({ + name: 'a', + content, + attrs: { target: '_blank', href: leftLink }, + }) + : new ElementList({ content }) + + const svgAttrs = { + xmlns: 'http://www.w3.org/2000/svg', + width, + height, + } + if (!hasLink) { + svgAttrs.role = 'img' + svgAttrs['aria-label'] = accessibleText + } -function stripXmlWhitespace(xml) { - return xml - .replace(/>\s+/g, '>') - .replace(/<\s+/g, '<') - .trim() + const svg = new XmlElement({ + name: 'svg', + content: [title, body], + attrs: svgAttrs, + }) + return svg.render() } class Badge { - static get fontFamily() { - throw new Error('Not implemented') - } - static get height() { throw new Error('Not implemented') } @@ -161,40 +131,30 @@ class Badge { logo, logoWidth, logoPadding, - color = '#4c1', + color = '#4b0', labelColor, + idSuffix = '', }) { const horizPadding = 5 - const { hasLogo, totalLogoWidth, renderedLogo } = renderLogo({ - logo, - badgeHeight: this.constructor.height, - horizPadding, - logoWidth, - logoPadding, - }) + const hasLogo = !!logo + const totalLogoWidth = logoWidth + logoPadding + const accessibleText = createAccessibleText({ label, message }) + const hasLabel = label.length || labelColor if (labelColor == null) { labelColor = '#555' } - labelColor = hasLabel || hasLogo ? labelColor : color - labelColor = escapeXml(labelColor) - color = escapeXml(color) const labelMargin = totalLogoWidth + 1 - - const { renderedText: renderedLabel, width: labelWidth } = renderText({ - leftMargin: labelMargin, - horizPadding, - content: label, - verticalMargin: this.constructor.verticalMargin, - shadow: this.constructor.shadow, - }) - + const labelWidth = label.length + ? preferredWidthOf(label, { font: WIDTH_FONT }) + : 0 const leftWidth = hasLabel ? labelWidth + 2 * horizPadding + totalLogoWidth : 0 + const messageWidth = preferredWidthOf(message, { font: WIDTH_FONT }) let messageMargin = leftWidth - (message.length ? 1 : 0) if (!hasLabel) { if (hasLogo) { @@ -203,31 +163,220 @@ class Badge { messageMargin = messageMargin + 1 } } - - const { renderedText: renderedMessage, width: messageWidth } = renderText({ - leftMargin: messageMargin, - horizPadding, - content: message, - verticalMargin: this.constructor.verticalMargin, - shadow: this.constructor.shadow, - }) - let rightWidth = messageWidth + 2 * horizPadding if (hasLogo && !hasLabel) { - rightWidth += totalLogoWidth + horizPadding - 1 + rightWidth += totalLogoWidth + (message.length ? horizPadding - 1 : 0) } const width = leftWidth + rightWidth + this.horizPadding = horizPadding + this.labelMargin = labelMargin + this.messageMargin = messageMargin this.links = links + this.labelWidth = labelWidth + this.messageWidth = messageWidth this.leftWidth = leftWidth this.rightWidth = rightWidth this.width = width this.labelColor = labelColor this.color = color - this.renderedLogo = renderedLogo - this.renderedLabel = renderedLabel - this.renderedMessage = renderedMessage + this.label = label + this.message = message + this.accessibleText = accessibleText + this.idSuffix = idSuffix + + this.logoElement = getLogoElement({ + logo, + horizPadding, + badgeHeight: this.constructor.height, + logoWidth, + }) + this.foregroundGroupElement = this.getForegroundGroupElement() + } + + static render(params) { + return new this(params).render() + } + + getTextElement({ leftMargin, content, link, color, textWidth, linkWidth }) { + if (!content.length) return '' + + const { textColor, shadowColor } = colorsForBackground(color) + const x = + FONT_SCALE_UP_FACTOR * (leftMargin + 0.5 * textWidth + this.horizPadding) + 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 element + } + + const rect = new XmlElement({ + name: 'rect', + attrs: { + width: linkWidth, + x: leftMargin > 1 ? leftMargin + 1 : 0, + height: this.constructor.height, + fill: 'rgba(0,0,0,0)', + }, + }) + return new XmlElement({ + name: 'a', + content: [rect, element], + attrs: { target: '_blank', href: link }, + }) + } + + getLabelElement() { + const leftLink = this.links[0] + return this.getTextElement({ + leftMargin: this.labelMargin, + content: this.label, + link: !shouldWrapBodyWithLink({ links: this.links }) + ? leftLink + : undefined, + color: this.labelColor, + textWidth: this.labelWidth, + linkWidth: this.leftWidth, + }) + } + + getMessageElement() { + const rightLink = this.links[1] + return this.getTextElement({ + leftMargin: this.messageMargin, + content: this.message, + link: rightLink, + color: this.color, + textWidth: this.messageWidth, + linkWidth: this.rightWidth, + }) + } + + getClipPathElement(rx) { + return new XmlElement({ + name: 'clipPath', + content: [ + new XmlElement({ + name: 'rect', + attrs: { + width: this.width, + height: this.constructor.height, + rx, + }, + }), + ], + attrs: { id: `r${this.idSuffix}` }, + }) + } + + getBackgroundGroupElement({ withGradient, attrs }) { + const leftRect = new XmlElement({ + name: 'rect', + attrs: { + width: this.leftWidth, + height: this.constructor.height, + fill: this.labelColor, + }, + }) + const rightRect = new XmlElement({ + name: 'rect', + attrs: { + x: this.leftWidth, + width: this.rightWidth, + height: this.constructor.height, + fill: this.color, + }, + }) + const gradient = new XmlElement({ + name: 'rect', + attrs: { + width: this.width, + height: this.constructor.height, + fill: `url(#s${this.idSuffix})`, + }, + }) + const content = withGradient + ? [leftRect, rightRect, gradient] + : [leftRect, rightRect] + return new XmlElement({ name: 'g', content, attrs }) + } + + getForegroundGroupElement() { + return new XmlElement({ + name: 'g', + content: [ + this.logoElement, + this.getLabelElement(), + this.getMessageElement(), + ], + attrs: { + fill: DEFAULT_TEXT_FILL, + 'text-anchor': 'middle', + 'font-family': FONT_FAMILY, + 'text-rendering': 'geometricPrecision', + 'font-size': 110, + }, + }) + } + + getBlurElement() { + return new XmlElement({ + name: 'filter', + content: [ + new XmlElement({ + name: 'feGaussianBlur', + attrs: { stdDeviation: '16' }, + }), + ], + attrs: { id: 'blur' }, + }) } render() { @@ -236,10 +385,6 @@ class Badge { } class Plastic extends Badge { - static get fontFamily() { - return fontFamily - } - static get height() { return 18 } @@ -253,45 +398,52 @@ class Plastic extends Badge { } render() { + const gradient = new XmlElement({ + name: 'linearGradient', + content: [ + new XmlElement({ + name: 'stop', + attrs: { offset: 0, 'stop-color': '#fff', 'stop-opacity': '.7' }, + }), + new XmlElement({ + name: 'stop', + attrs: { offset: '.1', 'stop-color': '#aaa', 'stop-opacity': '.1' }, + }), + new XmlElement({ + name: 'stop', + attrs: { offset: '.9', 'stop-color': '#000', 'stop-opacity': '.3' }, + }), + new XmlElement({ + name: 'stop', + attrs: { offset: 1, 'stop-color': '#000', 'stop-opacity': '.5' }, + }), + ], + 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${this.idSuffix})` }, + }) + return renderBadge( { links: this.links, leftWidth: this.leftWidth, rightWidth: this.rightWidth, + accessibleText: this.accessibleText, height: this.constructor.height, }, - ` - - - - - - - - - - - - - - - - - - - ${this.renderedLogo} - ${this.renderedLabel} - ${this.renderedMessage} - ` + [blur, gradient, clipPath, backgroundGroup, this.foregroundGroupElement], ) } } class Flat extends Badge { - static get fontFamily() { - return fontFamily - } - static get height() { return 20 } @@ -305,43 +457,44 @@ class Flat extends Badge { } render() { + const blur = this.getBlurElement() + + const gradient = new XmlElement({ + name: 'linearGradient', + content: [ + new XmlElement({ + name: 'stop', + attrs: { offset: 0, 'stop-color': '#bbb', 'stop-opacity': '.1' }, + }), + new XmlElement({ + name: 'stop', + attrs: { offset: 1, 'stop-opacity': '.1' }, + }), + ], + 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${this.idSuffix})` }, + }) + return renderBadge( { links: this.links, leftWidth: this.leftWidth, rightWidth: this.rightWidth, + accessibleText: this.accessibleText, height: this.constructor.height, }, - ` - - - - - - - - - - - - - - - - - ${this.renderedLogo} - ${this.renderedLabel} - ${this.renderedMessage} - ` + [blur, gradient, clipPath, backgroundGroup, this.foregroundGroupElement], ) } } class FlatSquare extends Badge { - static get fontFamily() { - return fontFamily - } - static get height() { return 20 } @@ -355,52 +508,24 @@ class FlatSquare extends Badge { } render() { + const backgroundGroup = this.getBackgroundGroupElement({ + withGradient: false, + attrs: { 'shape-rendering': 'crispEdges' }, + }) + return renderBadge( { links: this.links, leftWidth: this.leftWidth, rightWidth: this.rightWidth, + accessibleText: this.accessibleText, height: this.constructor.height, }, - ` - - - - - - - ${this.renderedLogo} - ${this.renderedLabel} - ${this.renderedMessage} - ` + [backgroundGroup, this.foregroundGroupElement], ) } } -function plastic(params) { - const badge = new Plastic(params) - if (params.minify) { - return stripXmlWhitespace(badge.render()) - } - return badge.render() -} - -function flat(params) { - const badge = new Flat(params) - if (params.minify) { - return stripXmlWhitespace(badge.render()) - } - return badge.render() -} - -function flatSquare(params) { - const badge = new FlatSquare(params) - if (params.minify) { - return stripXmlWhitespace(badge.render()) - } - return badge.render() -} - function social({ label, message, @@ -408,9 +533,9 @@ function social({ logo, logoWidth, logoPadding, - color = '#4c1', + color = '#4b0', labelColor = '#555', - minify, + idSuffix = '', }) { // Social label is styled with a leading capital. Convert to caps here so // width can be measured using the correct characters. @@ -418,102 +543,256 @@ function social({ const externalHeight = 20 const internalHeight = 19 - const horizPadding = 5 - const { totalLogoWidth, renderedLogo } = renderLogo({ - logo, - badgeHeight: externalHeight, - horizPadding, - logoWidth, - logoPadding, - }) + const labelHorizPadding = 5 + const messageHorizPadding = 4 + const horizGutter = 6 + const totalLogoWidth = logoWidth + logoPadding const hasMessage = message.length - let { labelWidth, messageWidth } = computeWidths({ label, message }) - labelWidth += 10 + totalLogoWidth - messageWidth += 10 - messageWidth -= 4 - - const labelTextX = ((labelWidth + totalLogoWidth) / 2) * 10 - const labelTextLength = (labelWidth - (10 + totalLogoWidth)) * 10 - const escapedLabel = escapeXml(label) + const font = 'bold 11px Helvetica' + const labelTextWidth = preferredWidthOf(label, { font }) + const messageTextWidth = preferredWidthOf(message, { font }) + const labelRectWidth = labelTextWidth + totalLogoWidth + 2 * labelHorizPadding + const messageRectWidth = messageTextWidth + 2 * messageHorizPadding + + const [leftLink, rightLink] = links + const { hasLeftLink, hasRightLink, hasLink } = hasLinks({ links }) + + const accessibleText = createAccessibleText({ label, message }) + + function getMessageBubble() { + if (!hasMessage) return '' + + const messageBubbleMainX = labelRectWidth + horizGutter + 0.5 + const messageBubbleNotchX = labelRectWidth + horizGutter + const content = [ + new XmlElement({ + name: 'rect', + attrs: { + x: messageBubbleMainX, + y: '.5', + width: messageRectWidth, + height: internalHeight, + rx: 2, + fill: '#fafafa', + }, + }), + new XmlElement({ + name: 'rect', + attrs: { + x: messageBubbleNotchX, + y: 7.5, + width: '.5', + height: 5, + stroke: '#fafafa', + }, + }), + new XmlElement({ + name: 'path', + attrs: { + d: `M${messageBubbleMainX} 6.5 l-3 3v1 l3 3`, + fill: '#fafafa', + }, + }), + ] + return new ElementList({ content }) + } - let [leftLink, rightLink] = links - leftLink = escapeXml(leftLink) - rightLink = escapeXml(rightLink) - const hasLeftLink = leftLink && leftLink.length - const hasRightLink = rightLink && rightLink.length + function getLabelText() { + const labelTextX = + FONT_SCALE_UP_FACTOR * + (totalLogoWidth + labelTextWidth / 2 + labelHorizPadding) + const labelTextLength = FONT_SCALE_UP_FACTOR * labelTextWidth + const shouldWrapWithLink = hasLeftLink && !shouldWrapBodyWithLink({ links }) + + const rect = new XmlElement({ + name: 'rect', + attrs: { + id: `llink${idSuffix}`, + stroke: '#d5d5d5', + fill: `url(#a${idSuffix})`, + x: '.5', + y: '.5', + width: labelRectWidth, + height: internalHeight, + rx: 2, + }, + }) + const shadow = new XmlElement({ + name: 'text', + content: [label], + attrs: { + 'aria-hidden': 'true', + x: labelTextX, + y: 150, + fill: '#fff', + transform: FONT_SCALE_DOWN_VALUE, + textLength: labelTextLength, + }, + }) + const text = new XmlElement({ + name: 'text', + content: [label], + attrs: { + x: labelTextX, + y: 140, + transform: FONT_SCALE_DOWN_VALUE, + textLength: labelTextLength, + }, + }) - function renderMessageBubble() { - const messageBubbleMainX = labelWidth + 6.5 - const messageBubbleNotchX = labelWidth + 6 - return ` - - - - ` - } - - function renderMessageText() { - const messageTextX = (labelWidth + messageWidth / 2 + 6) * 10 - const messageTextLength = (messageWidth - 8) * 10 - const escapedMessage = escapeXml(message) - const shadow = `${escapedMessage}` - const text = `${escapedMessage}` - if (hasRightLink) { - return ` - ${shadow} - ${text} - ` - } - return ` - ${shadow} - ${text} - ` + return shouldWrapWithLink + ? new XmlElement({ + name: 'a', + content: [shadow, text, rect], + attrs: { target: '_blank', href: leftLink }, + }) + : new ElementList({ content: [rect, shadow, text] }) } - function renderLeftLink() { - const rect = `` - if (hasLeftLink) { - return `${rect}` - } - return rect + function getMessageText() { + if (!hasMessage) return '' + + const messageTextX = + FONT_SCALE_UP_FACTOR * + (labelRectWidth + horizGutter + messageRectWidth / 2) + const messageTextLength = FONT_SCALE_UP_FACTOR * messageTextWidth + + const rect = new XmlElement({ + name: 'rect', + attrs: { + width: messageRectWidth + 1, + x: labelRectWidth + horizGutter, + height: internalHeight + 1, + fill: 'rgba(0,0,0,0)', + }, + }) + const shadow = new XmlElement({ + name: 'text', + content: [message], + attrs: { + 'aria-hidden': 'true', + x: messageTextX, + y: 150, + fill: '#fff', + transform: FONT_SCALE_DOWN_VALUE, + textLength: messageTextLength, + }, + }) + const text = new XmlElement({ + name: 'text', + content: [message], + attrs: { + id: `rlink${idSuffix}`, + x: messageTextX, + y: 140, + transform: FONT_SCALE_DOWN_VALUE, + textLength: messageTextLength, + }, + }) + + return hasRightLink + ? new XmlElement({ + name: 'a', + content: [rect, shadow, text], + attrs: { target: '_blank', href: rightLink }, + }) + : new ElementList({ content: [shadow, text] }) } - const badge = renderBadge( + const style = new XmlElement({ + name: 'style', + content: [ + `a:hover #llink${idSuffix}{fill:url(#b${idSuffix});stroke:#ccc}a:hover #rlink${idSuffix}{fill:#4183c4}`, + ], + }) + const gradients = new ElementList({ + content: [ + new XmlElement({ + name: 'linearGradient', + content: [ + new XmlElement({ + name: 'stop', + attrs: { + offset: 0, + 'stop-color': '#fcfcfc', + 'stop-opacity': 0, + }, + }), + new XmlElement({ + name: 'stop', + attrs: { offset: 1, 'stop-opacity': '.1' }, + }), + ], + attrs: { id: `a${idSuffix}`, x2: 0, y2: '100%' }, + }), + new XmlElement({ + name: 'linearGradient', + content: [ + new XmlElement({ + name: 'stop', + attrs: { offset: 0, 'stop-color': '#ccc', 'stop-opacity': '.1' }, + }), + new XmlElement({ + name: 'stop', + attrs: { offset: 1, 'stop-opacity': '.1' }, + }), + ], + attrs: { id: `b${idSuffix}`, x2: 0, y2: '100%' }, + }), + ], + }) + const labelRect = new XmlElement({ + name: 'rect', + attrs: { + stroke: 'none', + fill: '#fcfcfc', + x: '.5', + y: '.5', + width: labelRectWidth, + height: internalHeight, + rx: 2, + }, + }) + const messageBubble = getMessageBubble() + const labelText = getLabelText() + const messageText = getMessageText() + const backgroundGroup = new XmlElement({ + name: 'g', + content: [labelRect, messageBubble], + attrs: { stroke: '#d5d5d5' }, + }) + const foregroundGroup = new XmlElement({ + name: 'g', + content: [labelText, messageText], + attrs: { + 'aria-hidden': `${!hasLink}`, + fill: '#333', + 'text-anchor': 'middle', + 'font-family': SOCIAL_FONT_FAMILY, + 'text-rendering': 'geometricPrecision', + 'font-weight': 700, + 'font-size': '110px', + 'line-height': '14px', + }, + }) + const logoElement = getLogoElement({ + logo, + horizPadding: labelHorizPadding, + badgeHeight: externalHeight, + logoWidth, + }) + + return renderBadge( { - links: [], - leftWidth: labelWidth + 1, - rightWidth: hasMessage ? messageWidth + 6 : 0, + links, + leftWidth: labelRectWidth + 1, + rightWidth: hasMessage ? horizGutter + messageRectWidth : 0, + accessibleText, height: externalHeight, }, - ` - - - - - - - - - - - - ${hasMessage ? renderMessageBubble() : ''} - - ${renderedLogo} - - ${escapedLabel} - ${escapedLabel} - ${hasMessage ? renderMessageText() : ''} - - ${renderLeftLink()} - ` + [style, gradients, backgroundGroup, logoElement, foregroundGroup], ) - - if (minify) { - return stripXmlWhitespace(badge) - } - return badge } function forTheBadge({ @@ -522,91 +801,239 @@ function forTheBadge({ links, logo, logoWidth, - logoPadding, - color = '#4c1', + color = '#4b0', labelColor, - minify, }) { - // For the Badge is styled in all caps. Convert to caps here so widths can - // be measured using the correct characters. + const FONT_SIZE = 10 + const BADGE_HEIGHT = 28 + const TEXT_MARGIN = 12 + const LOGO_MARGIN = 9 + const LOGO_TEXT_GUTTER = 6 + const LETTER_SPACING = 1.25 + + // Prepare content. For the Badge is styled in all caps. It's important to to + // convert to uppercase first so the widths can be measured using the correct + // symbols. label = label.toUpperCase() message = message.toUpperCase() - let { labelWidth, messageWidth } = computeWidths({ label, message }) - const height = 28 - const hasLabel = label.length || labelColor - if (labelColor == null) { - labelColor = '#555' + const [leftLink, rightLink] = links + const { hasLeftLink, hasRightLink } = hasLinks({ links }) + + const outLabelColor = labelColor || '#555' + + // Compute text width. + // TODO: This really should count the symbols rather than just using `.length`. + // https://mathiasbynens.be/notes/javascript-unicode + // This is not using `preferredWidthOf()` as it tends to produce larger + // inconsistencies in the letter spacing. The badges look fine, however if you + // replace `textLength` with `letterSpacing` in the rendered SVG, you can see + // the discrepancy. Ideally, swapping out `textLength` for `letterSpacing` + // should not affect the appearance. + const labelTextWidth = label.length + ? (anafanafo(label, { font: `${FONT_SIZE}px Verdana` }) | 0) + + LETTER_SPACING * label.length + : 0 + const messageTextWidth = message.length + ? (anafanafo(message, { font: `bold ${FONT_SIZE}px Verdana` }) | 0) + + LETTER_SPACING * message.length + : 0 + + // Compute horizontal layout. + // If a `labelColor` is set, the logo is always set against it, even when + // 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 + gutter + } else { + labelTextMinX = TEXT_MARGIN + } + let labelRectWidth, messageTextMinX, messageRectWidth + if (needsLabelRect) { + if (hasLabel) { + labelRectWidth = labelTextMinX + labelTextWidth + TEXT_MARGIN + } else { + labelRectWidth = 2 * LOGO_MARGIN + logoWidth + } + messageTextMinX = labelRectWidth + TEXT_MARGIN + messageRectWidth = 2 * TEXT_MARGIN + messageTextWidth + } else { + if (logo) { + messageTextMinX = TEXT_MARGIN + logoWidth + gutter + messageRectWidth = 2 * TEXT_MARGIN + logoWidth + gutter + messageTextWidth + } else { + messageTextMinX = TEXT_MARGIN + messageRectWidth = 2 * TEXT_MARGIN + messageTextWidth + } } - const horizPadding = 9 - const { hasLogo, totalLogoWidth, renderedLogo } = renderLogo({ + + const logoElement = getLogoElement({ logo, - badgeHeight: height, - horizPadding, + horizPadding: logoMinX, + badgeHeight: BADGE_HEIGHT, logoWidth, - logoPadding, }) - labelWidth += 10 + totalLogoWidth - if (label.length) { - labelWidth += 10 + label.length * 1.5 - } else if (hasLogo) { - if (hasLabel) { - labelWidth += 7 + function getLabelElement() { + const { textColor } = colorsForBackground(outLabelColor) + const midX = labelTextMinX + 0.5 * labelTextWidth + const text = new XmlElement({ + name: 'text', + content: [label], + 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 })) { + const rect = new XmlElement({ + name: 'rect', + attrs: { + width: labelRectWidth, + height: BADGE_HEIGHT, + fill: 'rgba(0,0,0,0)', + }, + }) + return new XmlElement({ + name: 'a', + content: [rect, text], + attrs: { + target: '_blank', + href: leftLink, + }, + }) } else { - labelWidth -= 7 + return text } - } else { - labelWidth -= 11 } - messageWidth += 10 - messageWidth += 10 + message.length * 2 - const leftWidth = hasLogo && !hasLabel ? 0 : labelWidth - const rightWidth = - hasLogo && !hasLabel ? messageWidth + labelWidth : messageWidth - - labelColor = hasLabel || hasLogo ? labelColor : color + function getMessageElement() { + const { textColor } = colorsForBackground(color) + const midX = messageTextMinX + 0.5 * messageTextWidth + const text = new XmlElement({ + name: 'text', + content: [message], + 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, + ), + }) - color = escapeXml(color) - labelColor = escapeXml(labelColor) + if (hasRightLink) { + const rect = new XmlElement({ + name: 'rect', + attrs: { + width: messageRectWidth, + height: BADGE_HEIGHT, + x: labelRectWidth || 0, + fill: 'rgba(0,0,0,0)', + }, + }) + return new XmlElement({ + name: 'a', + content: [rect, text], + attrs: { + target: '_blank', + href: rightLink, + }, + }) + } else { + return text + } + } - function renderLabelText() { - const labelTextX = ((labelWidth + totalLogoWidth) / 2) * 10 - const labelTextLength = (labelWidth - (24 + totalLogoWidth)) * 10 - const escapedLabel = escapeXml(label) - return ` - ${escapedLabel} - ` + let backgroundContent + if (needsLabelRect) { + backgroundContent = [ + new XmlElement({ + name: 'rect', + attrs: { + width: labelRectWidth, + height: BADGE_HEIGHT, + fill: outLabelColor, + }, + }), + new XmlElement({ + name: 'rect', + attrs: { + x: labelRectWidth, + width: messageRectWidth, + height: BADGE_HEIGHT, + fill: color, + }, + }), + ] + } else { + backgroundContent = [ + new XmlElement({ + name: 'rect', + attrs: { + width: messageRectWidth, + height: BADGE_HEIGHT, + fill: color, + }, + }), + ] } - const badge = renderBadge( + const backgroundGroup = new XmlElement({ + name: 'g', + content: backgroundContent, + attrs: { + 'shape-rendering': 'crispEdges', + }, + }) + const foregroundGroup = new XmlElement({ + name: 'g', + content: [ + logoElement, + hasLabel ? getLabelElement() : '', + getMessageElement(), + ], + attrs: { + fill: DEFAULT_TEXT_FILL, + 'text-anchor': 'middle', + 'font-family': FONT_FAMILY, + 'text-rendering': 'geometricPrecision', + 'font-size': FONT_SCALE_UP_FACTOR * FONT_SIZE, + }, + }) + + // Render. + return renderBadge( { links, - leftWidth, - rightWidth, - height, + leftWidth: labelRectWidth || 0, + rightWidth: messageRectWidth, + accessibleText: createAccessibleText({ label, message }), + height: BADGE_HEIGHT, }, - ` - - - - - - ${renderedLogo} - ${hasLabel ? renderLabelText() : ''} - - ${escapeXml(message)} - ` + [backgroundGroup, foregroundGroup], ) - - if (minify) { - return stripXmlWhitespace(badge) - } - return badge } -module.exports = { plastic, flat, flatSquare, social, forTheBadge } +export default { + plastic: params => Plastic.render(params), + flat: params => Flat.render(params), + 'flat-square': params => FlatSquare.render(params), + social, + 'for-the-badge': forTheBadge, +} diff --git a/badge-maker/lib/color.js b/badge-maker/lib/color.js index 6273747f369e8..1935650c80f6c 100644 --- a/badge-maker/lib/color.js +++ b/badge-maker/lib/color.js @@ -1,18 +1,16 @@ -'use strict' - -const isCSSColor = require('is-css-color') +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,11 +31,15 @@ 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) } -function normalizeColor(color) { +function isCSSColor(color) { + return typeof color === 'string' && fromString(color.trim()) +} + +export function normalizeColor(color) { if (color === undefined) { return undefined } else if (color in namedColors) { @@ -45,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 { @@ -53,7 +55,7 @@ function normalizeColor(color) { } } -function toSvgColor(color) { +export function toSvgColor(color) { const normalized = normalizeColor(color) if (normalized in namedColors) { return namedColors[normalized] @@ -64,9 +66,13 @@ function toSvgColor(color) { } } -module.exports = { - namedColors, - isHexColor, - normalizeColor, - toSvgColor, +export function brightness(color) { + if (color) { + const cssColor = fromString(color) + if (cssColor) { + const rgb = cssColor.toRgbaArray() + return +((rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 255000).toFixed(2) + } + } + return 0 } diff --git a/badge-maker/lib/color.spec.js b/badge-maker/lib/color.spec.js index 8b71cc0252372..eaef02adcaa3e 100644 --- a/badge-maker/lib/color.spec.js +++ b/badge-maker/lib/color.spec.js @@ -1,10 +1,8 @@ -'use strict' - -const { test, given, forCases } = require('sazerac') -const { isHexColor, normalizeColor, toSvgColor } = 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(''), @@ -20,18 +18,40 @@ 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') + given('#fffeffff').expect('#fffeffff') given('#ABC123').expect('#abc123') given('papayawhip').expect('papayawhip') given('purple').expect('purple') + given(' blue ').expect(' blue ') + given('rgb(100%, 200%, 222%)').expect('rgb(100%, 200%, 222%)') + given('rgb(122, 200, 222)').expect('rgb(122, 200, 222)') + given('rgb(122, 200, 222, 1)').expect('rgb(122, 200, 222, 1)') + given('rgba(100, 20, 111, 1)').expect('rgba(100, 20, 111, 1)') + given('hsl(122, 200%, 222%)').expect('hsl(122, 200%, 222%)') + given('hsla(122, 200%, 222%, 1)').expect('hsla(122, 200%, 222%, 1)') forCases([ + given(), given(''), given('not-a-color'), + given('#ABCFGH'), + given('rgb(-100, 20, 111)'), + given('rgb(100%, 200, 222)'), + given('rgba(-100, 20, 111, 1.1)'), + given('hsl(122, 200, 222, 1)'), + given('hsl(122, 200, 222)'), + given('hsl(122, 200, 222%)'), given(undefined), given(null), given(true), + given(1), + given(Math), given([]), given({}), given(() => {}), @@ -41,17 +61,26 @@ 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') }) + +test(brightness, () => { + given('white').expect(1) + given('black').expect(0) + given('#abc123').expect(0.66) + given('rgb(10, 5, 128)').expect(0.08) + given(undefined).expect(0) + given('not-a-color').expect(0) +}) 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 49bef6c58aee8..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'] - stringFields.forEach(function(field) { + 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,33 +48,43 @@ 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()})`, ) } }) - // convert "public" format to "internal" format - cleaned.text = [cleaned.label || '', cleaned.message] - delete cleaned.label - delete cleaned.message - if ('style' in cleaned) { - cleaned.template = cleaned.style - delete cleaned.style - } + // Legacy. + cleaned.label = cleaned.label || '' return cleaned } @@ -71,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' - * @returns {string} Badge in SVG or JSON format + * @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 27ed81591d7b0..aafedc80dfdf0 100644 --- a/badge-maker/lib/index.spec.js +++ b/badge-maker/lib/index.spec.js @@ -1,21 +1,19 @@ -'use strict' +import { expect } from 'chai' +import { makeBadge, ValidationError } from './index.js' -const { expect } = require('chai') -const isSvg = require('is-svg') -const { makeBadge, ValidationError } = require('.') - -describe('makeBadge function', function() { - it('should produce badge with valid input', function() { +describe('makeBadge function', 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,53 +21,83 @@ 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() { + it('should throw a ValidationError with invalid inputs', function () { ;[null, undefined, 7, 'foo', 4.25].forEach(x => { 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 e9fc223855b27..a02400c2ea068 100644 --- a/badge-maker/lib/make-badge.js +++ b/badge-maker/lib/make-badge.js @@ -1,31 +1,28 @@ -'use strict' - -const camelcase = require('camelcase') -const { normalizeColor, toSvgColor } = require('./color') -const badgeRenderers = require('./badge-renderers') +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, - template = 'flat', - text, + style = 'flat', + label, + message, color, labelColor, logo, - logoPosition, + logoSize, logoWidth, links = ['', ''], + idSuffix, }) { // String coercion and whitespace removal. - text = text.map(value => `${value}`.trim()) - - const [label, message] = text - - color = normalizeColor(color) - labelColor = normalizeColor(labelColor) + label = `${label}`.trim() + message = `${message}`.trim() // This ought to be the responsibility of the server, not `makeBadge`. if (format === 'json') { @@ -33,32 +30,36 @@ module.exports = function makeBadge({ label, message, logoWidth, - color, - labelColor, + // Only call normalizeColor for the JSON case: this is handled + // internally by toSvgColor in the SVG case. + color: normalizeColor(color), + labelColor: normalizeColor(labelColor), link: links, name: label, value: message, + idSuffix, }) } - const methodName = camelcase(template) - if (!(methodName in badgeRenderers)) { - throw new Error(`Unknown template: '${template}'`) + const render = badgeRenderers[style] + if (!render) { + throw new Error(`Unknown badge style: '${style}'`) } - const render = badgeRenderers[methodName] - logoWidth = +logoWidth || (logo ? 14 : 0) + logoWidth = +logoWidth || (logo ? DEFAULT_LOGO_HEIGHT : 0) - return render({ - label, - message, - links, - logo, - logoPosition, - logoWidth, - logoPadding: logo && label.length ? 3 : 0, - color: toSvgColor(color), - labelColor: toSvgColor(labelColor), - minify: true, - }) + return stripXmlWhitespace( + render({ + label, + message, + links, + logo, + 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 152f07b006cff..819401266440b 100644 --- a/badge-maker/lib/make-badge.spec.js +++ b/badge-maker/lib/make-badge.spec.js @@ -1,31 +1,34 @@ -'use strict' - -const { test, given, forCases } = require('sazerac') -const { expect } = require('chai') -const snapshot = require('snap-shot-it') -const isSvg = require('is-svg') -const makeBadge = require('./make-badge') +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') { return JSON.parse( makeBadge({ - text: ['name', 'Bob'], + label: 'name', + message: 'Bob', [colorAttr]: color, format: 'json', - }) + }), ).color } -describe('The badge generator', function() { - describe('color test', function() { +describe('The badge generator', function () { + describe('color test', 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'), @@ -34,10 +37,14 @@ describe('The badge generator', function() { ]).expect('#abc123') // valid rgb(a) given('rgb(0,128,255)').expect('rgb(0,128,255)') + given('rgb(220,128,255,0.5)').expect('rgb(220,128,255,0.5)') + given('rgba(0,0,255)').expect('rgba(0,0,255)') given('rgba(0,128,255,0)').expect('rgba(0,128,255,0)') // valid hsl(a) given('hsl(100, 56%, 10%)').expect('hsl(100, 56%, 10%)') + given('hsl(360,50%,50%,0.5)').expect('hsl(360,50%,50%,0.5)') given('hsla(25,20%,0%,0.1)').expect('hsla(25,20%,0%,0.1)') + given('hsla(0,50%,101%)').expect('hsla(0,50%,101%)') // CSS named color. given('papayawhip').expect('papayawhip') // Shields named color. @@ -53,47 +60,45 @@ describe('The badge generator', function() { // invalid hex given('#123red'), // contains letter above F given('#red'), // contains letter above F - // invalid rgb(a) - given('rgb(220,128,255,0.5)'), // has alpha - given('rgba(0,0,255)'), // no alpha - // invalid hsl(a) - given('hsl(360,50%,50%,0.5)'), // has alpha - given('hsla(0,50%,101%)'), // no alpha // neither a css named color nor colorscheme given('notacolor'), given('bluish'), given('almostred'), given('brightmaroon'), - given('cactus') + given('cactus'), ).expect(undefined) }) }) - describe('color aliases', function() { + 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() { - const svg = makeBadge({ text: ['cactus', 'grown'], format: 'svg' }) - expect(svg) + describe('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() { - const svg = makeBadge({ text: ['cactus', 'grown'], format: 'svg' }) - snapshot(svg) + it('should match snapshot', async function () { + await expectBadgeToMatchSnapshot({ + label: 'cactus', + message: 'grown', + format: 'svg', + }) }) }) - describe('JSON', function() { - it('should produce the expected JSON', function() { + describe('JSON', function () { + it('should produce the expected JSON', function () { const json = makeBadge({ - text: ['cactus', 'grown'], + label: 'cactus', + message: 'grown', format: 'json', links: ['https://example.com/', 'https://other.example.com/'], }) @@ -106,466 +111,652 @@ describe('The badge generator', function() { }) }) - it('should replace undefined svg template with "flat"', function() { + it('should replace undefined svg badge style with "flat"', async function () { + const { default: isSvg } = await import('is-svg') const jsonBadgeWithUnknownStyle = makeBadge({ - text: ['name', 'Bob'], + label: 'name', + message: 'Bob', format: 'svg', }) const jsonBadgeWithDefaultStyle = makeBadge({ - text: ['name', 'Bob'], + label: 'name', + message: 'Bob', format: 'svg', - template: 'flat', + style: 'flat', }) expect(jsonBadgeWithUnknownStyle) .to.equal(jsonBadgeWithDefaultStyle) .and.to.satisfy(isSvg) }) - it('should fail with unknown svg template', function() { + it('should fail with unknown svg badge style', function () { expect(() => makeBadge({ - text: ['name', 'Bob'], + label: 'name', + message: 'Bob', format: 'svg', - template: 'unknown_style', - }) - ).to.throw(Error, "Unknown template: 'unknown_style'") + 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() { - snapshot( - makeBadge({ - text: ['cactus', 'grown'], - format: 'svg', - template: 'flat', - color: '#b3e', - labelColor: '#0f0', - }) - ) + describe('"flat" template badge generation', function () { + it('should match snapshots: message/label, no logo', async function () { + await expectBadgeToMatchSnapshot({ + label: 'cactus', + message: 'grown', + format: 'svg', + style: 'flat', + color: '#b3e', + labelColor: '#0f0', + }) }) - it('should match snapshots: message/label, with logo', function() { - snapshot( - makeBadge({ - text: ['cactus', 'grown'], - format: 'svg', - template: 'flat', - color: '#b3e', - labelColor: '#0f0', - logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', - }) - ) + it('should match snapshots: message/label, with logo', async function () { + await expectBadgeToMatchSnapshot({ + label: 'cactus', + message: 'grown', + format: 'svg', + style: 'flat', + color: '#b3e', + labelColor: '#0f0', + logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', + }) }) - it('should match snapshots: message only, no logo', function() { - snapshot( - makeBadge({ - text: ['', 'grown'], - format: 'svg', - template: 'flat', - color: '#b3e', - }) - ) + 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, with logo', function() { - snapshot( - makeBadge({ - text: ['', 'grown'], - format: 'svg', - template: 'flat', - color: '#b3e', - logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', - }) - ) + it('should match snapshots: message only, no logo', async function () { + await expectBadgeToMatchSnapshot({ + label: '', + message: 'grown', + format: 'svg', + style: 'flat', + color: '#b3e', + }) }) - it('should match snapshots: message only, with logo and labelColor', function() { - snapshot( - makeBadge({ - text: ['', 'grown'], - format: 'svg', - template: 'flat', - color: '#b3e', - labelColor: '#0f0', - logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', - }) - ) + it('should match snapshots: message only, with logo', async function () { + await expectBadgeToMatchSnapshot({ + label: '', + message: 'grown', + format: 'svg', + style: 'flat', + color: '#b3e', + logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', + }) }) - it('should match snapshots: message/label, with links', function() { - snapshot( - makeBadge({ - text: ['cactus', 'grown'], - format: 'svg', - template: 'flat', - color: '#b3e', - labelColor: '#0f0', - links: ['https://shields.io/', 'https://www.google.co.uk/'], - }) - ) + it('should match snapshots: message only, with logo and labelColor', async function () { + await expectBadgeToMatchSnapshot({ + label: '', + message: 'grown', + format: 'svg', + style: 'flat', + color: '#b3e', + labelColor: '#0f0', + logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', + }) + }) + + it('should match snapshots: message/label, with links', async function () { + await expectBadgeToMatchSnapshot({ + label: 'cactus', + message: 'grown', + format: 'svg', + style: 'flat', + color: '#b3e', + labelColor: '#0f0', + links: ['https://shields.io/', 'https://www.google.co.uk/'], + }) + }) + + it('should match snapshots: black text when the label color is light', async function () { + await expectBadgeToMatchSnapshot({ + label: 'cactus', + message: 'grown', + format: 'svg', + style: 'flat', + color: '#000', + labelColor: '#f3f3f3', + }) + }) + + it('should match snapshots: black text when the message color is light', async function () { + await expectBadgeToMatchSnapshot({ + label: 'cactus', + message: 'grown', + format: 'svg', + style: 'flat', + color: '#e2ffe1', + labelColor: '#000', + }) }) }) - describe('"flat-square" template badge generation', function() { - it('should match snapshots: message/label, no logo', function() { - snapshot( - makeBadge({ - text: ['cactus', 'grown'], - format: 'svg', - template: 'flat-square', - color: '#b3e', - labelColor: '#0f0', - }) - ) + describe('"flat-square" template badge generation', function () { + it('should match snapshots: message/label, no logo', async function () { + await expectBadgeToMatchSnapshot({ + label: 'cactus', + message: 'grown', + format: 'svg', + style: 'flat-square', + color: '#b3e', + labelColor: '#0f0', + }) }) - it('should match snapshots: message/label, with logo', function() { - snapshot( - makeBadge({ - text: ['cactus', 'grown'], - format: 'svg', - template: 'flat-square', - color: '#b3e', - labelColor: '#0f0', - logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', - }) - ) + it('should match snapshots: message/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 only, no logo', function() { - snapshot( - makeBadge({ - text: ['', 'grown'], - format: 'svg', - template: 'flat-square', - color: '#b3e', - }) - ) + it('should match snapshots: message with custom suffix', async function () { + await expectBadgeToMatchSnapshot({ + label: 'cactus', + message: 'grown', + format: 'svg', + style: 'flat-square', + color: '#b3e', + labelColor: '#0f0', + logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', + idSuffix: '1', + }) }) - it('should match snapshots: message only, with logo', function() { - snapshot( - makeBadge({ - text: ['', 'grown'], - format: 'svg', - template: 'flat-square', - color: '#b3e', - logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', - }) - ) + it('should match snapshots: message only, no logo', async function () { + await expectBadgeToMatchSnapshot({ + label: '', + message: 'grown', + format: 'svg', + style: 'flat-square', + color: '#b3e', + }) }) - it('should match snapshots: message only, with logo and labelColor', function() { - snapshot( - makeBadge({ - text: ['', 'grown'], - format: 'svg', - template: 'flat-square', - color: '#b3e', - labelColor: '#0f0', - logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', - }) - ) + it('should match snapshots: message only, with logo', async function () { + await expectBadgeToMatchSnapshot({ + label: '', + message: 'grown', + format: 'svg', + style: 'flat-square', + color: '#b3e', + logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', + }) }) - it('should match snapshots: message/label, with links', function() { - snapshot( - makeBadge({ - text: ['cactus', 'grown'], - format: 'svg', - template: 'flat-square', - color: '#b3e', - labelColor: '#0f0', - links: ['https://shields.io/', 'https://www.google.co.uk/'], - }) - ) + it('should match snapshots: message only, with logo and labelColor', async function () { + await expectBadgeToMatchSnapshot({ + label: '', + message: 'grown', + format: 'svg', + style: 'flat-square', + color: '#b3e', + labelColor: '#0f0', + logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', + }) + }) + + it('should match snapshots: message/label, with links', async function () { + await expectBadgeToMatchSnapshot({ + label: 'cactus', + message: 'grown', + format: 'svg', + style: 'flat-square', + color: '#b3e', + labelColor: '#0f0', + links: ['https://shields.io/', 'https://www.google.co.uk/'], + }) + }) + + it('should match snapshots: black text when the label color is light', async function () { + await expectBadgeToMatchSnapshot({ + label: 'cactus', + message: 'grown', + format: 'svg', + style: 'flat-square', + color: '#000', + labelColor: '#f3f3f3', + }) + }) + + it('should match snapshots: black text when the message color is light', async function () { + await expectBadgeToMatchSnapshot({ + label: 'cactus', + message: 'grown', + format: 'svg', + style: 'flat-square', + color: '#e2ffe1', + labelColor: '#000', + }) }) }) - describe('"plastic" template badge generation', function() { - it('should match snapshots: message/label, no logo', function() { - snapshot( - makeBadge({ - text: ['cactus', 'grown'], - format: 'svg', - template: 'plastic', - color: '#b3e', - labelColor: '#0f0', - }) - ) + describe('"plastic" template badge generation', function () { + it('should match snapshots: message/label, no logo', async function () { + await expectBadgeToMatchSnapshot({ + label: 'cactus', + message: 'grown', + format: 'svg', + style: 'plastic', + color: '#b3e', + labelColor: '#0f0', + }) }) - it('should match snapshots: message/label, with logo', function() { - snapshot( - makeBadge({ - text: ['cactus', 'grown'], - format: 'svg', - template: 'plastic', - color: '#b3e', - labelColor: '#0f0', - logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', - }) - ) + it('should match snapshots: message/label, with logo', async function () { + await expectBadgeToMatchSnapshot({ + label: 'cactus', + message: 'grown', + format: 'svg', + style: 'plastic', + color: '#b3e', + labelColor: '#0f0', + logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', + }) }) - it('should match snapshots: message only, no logo', function() { - snapshot( - makeBadge({ - text: ['', 'grown'], - format: 'svg', - template: 'plastic', - color: '#b3e', - }) - ) + 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, with logo', function() { - snapshot( - makeBadge({ - text: ['', 'grown'], - format: 'svg', - template: 'plastic', - color: '#b3e', - logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', - }) - ) + it('should match snapshots: message only, no logo', async function () { + await expectBadgeToMatchSnapshot({ + label: '', + message: 'grown', + format: 'svg', + style: 'plastic', + color: '#b3e', + }) }) - it('should match snapshots: message only, with logo and labelColor', function() { - snapshot( - makeBadge({ - text: ['', 'grown'], - format: 'svg', - template: 'plastic', - color: '#b3e', - labelColor: '#0f0', - logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', - }) - ) + it('should match snapshots: message only, with logo', async function () { + await expectBadgeToMatchSnapshot({ + label: '', + message: 'grown', + format: 'svg', + style: 'plastic', + color: '#b3e', + logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', + }) }) - it('should match snapshots: message/label, with links', function() { - snapshot( - makeBadge({ - text: ['cactus', 'grown'], - format: 'svg', - template: 'plastic', - color: '#b3e', - labelColor: '#0f0', - links: ['https://shields.io/', 'https://www.google.co.uk/'], - }) - ) + it('should match snapshots: message only, with logo and labelColor', async function () { + await expectBadgeToMatchSnapshot({ + label: '', + message: 'grown', + format: 'svg', + style: 'plastic', + color: '#b3e', + labelColor: '#0f0', + logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', + }) }) - }) - describe('"for-the-badge" template badge generation', function() { - // https://github.com/badges/shields/issues/1280 - it('numbers should produce a string', function() { - const svg = makeBadge({ - text: [1998, 1999], + it('should match snapshots: message/label, with links', async function () { + await expectBadgeToMatchSnapshot({ + label: 'cactus', + message: 'grown', format: 'svg', - template: 'for-the-badge', + style: 'plastic', + color: '#b3e', + labelColor: '#0f0', + links: ['https://shields.io/', 'https://www.google.co.uk/'], }) - expect(svg) - .to.include('1998') - .and.to.include('1999') }) - it('lowercase/mixedcase string should produce uppercase string', function() { - const svg = makeBadge({ - text: ['Label', '1 string'], + it('should match snapshots: black text when the label color is light', async function () { + await expectBadgeToMatchSnapshot({ + label: 'cactus', + message: 'grown', format: 'svg', - template: 'for-the-badge', + style: 'plastic', + color: '#000', + labelColor: '#f3f3f3', }) - expect(svg) - .to.include('LABEL') - .and.to.include('1 STRING') }) - it('should match snapshots: message/label, no logo', function() { - snapshot( - makeBadge({ - text: ['cactus', 'grown'], - format: 'svg', - template: 'for-the-badge', - color: '#b3e', - labelColor: '#0f0', - }) - ) + it('should match snapshots: black text when the message color is light', async function () { + await expectBadgeToMatchSnapshot({ + label: 'cactus', + message: 'grown', + format: 'svg', + style: 'plastic', + color: '#e2ffe1', + labelColor: '#000', + }) }) + }) - it('should match snapshots: message/label, with logo', function() { - snapshot( + describe('"for-the-badge" template badge generation', function () { + // https://github.com/badges/shields/issues/1280 + it('numbers should produce a string', function () { + expect( makeBadge({ - text: ['cactus', 'grown'], + label: 1998, + message: 1999, format: 'svg', - template: 'for-the-badge', - color: '#b3e', - labelColor: '#0f0', - logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', - }) + style: 'for-the-badge', + }), ) + .to.include('1998') + .and.to.include('1999') }) - it('should match snapshots: message only, no logo', function() { - snapshot( + it('lowercase/mixedcase string should produce uppercase string', function () { + expect( makeBadge({ - text: ['', 'grown'], + label: 'Label', + message: '1 string', format: 'svg', - template: 'for-the-badge', - color: '#b3e', - }) + style: 'for-the-badge', + }), ) + .to.include('LABEL') + .and.to.include('1 STRING') }) - it('should match snapshots: message only, with logo', function() { - snapshot( - makeBadge({ - text: ['', 'grown'], - format: 'svg', - template: 'for-the-badge', - color: '#b3e', - logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', - }) - ) + 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 only, with logo and labelColor', function() { - snapshot( - makeBadge({ - text: ['', 'grown'], - format: 'svg', - template: 'for-the-badge', - color: '#b3e', - labelColor: '#0f0', - logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', - }) - ) + it('should match snapshots: message/label, with 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 links', function() { - snapshot( - makeBadge({ - text: ['cactus', 'grown'], - format: 'svg', - template: 'for-the-badge', - color: '#b3e', - labelColor: '#0f0', - links: ['https://shields.io/', 'https://www.google.co.uk/'], - }) - ) + it('should match snapshots: message with custom suffix', 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', + idSuffix: '1', + }) }) - }) - describe('"social" template badge generation', function() { - it('should produce capitalized string for badge key', function() { - const svg = makeBadge({ - text: ['some-key', 'some-value'], + it('should match snapshots: message only, no logo', async function () { + await expectBadgeToMatchSnapshot({ + label: '', + message: 'grown', format: 'svg', - template: 'social', + style: 'for-the-badge', + color: '#b3e', }) - expect(svg) - .to.include('Some-key') - .and.to.include('some-value') }) - // https://github.com/badges/shields/issues/1606 - it('should handle empty strings used as badge keys', function() { - const svg = makeBadge({ - text: ['', 'some-value'], - format: 'json', - template: 'social', + it('should match snapshots: message only, with logo', async function () { + await expectBadgeToMatchSnapshot({ + label: '', + message: 'grown', + format: 'svg', + style: 'for-the-badge', + color: '#b3e', + logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', }) - expect(svg) - .to.include('""') - .and.to.include('some-value') }) - it('should match snapshots: message/label, no logo', function() { - snapshot( - makeBadge({ - text: ['cactus', 'grown'], - format: 'svg', - template: 'social', - color: '#b3e', - labelColor: '#0f0', - }) - ) + it('should match snapshots: message only, with logo and labelColor', async function () { + await expectBadgeToMatchSnapshot({ + label: '', + 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() { - snapshot( - makeBadge({ - text: ['cactus', 'grown'], - format: 'svg', - template: 'social', - color: '#b3e', - labelColor: '#0f0', - logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', - }) - ) + it('should match snapshots: message/label, with links', async function () { + await expectBadgeToMatchSnapshot({ + label: 'cactus', + message: 'grown', + format: 'svg', + style: 'for-the-badge', + color: '#b3e', + labelColor: '#0f0', + links: ['https://shields.io/', 'https://www.google.co.uk/'], + }) }) - it('should match snapshots: message only, no logo', function() { - snapshot( - makeBadge({ - text: ['', 'grown'], - format: 'svg', - template: 'social', - color: '#b3e', - }) - ) + it('should match snapshots: black text when the label color is light', async function () { + await expectBadgeToMatchSnapshot({ + label: 'cactus', + message: 'grown', + format: 'svg', + style: 'for-the-badge', + color: '#000', + labelColor: '#f3f3f3', + }) }) - it('should match snapshots: message only, with logo', function() { - snapshot( - makeBadge({ - text: ['', 'grown'], - format: 'svg', - template: 'social', - color: '#b3e', - logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', - }) - ) + it('should match snapshots: black text when the message color is light', async function () { + await expectBadgeToMatchSnapshot({ + label: 'cactus', + message: 'grown', + format: 'svg', + style: 'for-the-badge', + color: '#e2ffe1', + labelColor: '#000', + }) }) + }) - it('should match snapshots: message only, with logo and labelColor', function() { - snapshot( + describe('"social" template badge generation', function () { + it('should produce capitalized string for badge key', function () { + expect( makeBadge({ - text: ['', 'grown'], + label: 'some-key', + message: 'some-value', format: 'svg', - template: 'social', - color: '#b3e', - labelColor: '#0f0', - logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', - }) + style: 'social', + }), ) + .to.include('Some-key') + .and.to.include('some-value') }) - it('should match snapshots: message/label, with links', function() { - snapshot( + // https://github.com/badges/shields/issues/1606 + it('should handle empty strings used as badge keys', function () { + expect( makeBadge({ - text: ['cactus', 'grown'], - format: 'svg', - template: 'social', - color: '#b3e', - labelColor: '#0f0', - links: ['https://shields.io/', 'https://www.google.co.uk/'], - }) + label: '', + message: 'some-value', + format: 'json', + style: 'social', + }), ) + .to.include('""') + .and.to.include('some-value') + }) + + 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 with custom suffix', async function () { + await expectBadgeToMatchSnapshot({ + label: 'cactus', + message: 'grown', + format: 'svg', + style: 'social', + 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', + style: 'social', + color: '#b3e', + }) + }) + + it('should match snapshots: message only, with logo', async function () { + await expectBadgeToMatchSnapshot({ + label: '', + message: 'grown', + format: 'svg', + style: 'social', + color: '#b3e', + logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', + }) + }) + + it('should match snapshots: message only, with logo and labelColor', async function () { + await expectBadgeToMatchSnapshot({ + label: '', + message: 'grown', + format: 'svg', + style: 'social', + color: '#b3e', + labelColor: '#0f0', + logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', + }) + }) + + it('should match snapshots: message/label, with links', async function () { + await expectBadgeToMatchSnapshot({ + label: 'cactus', + message: 'grown', + format: 'svg', + style: 'social', + color: '#b3e', + labelColor: '#0f0', + links: ['https://shields.io/', 'https://www.google.co.uk/'], + }) + }) + }) + + describe('badges with logos should always produce the same badge', function () { + it('default badge with logo', async function () { + await expectBadgeToMatchSnapshot({ + label: 'label', + message: 'message', + format: 'svg', + logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu', + }) }) }) - describe('badges with logos should always produce the same badge', function() { - it('badge with logo', function() { - const svg = makeBadge({ - text: ['label', 'message'], + 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', }) - snapshot(svg) }) }) }) diff --git a/badge-maker/lib/xml.js b/badge-maker/lib/xml.js new file mode 100644 index 0000000000000..54d2ad0ff4057 --- /dev/null +++ b/badge-maker/lib/xml.js @@ -0,0 +1,92 @@ +/** + * @module + */ + +export function stripXmlWhitespace(xml) { + return xml.replace(/>\s+/g, '>').replace(/<\s+/g, '<').trim() +} + +export function escapeXml(s) { + if (typeof s === 'number') { + return s + } else if (s === undefined || typeof s !== 'string') { + return undefined + } else { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + } +} + +/** + * Representation of an XML element + */ +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=[]] + * Array of objects to render inside the tag. content may contain a mix of + * 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 + */ + constructor({ name, content = [], attrs = {} }) { + this.name = name + this.content = content + this.attrs = attrs + } + + /** + * Render the XML element to a string, applying appropriate escaping + * + * @returns {string} String representation of the XML element + */ + render() { + const attrsStr = Object.entries(this.attrs) + .map(([k, v]) => ` ${k}="${escapeXml(v)}"`) + .join('') + if (this.content.length > 0) { + const content = this.content + .map(function (el) { + if (typeof el.render === 'function') { + return el.render() + } else { + return escapeXml(el) + } + }) + .join(' ') + return stripXmlWhitespace( + `<${this.name}${attrsStr}>${content}`, + ) + } + return stripXmlWhitespace(`<${this.name}${attrsStr}/>`) + } +} + +/** + * Convenience class. Sometimes it is useful to return an object that behaves + * like an XmlElement but renders multiple XML tags (not wrapped in a ). + */ +export class ElementList { + constructor({ content = [] }) { + this.content = content + } + + render() { + return this.content.reduce( + (acc, el) => + typeof el.render === 'function' + ? acc + el.render() + : acc + escapeXml(el), + '', + ) + } +} diff --git a/badge-maker/lib/xml.spec.js b/badge-maker/lib/xml.spec.js new file mode 100644 index 0000000000000..a54e6a6d10cab --- /dev/null +++ b/badge-maker/lib/xml.spec.js @@ -0,0 +1,48 @@ +import { test, given } from 'sazerac' +import { XmlElement } from './xml.js' + +function testRender(params) { + return new XmlElement(params).render() +} + +describe('XmlElement class', function () { + test(testRender, () => { + given({ name: 'tag' }).expect('') + + given({ name: 'tag', content: ['text'] }).expect('text') + + given({ + name: 'tag', + content: ['not xml>>>', 'text', new XmlElement({ name: 'xml' })], + }).expect('not xml>>> text ') + + given({ + name: 'nested1', + content: [ + new XmlElement({ + name: 'nested2', + content: [new XmlElement({ name: 'nested3' })], + }), + ], + }).expect('') + + given({ + name: 'tag', + attrs: { + int: 47, + text: 'text', + escape: '', + }, + }).expect('') + + given({ + name: 'tag', + content: ['text'], + attrs: { + int: 47, + text: 'text', + escape: '', + }, + }).expect('text') + }) +}) diff --git a/badge-maker/package.json b/badge-maker/package.json index 947bf5bc615d7..03cd292e0b3e2 100644 --- a/badge-maker/package.json +++ b/badge-maker/package.json @@ -1,6 +1,14 @@ { "name": "badge-maker", - "version": "3.0.0-rc1", + "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", @@ -9,24 +17,27 @@ "image", "shields.io" ], - "main": "lib/index.js", + "types": "index.d.ts", "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" }, - "homepage": "http://shields.io", + "homepage": "https://shields.io", "bin": { "badge": "lib/badge-cli.js" }, "engines": { - "node": ">= 8", - "npm": ">= 5" + "node": ">=22" }, "collective": { "type": "opencollective", @@ -34,8 +45,8 @@ "logo": "https://opencollective.com/opencollective/logo.txt" }, "dependencies": { - "anafanafo": "^1.0.0", - "is-css-color": "^1.0.0" + "anafanafo": "2.0.0", + "css-color-converter": "^2.0.0" }, "scripts": { "test": "echo 'Run tests from parent dir'; false" diff --git a/config/custom-environment-variables.yml b/config/custom-environment-variables.yml index 45dba2c19b76d..e3b724ba9ce38 100644 --- a/config/custom-environment-variables.yml +++ b/config/custom-environment-variables.yml @@ -21,28 +21,26 @@ public: key: 'HTTPS_KEY' cert: 'HTTPS_CRT' - redirectUri: 'REDIRECT_URI' + redirectUrl: 'REDIRECT_URI' rasterUrl: 'RASTER_URL' - cors: - allowedOrigin: - __name: 'ALLOWED_ORIGIN' - __format: 'json' - - persistence: - dir: 'PERSISTENCE_DIR' - services: + bitbucket: + authorizedOrigins: 'BITBUCKET_ORIGINS' bitbucketServer: authorizedOrigins: 'BITBUCKET_SERVER_ORIGINS' drone: authorizedOrigins: 'DRONE_ORIGINS' + gitea: + authorizedOrigins: 'GITEA_ORIGINS' github: baseUri: 'GITHUB_URL' debug: enabled: 'GITHUB_DEBUG_ENABLED' intervalSeconds: 'GITHUB_DEBUG_INTERVAL_SECONDS' + gitlab: + authorizedOrigins: 'GITLAB_ORIGINS' jenkins: authorizedOrigins: 'JENKINS_ORIGINS' jira: @@ -51,50 +49,71 @@ public: authorizedOrigins: 'NEXUS_ORIGINS' npm: authorizedOrigins: 'NPM_ORIGINS' + obs: + authorizedOrigins: 'OBS_ORIGINS' + pypi: + baseUri: 'PYPI_URL' sonar: authorizedOrigins: 'SONAR_ORIGINS' teamcity: authorizedOrigins: 'TEAMCITY_ORIGINS' + weblate: + authorizedOrigins: 'WEBLATE_ORIGINS' trace: 'TRACE_SERVICES' cacheHeaders: defaultCacheLengthSeconds: 'BADGE_MAX_AGE_SECONDS' - rateLimit: 'RATE_LIMIT' + fetchLimitBytes: 'FETCH_LIMIT_BYTES' + userAgentBase: 'USER_AGENT_BASE' - fetchLimit: 'FETCH_LIMIT' + requestTimeoutSeconds: 'REQUEST_TIMEOUT_SECONDS' + requestTimeoutMaxAgeSeconds: 'REQUEST_TIMEOUT_MAX_AGE_SECONDS' - shieldsProductionHerokuHacks: 'SHIELDS_PRODUCTION_HEROKU_HACKS' + requireCloudflare: 'REQUIRE_CLOUDFLARE' private: azure_devops_token: 'AZURE_DEVOPS_TOKEN' - bintray_user: 'BINTRAY_USER' - bintray_apikey: 'BINTRAY_API_KEY' bitbucket_username: 'BITBUCKET_USER' bitbucket_password: 'BITBUCKET_PASS' bitbucket_server_username: 'BITBUCKET_SERVER_USER' 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' + gitlab_token: 'GITLAB_TOKEN' jenkins_user: 'JENKINS_USER' 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' - redis_url: 'REDIS_URL' + obs_user: 'OBS_USER' + obs_pass: 'OBS_PASS' + opencollective_token: 'OPENCOLLECTIVE_TOKEN' + pepy_key: 'PEPY_KEY' + postgres_url: 'POSTGRES_URL' + readthedocs_token: 'READTHEDOCS_TOKEN' + 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' + youtube_api_key: 'YOUTUBE_API_KEY' diff --git a/config/default.yml b/config/default.yml index e27a3cbc6d49d..f508753521837 100644 --- a/config/default.yml +++ b/config/default.yml @@ -1,7 +1,6 @@ public: bind: address: '::' - metrics: prometheus: enabled: false @@ -12,30 +11,29 @@ public: intervalSeconds: 15 ssl: isSecure: false - - cors: - allowedOrigin: [] - - persistence: - dir: './private' - 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 - - rateLimit: true - handleInternalErrors: true - - fetchLimit: '10MB' - - shieldsProductionHerokuHacks: false - + 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 f80029186153b..c45285d48e94b 100644 --- a/config/development.yml +++ b/config/development.yml @@ -2,9 +2,4 @@ public: bind: address: 'localhost' - cors: - allowedOrigin: ['http://localhost:3000'] - - rateLimit: false - handleInternalErrors: false diff --git a/config/local-shields-io-production.template.yml b/config/local-shields-io-production.template.yml index 6e021d35b07bb..7265ed65432d7 100644 --- a/config/local-shields-io-production.template.yml +++ b/config/local-shields-io-production.template.yml @@ -1,12 +1,19 @@ private: # These are the keys which are set on the production servers. + curseforge_api_key: ... + discord_bot_token: ... gh_client_id: ... gh_client_secret: ... - redis_url: ... + gitlab_token: ... + readthedocs_token: ... + reddit_client_id: ... + reddit_client_secret: ... sentry_dsn: ... shields_secret: ... sl_insight_userUuid: ... sl_insight_apiToken: ... twitch_client_id: ... twitch_client_secret: ... + weblate_api_key: ... wheelmap_token: ... + youtube_api_key: ... diff --git a/config/local.template.yml b/config/local.template.yml index ff9d531cc2197..1a61fd4f7e473 100644 --- a/config/local.template.yml +++ b/config/local.template.yml @@ -4,7 +4,16 @@ 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: '...' + readthedocs_token: '...' + reddit_client_id: '...' + reddit_client_secret: '...' twitch_client_id: '...' twitch_client_secret: '...' + weblate_api_key: '...' wheelmap_token: '...' + youtube_api_key: '...' diff --git a/config/shields-io-production.yml b/config/shields-io-production.yml index 6b5f822cce554..9ebb4c511002a 100644 --- a/config/shields-io-production.yml +++ b/config/shields-io-production.yml @@ -6,19 +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'] - - redirectUrl: 'https://shields.io/' + services: + gitlab: + authorizedOrigins: 'https://gitlab.com' rasterUrl: 'https://raster.shields.io' - -private: - # These are not really private; they should be moved to `public`. - shields_ips: ['192.99.59.72', '51.254.114.150', '149.56.96.133'] + userAgentBase: 'Shields.io' + requireCloudflare: true + requestTimeoutSeconds: 8 diff --git a/config/test.yml b/config/test.yml index 2c5bfca98f499..b1d558053a784 100644 --- a/config/test.yml +++ b/config/test.yml @@ -3,10 +3,10 @@ public: address: 'localhost' port: 1111 - rateLimit: false - redirectUrl: 'http://frontend.example.test' 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 f2bdb8be5f969..0000000000000 --- a/core/badge-urls/make-badge-url.d.ts +++ /dev/null @@ -1,110 +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, -}: { - baseUrl?: string - label: string - message: string - labelColor?: string - color?: string - style?: string - namedLogo?: string - format?: 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 415676589d3a8..71dcf29c9298d 100644 --- a/core/badge-urls/make-badge-url.js +++ b/core/badge-urls/make-badge-url.js @@ -1,162 +1,22 @@ -'use strict' - -const { URL } = require('url') -const queryString = require('query-string') -const { compile } = require('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 = '', -}) { - const path = [label, message, color].map(encodeField).join('-') - const outQueryString = queryString.stringify({ - labelColor, - style, - logo: namedLogo, - }) - 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}` -} - +// Avoid "Attempted import error: 'URL' is not exported from 'url'" in frontend. +import url from 'url' + +/** + * 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. - const { pathname, search } = new URL(badgeUrl, 'https://bogus.test') - const result = new URL(pathname, rasterUrl) + const { pathname, search } = new url.URL(badgeUrl, 'https://bogus.test') + const result = new url.URL(pathname, rasterUrl) result.search = search return result } -module.exports = { - 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 450b15d673136..0000000000000 --- a/core/badge-urls/make-badge-url.spec.js +++ /dev/null @@ -1,157 +0,0 @@ -'use strict' - -const { test, given } = require('sazerac') -const { - badgeUrlFromPath, - badgeUrlFromPattern, - encodeField, - staticBadgeUrl, - queryStringStaticBadgeUrl, - dynamicBadgeUrl, -} = require('./make-badge-url') - -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 81c7222fe43d5..27f6d74a40df7 100644 --- a/core/badge-urls/path-helpers.js +++ b/core/badge-urls/path-helpers.js @@ -1,21 +1,23 @@ -'use strict' - -// 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 - // Inline single underscore. - .replace(/([^_])_([^_])/g, '$1 $2') - // Leading or trailing underscore. - .replace(/([^_])_$/, '$1 ') - .replace(/^_([^_])/, ' $1') + // Single underscore. + .replace(/(^|[^_])((?:__)*)_(?!_)/g, '$1$2 ') // Double underscore and double dash. .replace(/__/g, '_') .replace(/--/g, '-') ) } -module.exports = { - escapeFormat, -} +export { escapeFormat } diff --git a/core/badge-urls/path-helpers.spec.js b/core/badge-urls/path-helpers.spec.js new file mode 100644 index 0000000000000..904660d1e517d --- /dev/null +++ b/core/badge-urls/path-helpers.spec.js @@ -0,0 +1,19 @@ +import { test, given } from 'sazerac' +import { escapeFormat } from './path-helpers.js' + +describe('Badge URL helper functions', function () { + test(escapeFormat, () => { + given('_single leading underscore').expect(' single leading underscore') + given('single trailing underscore_').expect('single trailing underscore ') + given('__double leading underscores').expect('_double leading underscores') + given('double trailing underscores__').expect( + 'double trailing underscores_', + ) + given('treble___underscores').expect('treble_ underscores') + given('fourfold____underscores').expect('fourfold__underscores') + given('double--dashes').expect('double-dashes') + given('treble---dashes').expect('treble--dashes') + given('fourfold----dashes').expect('fourfold--dashes') + given('once_in_a_blue--moon').expect('once in a blue-moon') + }) +}) diff --git a/core/base-service/auth-helper.js b/core/base-service/auth-helper.js index 6dedf1ba9e11e..4fadbc99bcd58 100644 --- a/core/base-service/auth-helper.js +++ b/core/base-service/auth-helper.js @@ -1,7 +1,13 @@ -'use strict' +import { URL } from 'url' +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' -const { URL } = require('url') -const { InvalidParameter } = require('./errors') +let jwtCache = Object.create(null) class AuthHelper { constructor( @@ -13,7 +19,7 @@ class AuthHelper { isRequired = false, defaultToEmptyStringForUser = false, }, - config + config, ) { if (!userKey && !passKey) { throw Error('Expected userKey or passKey to be set') @@ -76,7 +82,7 @@ class AuthHelper { } static _isInsecureSslRequest({ options = {} }) { - const { strictSSL = true } = options + const strictSSL = options?.https?.rejectUnauthorized ?? true return strictSSL !== true } @@ -89,7 +95,7 @@ class AuthHelper { } } - shouldAuthenticateRequest({ url, options = {} }) { + isAllowedOrigin(url) { let parsed try { parsed = new URL(url) @@ -99,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 && @@ -109,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 } /* @@ -133,7 +145,7 @@ class AuthHelper { const { options, ...rest } = requestParams return { options: { - auth, + ...auth, ...options, }, ...rest, @@ -142,13 +154,20 @@ class AuthHelper { withBasicAuth(requestParams) { return this._withAnyAuth(requestParams, requestParams => - this.constructor._mergeAuth(requestParams, this._basicAuth) + this.constructor._mergeAuth(requestParams, this._basicAuth), ) } - get _bearerAuthHeader() { + _bearerAuthHeader(bearerKey) { + const { _pass: pass } = this + return this.isConfigured + ? { Authorization: `${bearerKey} ${pass}` } + : undefined + } + + _apiKeyHeader(apiKeyHeader) { const { _pass: pass } = this - return this.isConfigured ? { Authorization: `Bearer ${pass}` } : undefined + return this.isConfigured ? { [apiKeyHeader]: pass } : undefined } static _mergeHeaders(requestParams, headers) { @@ -168,20 +187,32 @@ class AuthHelper { } } - withBearerAuthHeader(requestParams) { + withApiKeyHeader(requestParams, header = 'x-api-key') { return this._withAnyAuth(requestParams, requestParams => - this.constructor._mergeHeaders(requestParams, this._bearerAuthHeader) + this.constructor._mergeHeaders(requestParams, this._apiKeyHeader(header)), + ) + } + + withBearerAuthHeader( + requestParams, + bearerKey = 'Bearer', // lgtm [js/hardcoded-credentials] + ) { + return this._withAnyAuth(requestParams, requestParams => + this.constructor._mergeHeaders( + requestParams, + this._bearerAuthHeader(bearerKey), + ), ) } static _mergeQueryParams(requestParams, query) { const { - options: { qs: existingQuery, ...restOptions } = {}, + options: { searchParams: existingQuery, ...restOptions } = {}, ...rest } = requestParams return { options: { - qs: { + searchParams: { ...existingQuery, ...query, }, @@ -196,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), ) } } -module.exports = { AuthHelper } +function clearJwtCache() { + jwtCache = Object.create(null) +} + +export { AuthHelper, clearJwtCache } diff --git a/core/base-service/auth-helper.spec.js b/core/base-service/auth-helper.spec.js index bec321dde1cf0..beb4142eb2e88 100644 --- a/core/base-service/auth-helper.spec.js +++ b/core/base-service/auth-helper.spec.js @@ -1,24 +1,48 @@ -'use strict' +import dayjs from 'dayjs' +import nock from 'nock' +import { expect } from 'chai' +import { test, given, forCases } from 'sazerac' +import { AuthHelper, clearJwtCache } from './auth-helper.js' +import { InvalidParameter, InvalidResponse } from './errors.js' -const { expect } = require('chai') -const { test, given, forCases } = require('sazerac') -const { AuthHelper } = require('./auth-helper') -const { InvalidParameter } = require('./errors') +function base64UrlEncode(input) { + const base64 = btoa(JSON.stringify(input)) + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} -describe('AuthHelper', function() { - describe('constructor checks', function() { - it('throws without userKey or passKey', function() { +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() { + 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() { + it('throws when authorizedOrigins is not an array', function () { expect( () => new AuthHelper( @@ -27,17 +51,17 @@ describe('AuthHelper', function() { passKey: 'myci_pass', authorizedOrigins: true, }, - { private: {} } - ) + { private: {} }, + ), ).to.throw(Error, 'Expected authorizedOrigins to be an array of origins') }) }) - describe('isValid', function() { + describe('isValid', function () { function validate(config, privateConfig) { return new AuthHelper( { authorizedOrigins: ['https://example.test'], ...config }, - { private: privateConfig } + { private: privateConfig }, ).isValid } test(validate, () => { @@ -45,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' }), @@ -72,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 }, {}), @@ -89,88 +113,93 @@ describe('AuthHelper', function() { }) }) - describe('_basicAuth', function() { + describe('_basicAuth', 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', }) }) }) - describe('_isInsecureSslRequest', function() { + describe('_isInsecureSslRequest', function () { test(AuthHelper._isInsecureSslRequest, () => { 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) }) }) - describe('enforceStrictSsl', function() { + describe('enforceStrictSsl', function () { const authConfig = { userKey: 'myci_user', passKey: 'myci_pass', serviceKey: 'myci', } - context('by default', function() { + context('by default', function () { const authHelper = new AuthHelper(authConfig, { public: { services: { myci: { authorizedOrigins: ['http://myci.test'] } }, }, private: { myci_user: 'admin', myci_pass: 'abc123' }, }) - it('does not throw for secure requests', function() { + it('does not throw for secure requests', function () { expect(() => authHelper.enforceStrictSsl({})).not.to.throw() }) - it('throws for insecure requests', function() { + it('throws for insecure requests', function () { expect(() => - authHelper.enforceStrictSsl({ options: { strictSSL: false } }) + authHelper.enforceStrictSsl({ + options: { https: { rejectUnauthorized: false } }, + }), ).to.throw(InvalidParameter) }) }) - context("when strict SSL isn't required", function() { + context("when strict SSL isn't required", function () { const authHelper = new AuthHelper(authConfig, { public: { services: { @@ -182,25 +211,27 @@ describe('AuthHelper', function() { }, private: { myci_user: 'admin', myci_pass: 'abc123' }, }) - it('does not throw for secure requests', function() { + it('does not throw for secure requests', function () { expect(() => authHelper.enforceStrictSsl({})).not.to.throw() }) - it('does not throw for insecure requests', function() { + it('does not throw for insecure requests', function () { expect(() => - authHelper.enforceStrictSsl({ options: { strictSSL: false } }) + authHelper.enforceStrictSsl({ + options: { https: { rejectUnauthorized: false } }, + }), ).not.to.throw() }) }) }) - describe('shouldAuthenticateRequest', function() { + describe('shouldAuthenticateRequest', function () { const authConfig = { userKey: 'myci_user', passKey: 'myci_pass', serviceKey: 'myci', } - context('by default', function() { + context('by default', function () { const authHelper = new AuthHelper(authConfig, { public: { services: { @@ -213,20 +244,20 @@ describe('AuthHelper', function() { }) const shouldAuthenticateRequest = requestOptions => authHelper.shouldAuthenticateRequest(requestOptions) - describe('a secure request to an authorized origin', function() { + describe('a secure request to an authorized origin', function () { test(shouldAuthenticateRequest, () => { given({ url: 'https://myci.test/api' }).expect(true) }) }) - describe('an insecure request', function() { + describe('an insecure request', function () { test(shouldAuthenticateRequest, () => { given({ url: 'https://myci.test/api', - options: { strictSSL: false }, + options: { https: { rejectUnauthorized: false } }, }).expect(false) }) }) - describe('a request to an unauthorized origin', function() { + describe('a request to an unauthorized origin', function () { test(shouldAuthenticateRequest, () => { forCases([ given({ url: 'http://myci.test/api' }), @@ -237,7 +268,7 @@ describe('AuthHelper', function() { }) }) - context('when auth over insecure SSL is allowed', function() { + context('when auth over insecure SSL is allowed', function () { const authHelper = new AuthHelper(authConfig, { public: { services: { @@ -251,20 +282,20 @@ describe('AuthHelper', function() { }) const shouldAuthenticateRequest = requestOptions => authHelper.shouldAuthenticateRequest(requestOptions) - describe('a secure request to an authorized origin', function() { + describe('a secure request to an authorized origin', function () { test(shouldAuthenticateRequest, () => { given({ url: 'https://myci.test' }).expect(true) }) }) - describe('an insecure request', function() { + describe('an insecure request', function () { test(shouldAuthenticateRequest, () => { given({ url: 'https://myci.test', - options: { strictSSL: false }, + options: { https: { rejectUnauthorized: false } }, }).expect(true) }) }) - describe('a request to an unauthorized origin', function() { + describe('a request to an unauthorized origin', function () { test(shouldAuthenticateRequest, () => { forCases([ given({ url: 'http://myci.test' }), @@ -275,7 +306,7 @@ describe('AuthHelper', function() { }) }) - context('when the service is partly configured', function() { + context('when the service is partly configured', function () { const authHelper = new AuthHelper(authConfig, { public: { services: { @@ -289,7 +320,7 @@ describe('AuthHelper', function() { }) const shouldAuthenticateRequest = requestOptions => authHelper.shouldAuthenticateRequest(requestOptions) - describe('a secure request to an authorized origin', function() { + describe('a secure request to an authorized origin', function () { test(shouldAuthenticateRequest, () => { given({ url: 'https://myci.test' }).expect(false) }) @@ -297,7 +328,7 @@ describe('AuthHelper', function() { }) }) - describe('withBasicAuth', function() { + describe('withBasicAuth', function () { const authHelper = new AuthHelper( { userKey: 'myci_user', @@ -313,19 +344,20 @@ describe('AuthHelper', function() { }, }, private: { myci_user: 'admin', myci_pass: 'abc123' }, - } + }, ) const withBasicAuth = requestOptions => authHelper.withBasicAuth(requestOptions) - describe('authenticates a secure request to an authorized origin', function() { + describe('authenticates a secure request to an authorized origin', function () { test(withBasicAuth, () => { given({ url: 'https://myci.test/api', }).expect({ url: 'https://myci.test/api', options: { - auth: { user: 'admin', pass: 'abc123' }, + username: 'admin', + password: 'abc123', }, }) given({ @@ -337,13 +369,14 @@ describe('AuthHelper', function() { url: 'https://myci.test/api', options: { headers: { Accept: 'application/json' }, - auth: { user: 'admin', pass: 'abc123' }, + username: 'admin', + password: 'abc123', }, }) }) }) - describe('does not authenticate a request to an unauthorized origin', function() { + describe('does not authenticate a request to an unauthorized origin', function () { test(withBasicAuth, () => { given({ url: 'https://other.test/api', @@ -364,13 +397,162 @@ describe('AuthHelper', function() { }) }) - describe('throws on an insecure SSL request', function() { + describe('throws on an insecure SSL request', 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 e0199ee5e5cef..e70a81c0d4c52 100644 --- a/core/base-service/base-graphql.js +++ b/core/base-service/base-graphql.js @@ -2,12 +2,10 @@ * @module */ -'use strict' - -const { print } = require('graphql/language/printer') -const BaseService = require('./base') -const { InvalidResponse, ShieldsRuntimeError } = require('./errors') -const { parseJson } = require('./json') +import { print } from 'graphql/language/printer.js' +import BaseService from './base.js' +import { InvalidResponse, ShieldsRuntimeError } from './errors.js' +import { parseJson } from './json.js' function defaultTransformErrors(errors) { return new InvalidResponse({ prettyMessage: errors[0].message }) @@ -22,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 @@ -40,19 +40,30 @@ 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 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 * response and returns an instance of ShieldsRuntimeError. * 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, @@ -61,10 +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' @@ -72,16 +86,18 @@ class BaseGraphqlService extends BaseService { const { buffer } = await this._request({ url, options: mergedOptions, - errorMessages: httpErrorMessages, + httpErrors: httpErrorMessages, + systemErrors, + logErrors, }) - const json = this._parseJson(buffer) + const json = transformJson(this._parseJson(buffer)) if (json.errors) { const exception = transformErrors(json.errors) if (exception instanceof ShieldsRuntimeError) { throw exception } else { throw Error( - `transformErrors() must return a ShieldsRuntimeError; got ${exception}` + `transformErrors() must return a ShieldsRuntimeError; got ${exception}`, ) } } @@ -89,4 +105,4 @@ class BaseGraphqlService extends BaseService { } } -module.exports = BaseGraphqlService +export default BaseGraphqlService diff --git a/core/base-service/base-graphql.spec.js b/core/base-service/base-graphql.spec.js index 4f72887d2d68c..b22f96ce12ebc 100644 --- a/core/base-service/base-graphql.spec.js +++ b/core/base-service/base-graphql.spec.js @@ -1,26 +1,17 @@ -'use strict' - -const Joi = require('@hapi/joi') -const { expect } = require('chai') -const gql = require('graphql-tag') -const sinon = require('sinon') -const BaseGraphqlService = require('./base-graphql') -const { InvalidResponse } = require('./errors') +import Joi from 'joi' +import { expect } from 'chai' +import gql from 'graphql-tag' +import sinon from 'sinon' +import BaseGraphqlService from './base-graphql.js' +import { InvalidResponse } from './errors.js' const dummySchema = Joi.object({ requiredString: Joi.string().required(), }).required() class DummyGraphqlService extends BaseGraphqlService { - static get category() { - return 'cat' - } - - static get route() { - return { - base: 'foo', - } - } + static category = 'cat' + static route = { base: 'foo' } async handle() { const { requiredString } = await this._requestGraphql({ @@ -36,35 +27,35 @@ class DummyGraphqlService extends BaseGraphqlService { } } -describe('BaseGraphqlService', function() { - describe('Making requests', function() { - let sendAndCacheRequest - beforeEach(function() { - sendAndCacheRequest = sinon.stub().returns( +describe('BaseGraphqlService', function () { + describe('Making requests', function () { + let requestFetcher + beforeEach(function () { + 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({ @@ -75,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 () => ({ + describe('Making badges', function () { + it('handles valid json responses', async function () { + 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 () => ({ + it('handles json responses which do not match the schema', async function () { + 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', @@ -131,16 +122,16 @@ describe('BaseGraphqlService', function() { }) }) - it('handles unparseable json responses', async function() { - const sendAndCacheRequest = async () => ({ + it('handles unparseable json responses', async function () { + 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', @@ -149,17 +140,17 @@ describe('BaseGraphqlService', function() { }) }) - describe('Error handling', function() { - it('handles generic error', async function() { - const sendAndCacheRequest = async () => ({ + describe('Error handling', function () { + it('handles generic error', async function () { + 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', @@ -167,7 +158,7 @@ describe('BaseGraphqlService', function() { }) }) - it('handles custom error', async function() { + it('handles custom error', async function () { class WithErrorHandler extends DummyGraphqlService { async handle() { const { requiredString } = await this._requestGraphql({ @@ -178,7 +169,7 @@ describe('BaseGraphqlService', function() { requiredString } `, - transformErrors: function(errors) { + transformErrors: function (errors) { if (errors[0].message === 'oh noes!!') { return new InvalidResponse({ prettyMessage: 'a terrible thing has happened', @@ -190,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 3d786df49aacc..c87bafe038b2c 100644 --- a/core/base-service/base-json.js +++ b/core/base-service/base-json.js @@ -2,10 +2,8 @@ * @module */ -'use strict' - -const BaseService = require('./base') -const { parseJson } = require('./json') +import BaseService from './base.js' +import { parseJson } from './json.js' /** * Services which query a JSON endpoint should extend BaseJsonService @@ -16,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 @@ -30,28 +30,45 @@ 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) } } -module.exports = BaseJsonService +export default BaseJsonService diff --git a/core/base-service/base-json.spec.js b/core/base-service/base-json.spec.js index a6596fb4e028b..fc894d7a89fd9 100644 --- a/core/base-service/base-json.spec.js +++ b/core/base-service/base-json.spec.js @@ -1,24 +1,15 @@ -'use strict' - -const Joi = require('@hapi/joi') -const { expect } = require('chai') -const sinon = require('sinon') -const BaseJsonService = require('./base-json') +import Joi from 'joi' +import { expect } from 'chai' +import sinon from 'sinon' +import BaseJsonService from './base-json.js' const dummySchema = Joi.object({ requiredString: Joi.string().required(), }).required() class DummyJsonService extends BaseJsonService { - static get category() { - return 'cat' - } - - static get route() { - return { - base: 'foo', - } - } + static category = 'cat' + static route = { base: 'foo' } async handle() { const { requiredString } = await this._requestJson({ @@ -29,86 +20,86 @@ class DummyJsonService extends BaseJsonService { } } -describe('BaseJsonService', function() { - describe('Making requests', function() { - let sendAndCacheRequest - beforeEach(function() { - sendAndCacheRequest = sinon.stub().returns( +describe('BaseJsonService', function () { + describe('Making requests', function () { + let requestFetcher + beforeEach(function () { + 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 () => ({ + describe('Making badges', function () { + it('handles valid json responses', async function () { + 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 () => ({ + it('handles json responses which do not match the schema', async function () { + 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', @@ -116,16 +107,16 @@ describe('BaseJsonService', function() { }) }) - it('handles unparseable json responses', async function() { - const sendAndCacheRequest = async () => ({ + it('handles unparseable json responses', async function () { + 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-non-memory-caching.js b/core/base-service/base-non-memory-caching.js deleted file mode 100644 index f441caf7167a1..0000000000000 --- a/core/base-service/base-non-memory-caching.js +++ /dev/null @@ -1,74 +0,0 @@ -'use strict' - -const makeBadge = require('../../badge-maker/lib/make-badge') -const BaseService = require('./base') -const { MetricHelper } = require('./metric-helper') -const { setCacheHeaders } = require('./cache-headers') -const { makeSend } = require('./legacy-result-sender') -const coalesceBadge = require('./coalesce-badge') -const { prepareRoute, namedParamsForMatch } = require('./route') - -// Badges are subject to two independent types of caching: in-memory and -// downstream. -// -// Services deriving from `NonMemoryCachingBaseService` are not cached in -// memory on the server. This means that each request that hits the server -// triggers another call to the handler. When using badges for server -// diagnostics, that's useful! -// -// In contrast, The `handle()` function of most other `BaseService` -// subclasses is wrapped in onboard, in-memory caching. See `lib /request- -// handler.js` and `BaseService.prototype.register()`. -// -// All services, including those extending NonMemoryCachingBaseServices, may -// be cached _downstream_. This is governed by cache headers, which are -// configured by the service, the user's request, and the server's default -// cache length. -module.exports = class NonMemoryCachingBaseService extends BaseService { - static register({ camp, metricInstance }, serviceConfig) { - const { cacheHeaders: cacheHeaderConfig } = serviceConfig - const { _cacheLength: serviceDefaultCacheLengthSeconds } = this - const { regex, captureNames } = prepareRoute(this.route) - - const metricHelper = MetricHelper.create({ - metricInstance, - ServiceClass: this, - }) - - camp.route(regex, async (queryParams, match, end, ask) => { - const metricHandle = metricHelper.startRequest() - - const namedParams = namedParamsForMatch(captureNames, match, this) - const serviceData = await this.invoke( - {}, - serviceConfig, - namedParams, - queryParams - ) - - const badgeData = coalesceBadge( - queryParams, - serviceData, - this.defaultBadgeData, - this - ) - - // The final capture group is the extension. - const format = (match.slice(-1)[0] || '.svg').replace(/^\./, '') - badgeData.format = format - - const svg = makeBadge(badgeData) - - setCacheHeaders({ - cacheHeaderConfig, - serviceDefaultCacheLengthSeconds, - queryParams, - res: ask.res, - }) - - makeSend(format, ask.res, end)(svg) - - metricHandle.noteResponseSent() - }) - } -} diff --git a/core/base-service/base-static.js b/core/base-service/base-static.js index 3781d450e3578..0990d6ec44b6e 100644 --- a/core/base-service/base-static.js +++ b/core/base-service/base-static.js @@ -1,17 +1,23 @@ -'use strict' - -const makeBadge = require('../../badge-maker/lib/make-badge') -const BaseService = require('./base') -const { +import makeBadge from '../../badge-maker/lib/make-badge.js' +import BaseService from './base.js' +import { serverHasBeenUpSinceResourceCached, setCacheHeadersForStaticResource, -} = require('./cache-headers') -const { makeSend } = require('./legacy-result-sender') -const { MetricHelper } = require('./metric-helper') -const coalesceBadge = require('./coalesce-badge') -const { prepareRoute, namedParamsForMatch } = require('./route') +} from './cache-headers.js' +import { makeSend } from './legacy-result-sender.js' +import { MetricHelper } from './metric-helper.js' +import coalesceBadge from './coalesce-badge.js' +import { prepareRoute, namedParamsForMatch } from './route.js' -module.exports = class BaseStaticService extends BaseService { +/** + * 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) @@ -35,21 +41,25 @@ module.exports = 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 a79caeca82801..29051b78b95e1 100644 --- a/core/base-service/base-svg-scraping.js +++ b/core/base-service/base-svg-scraping.js @@ -2,15 +2,13 @@ * @module */ -'use strict' - // See available emoji at http://emoji.muan.co/ -const emojic = require('emojic') -const BaseService = require('./base') -const trace = require('./trace') -const { InvalidResponse } = require('./errors') +import emojic from 'emojic' +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 /** @@ -44,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 @@ -53,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 = { @@ -90,4 +102,4 @@ class BaseSvgScrapingService extends BaseService { } } -module.exports = BaseSvgScrapingService +export default BaseSvgScrapingService diff --git a/core/base-service/base-svg-scraping.spec.js b/core/base-service/base-svg-scraping.spec.js index 520b7b9a2ff93..24502a8584203 100644 --- a/core/base-service/base-svg-scraping.spec.js +++ b/core/base-service/base-svg-scraping.spec.js @@ -1,29 +1,16 @@ -'use strict' - -const { expect } = require('chai') -const sinon = require('sinon') -const Joi = require('@hapi/joi') -const makeBadge = require('../../badge-maker/lib/make-badge') -const BaseSvgScrapingService = require('./base-svg-scraping') - -function makeExampleSvg({ label, message }) { - return makeBadge({ text: ['this is the label', 'this is the result!'] }) -} +import { expect } from 'chai' +import sinon from 'sinon' +import Joi from 'joi' +import makeBadge from '../../badge-maker/lib/make-badge.js' +import BaseSvgScrapingService from './base-svg-scraping.js' const schema = Joi.object({ message: Joi.string().required(), }).required() class DummySvgScrapingService extends BaseSvgScrapingService { - static get category() { - return 'cat' - } - - static get route() { - return { - base: 'foo', - } - } + static category = 'cat' + static route = { base: 'foo' } async handle() { return this._requestSvg({ @@ -33,48 +20,45 @@ class DummySvgScrapingService extends BaseSvgScrapingService { } } -describe('BaseSvgScrapingService', function() { +describe('BaseSvgScrapingService', function () { const exampleLabel = 'this is the label' const exampleMessage = 'this is the result!' - const exampleSvg = makeExampleSvg({ - label: exampleLabel, - message: exampleMessage, - }) + const exampleSvg = makeBadge({ label: exampleLabel, message: exampleMessage }) - describe('valueFromSvgBadge', function() { - it('should find the correct value', 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 - beforeEach(function() { - sendAndCacheRequest = sinon.stub().returns( + describe('Making requests', function () { + let requestFetcher + beforeEach(function () { + 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({ @@ -82,7 +66,7 @@ describe('BaseSvgScrapingService', function() { url: 'http://example.com/foo.svg', options: { method: 'POST', - qs: { queryParam: 123 }, + searchParams: { queryParam: 123 }, }, }) return { message } @@ -90,42 +74,40 @@ 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 () => ({ + describe('Making badges', function () { + it('handles valid svg responses', async function () { + const requestFetcher = async () => ({ buffer: exampleSvg, res: { statusCode: 200 }, }) expect( await DummySvgScrapingService.invoke( - { sendAndCacheRequest }, - { handleInternalErrors: false } - ) + { requestFetcher }, + { handleInternalErrors: false }, + ), ).to.deep.equal({ message: exampleMessage, }) }) - it('allows overriding the valueMatcher', async function() { + it('allows overriding the valueMatcher', async function () { class WithValueMatcher extends BaseSvgScrapingService { - static get route() { - return {} - } + static route = {} async handle() { return this._requestSvg({ @@ -135,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 () => ({ + it('handles unparseable svg responses', async function () { + 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 d42a54e11d9a0..0afffeb1dc846 100644 --- a/core/base-service/base-xml.js +++ b/core/base-service/base-xml.js @@ -2,14 +2,12 @@ * @module */ -'use strict' - // See available emoji at http://emoji.muan.co/ -const emojic = require('emojic') -const fastXmlParser = require('fast-xml-parser') -const BaseService = require('./base') -const trace = require('./trace') -const { InvalidResponse } = require('./errors') +import emojic from 'emojic' +import { XMLParser, XMLValidator } from 'fast-xml-parser' +import BaseService from './base.js' +import trace from './trace.js' +import { InvalidResponse } from './errors.js' /** * Services which query a XML endpoint should extend BaseXmlService @@ -17,6 +15,8 @@ const { InvalidResponse } = require('./errors') * @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 @@ -24,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, }) @@ -68,4 +81,4 @@ class BaseXmlService extends BaseService { } } -module.exports = BaseXmlService +export default BaseXmlService diff --git a/core/base-service/base-xml.spec.js b/core/base-service/base-xml.spec.js index 7f36b209dd28e..cf22f02deac44 100644 --- a/core/base-service/base-xml.spec.js +++ b/core/base-service/base-xml.spec.js @@ -1,24 +1,15 @@ -'use strict' - -const Joi = require('@hapi/joi') -const { expect } = require('chai') -const sinon = require('sinon') -const BaseXmlService = require('./base-xml') +import Joi from 'joi' +import { expect } from 'chai' +import sinon from 'sinon' +import BaseXmlService from './base-xml.js' const dummySchema = Joi.object({ requiredString: Joi.string().required(), }).required() class DummyXmlService extends BaseXmlService { - static get category() { - return 'cat' - } - - static get route() { - return { - base: 'foo', - } - } + static category = 'cat' + static route = { base: 'foo' } async handle() { const { requiredString } = await this._requestXml({ @@ -29,81 +20,79 @@ class DummyXmlService extends BaseXmlService { } } -describe('BaseXmlService', function() { - describe('Making requests', function() { - let sendAndCacheRequest - beforeEach(function() { - sendAndCacheRequest = sinon.stub().returns( +describe('BaseXmlService', function () { + describe('Making requests', function () { + let requestFetcher + beforeEach(function () { + 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 get route() { - return {} - } + static route = {} async handle() { 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 () => ({ + describe('Making badges', function () { + it('handles valid xml responses', async function () { + 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', }) }) - it('parses XML response with custom parser options', async function() { + it('parses XML response with custom parser options', async function () { const customParserOption = { trimValues: false } class DummyXmlServiceWithParserOption extends DummyXmlService { async handle() { @@ -115,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 () => ({ + it('handles xml responses which do not match the schema', async function () { + 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', @@ -147,16 +136,16 @@ describe('BaseXmlService', function() { }) }) - it('handles unparseable xml responses', async function() { - const sendAndCacheRequest = async () => ({ + it('handles unparseable xml responses', async function () { + 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 e881dbb196947..358793e4de148 100644 --- a/core/base-service/base-yaml.js +++ b/core/base-service/base-yaml.js @@ -2,13 +2,11 @@ * @module */ -'use strict' - -const emojic = require('emojic') -const yaml = require('js-yaml') -const BaseService = require('./base') -const { InvalidResponse } = require('./errors') -const trace = require('./trace') +import emojic from 'emojic' +import * as yaml from 'js-yaml' +import BaseService from './base.js' +import { InvalidResponse } from './errors.js' +import trace from './trace.js' /** * Services which query a YAML endpoint should extend BaseYamlService @@ -16,6 +14,11 @@ const trace = require('./trace') * @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 @@ -23,41 +26,48 @@ 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 { - parsed = yaml.safeLoad(buffer.toString(), encoding) + parsed = yaml.load(buffer.toString(), encoding) } catch (err) { logTrace(emojic.dart, 'Response YAML (unparseable)', buffer) throw new InvalidResponse({ @@ -72,4 +82,4 @@ class BaseYamlService extends BaseService { } } -module.exports = BaseYamlService +export default BaseYamlService diff --git a/core/base-service/base-yaml.spec.js b/core/base-service/base-yaml.spec.js index 8098501a0efbd..1f8ebef65f380 100644 --- a/core/base-service/base-yaml.spec.js +++ b/core/base-service/base-yaml.spec.js @@ -1,24 +1,15 @@ -'use strict' - -const Joi = require('@hapi/joi') -const { expect } = require('chai') -const sinon = require('sinon') -const BaseYamlService = require('./base-yaml') +import Joi from 'joi' +import { expect } from 'chai' +import sinon from 'sinon' +import BaseYamlService from './base-yaml.js' const dummySchema = Joi.object({ requiredString: Joi.string().required(), }).required() class DummyYamlService extends BaseYamlService { - static get category() { - return 'cat' - } - - static get route() { - return { - base: 'foo', - } - } + static category = 'cat' + static route = { base: 'foo' } async handle() { const { requiredString } = await this._requestYaml({ @@ -45,53 +36,53 @@ foo: bar foo: baz ` -describe('BaseYamlService', function() { - describe('Making requests', function() { - let sendAndCacheRequest - beforeEach(function() { - sendAndCacheRequest = sinon.stub().returns( +describe('BaseYamlService', function () { + describe('Making requests', function () { + let requestFetcher + beforeEach(function () { + 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: { @@ -99,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 () => ({ + describe('Making badges', function () { + it('handles valid yaml responses', async function () { + 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 () => ({ + it('handles yaml responses which do not match the schema', async function () { + 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', @@ -138,16 +129,16 @@ describe('BaseYamlService', function() { }) }) - it('handles unparseable yaml responses', async function() { - const sendAndCacheRequest = async () => ({ + it('handles unparseable yaml responses', async function () { + 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 db671e1c1dd64..8fcf06d25c117 100644 --- a/core/base-service/base.js +++ b/core/base-service/base.js @@ -1,36 +1,37 @@ -'use strict' /** * @module */ // See available emoji at http://emoji.muan.co/ -const emojic = require('emojic') -const Joi = require('@hapi/joi') -const log = require('../server/log') -const { AuthHelper } = require('./auth-helper') -const { MetricHelper, MetricNames } = require('./metric-helper') -const { assertValidCategory } = require('./categories') -const checkErrorResponse = require('./check-error-response') -const coalesceBadge = require('./coalesce-badge') -const { +import emojic from 'emojic' +import Joi from 'joi' +import log from '../server/log.js' +import { AuthHelper } from './auth-helper.js' +import { MetricHelper, MetricNames } from './metric-helper.js' +import { assertValidCategory } from './categories.js' +import checkErrorResponse from './check-error-response.js' +import coalesceBadge from './coalesce-badge.js' +import { NotFound, InvalidResponse, Inaccessible, ImproperlyConfigured, InvalidParameter, - Deprecated, -} = require('./errors') -const { validateExample, transformExample } = require('./examples') -const { +} from './errors.js' +import { fetch } from './got.js' +import { getEnum } from './openapi.js' +import { makeFullUrl, assertValidRoute, prepareRoute, namedParamsForMatch, getQueryParamNames, -} = require('./route') -const { assertValidServiceDefinition } = require('./service-definitions') -const trace = require('./trace') -const validate = require('./validate') +} from './route.js' +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(), @@ -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(''), @@ -58,21 +55,15 @@ const serviceDataSchema = Joi.object({ // `render()` to always return a string. message: Joi.alternatives(Joi.string().allow(''), Joi.number()).required(), color: Joi.string(), - link: Joi.array() - .items(Joi.string().uri()) - .single() - .max(2), + link: Joi.array().items(Joi.string().uri()).single().max(2), // Generally services should not use these options, which are provided to // support the Endpoint badge. labelColor: Joi.string(), namedLogo: Joi.string(), logoSvg: Joi.string(), logoColor: optionalStringWhenNamedLogoPresent, - logoWidth: optionalNumberWhenAnyLogoPresent, - logoPosition: optionalNumberWhenAnyLogoPresent, - cacheSeconds: Joi.number() - .integer() - .min(0), + logoSize: optionalStringWhenNamedLogoPresent, + cacheSeconds: Joi.number().integer().min(0), style: Joi.string(), }) .oxor('namedLogo', 'logoSvg') @@ -95,65 +86,84 @@ class BaseService { throw new Error(`Category not set for ${this.name}`) } - static get isDeprecated() { - return 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 get auth() { - return undefined - } + 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 get examples() { - return [] - } + static openApi = {} static get _cacheLength() { const cacheLengths = { build: 30, - license: 3600, - version: 300, debug: 60, + + 'platform-support': 300, + size: 300, + version: 300, + + chat: 1800, + downloads: 1800, + rating: 1800, + social: 1800, + + license: 14400, } return cacheLengths[this.category] } @@ -163,11 +173,9 @@ 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 get defaultBadgeData() { - return {} - } + static defaultBadgeData = {} static render(props) { throw new Error(`render() function not implemented for ${this.name}`) @@ -181,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 } @@ -207,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}`) @@ -215,27 +229,52 @@ 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) - logTrace(emojic.bowAndArrow, 'Request', url, '\n', options) - const { res, buffer } = await this._requestFetcher(url, options) + let logUrl = url + const logOptions = Object.assign({}, options) + 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.searchParams + } + logTrace( + emojic.bowAndArrow, + 'Request', + `${logUrl}\n${JSON.stringify(logOptions, null, 2)}`, + ) + const { res, buffer } = await this._requestFetcher( + url, + options, + systemErrors, + ) 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 get enabledMetrics() { - return [] - } + static enabledMetrics = [] static isMetricEnabled(metricName) { return this.enabledMetrics.includes(metricName) @@ -254,11 +293,7 @@ class BaseService { static _validate( data, schema, - { - prettyErrorMessage = 'invalid response data', - includeKeys = false, - allowAndStripUnknownKeys = true, - } = {} + { prettyErrorMessage = 'invalid response data', includeKeys = false } = {}, ) { return validate( { @@ -267,23 +302,22 @@ class BaseService { includeKeys, traceErrorMessage: 'Response did not match schema', traceSuccessMessage: 'Response after validation', - 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) { @@ -307,22 +341,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 @@ -340,7 +377,7 @@ class BaseService { 'unhandledError', emojic.boom, 'Unhandled internal error', - error + error, ) throw error } @@ -350,7 +387,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) @@ -384,13 +421,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 @@ -404,7 +441,7 @@ class BaseService { try { serviceData = await serviceInstance.handle( namedParams, - transformedQueryParams + transformedQueryParams, ) serviceInstance._validateServiceData(serviceData) } catch (error) { @@ -422,10 +459,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) @@ -438,27 +481,27 @@ class BaseService { 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: request.asPromise, - 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 +510,7 @@ class BaseService { metricHandle.noteResponseSent() }, cacheLength: this._cacheLength, - fetchLimitBytes, - }) + }), ) } } @@ -524,9 +566,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 +585,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. - */ - -module.exports = BaseService +export default BaseService diff --git a/core/base-service/base.spec.js b/core/base-service/base.spec.js index 01f28d0f5fbc7..4593d7528e1fd 100644 --- a/core/base-service/base.spec.js +++ b/core/base-service/base.spec.js @@ -1,23 +1,21 @@ -'use strict' - -const Joi = require('@hapi/joi') -const chai = require('chai') -const { expect } = chai -const sinon = require('sinon') -const prometheus = require('prom-client') -const PrometheusMetrics = require('../server/prometheus-metrics') -const trace = require('./trace') -const { +import Joi from 'joi' +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, -} = require('./errors') -const BaseService = require('./base') -const { MetricHelper, MetricNames } = require('./metric-helper') -require('../register-chai-plugins.spec') -chai.use(require('chai-as-promised')) +} from './errors.js' +import BaseService from './base.js' +import { MetricHelper, MetricNames } from './metric-helper.js' +import '../register-chai-plugins.spec.js' +use(chaiAsPromised) const queryParamSchema = Joi.object({ queryParamA: Joi.string(), @@ -29,32 +27,22 @@ const queryParamSchema = Joi.object({ .required() class DummyService extends BaseService { - static get category() { - return 'other' - } - - static get route() { - return { - base: 'foo', - pattern: ':namedParamA', - queryParamSchema, - } - } - - static get examples() { - return [ - { - pattern: ':world', - namedParams: { world: 'World' }, - staticPreview: this.render({ namedParamA: 'foo', queryParamA: 'bar' }), - keywords: ['hello'], + static category = 'other' + static route = { base: 'foo', pattern: ':namedParamA', queryParamSchema } + + static openApi = { + '/foo/{namedParamA}': { + get: { + summary: 'Dummy Service', + parameters: [ + pathParam({ name: 'namedParamA', example: 'foo' }), + queryParam({ name: 'queryParamA', example: 'bar' }), + ], }, - ] + }, } - static get defaultBadgeData() { - return { label: 'cat', namedLogo: 'appveyor' } - } + static defaultBadgeData = { label: 'cat', namedLogo: 'appveyor' } static render({ namedParamA, queryParamA }) { return { @@ -68,12 +56,10 @@ class DummyService extends BaseService { } class DummyServiceWithServiceResponseSizeMetricEnabled extends DummyService { - static get enabledMetrics() { - return [MetricNames.SERVICE_RESPONSE_SIZE] - } + static enabledMetrics = [MetricNames.SERVICE_RESPONSE_SIZE] } -describe('BaseService', function() { +describe('BaseService', function () { const defaultConfig = { public: { handleInternalErrors: false, @@ -82,27 +68,27 @@ describe('BaseService', function() { private: {}, } - it('Invokes the handler as expected', async function() { + it('Invokes the handler as expected', async function () { expect( await DummyService.invoke( {}, defaultConfig, { namedParamA: 'bar.bar.bar' }, - { queryParamA: '!' } - ) + { queryParamA: '!' }, + ), ).to.deep.equal({ message: 'Hello namedParamA: bar.bar.bar with queryParamA: !', }) }) - it('Validates query params', async function() { + it('Validates query params', async function () { expect( await DummyService.invoke( {}, defaultConfig, { namedParamA: 'bar.bar.bar' }, - { queryParamA: ['foo', 'bar'] } - ) + { queryParamA: ['foo', 'bar'] }, + ), ).to.deep.equal({ color: 'red', isError: true, @@ -110,78 +96,72 @@ describe('BaseService', function() { }) }) - describe('Required overrides', function() { - it('Should throw if render() is not overridden', 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() { + 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$/, ) }) class WithRoute extends BaseService { - static get route() { - return {} - } + static route = {} } - it('Should throw if handle() is not overridden', 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() { + 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() + describe('Logging', function () { + beforeEach(function () { + sinon.stub(trace, 'logTrace') }) - afterEach(function() { - sandbox.restore() + afterEach(function () { + sinon.restore() }) - beforeEach(function() { - sandbox.stub(trace, 'logTrace') - }) - it('Invokes the logger as expected', async function() { + 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: '!' }, ) }) }) - describe('Service data validation', function() { - it('Allows a link array', async function() { + describe('Service data validation', function () { + it('Allows a link array', async function () { const message = 'hello' const link = ['https://example.com/', 'https://other.example.com/'] class LinkService extends DummyService { @@ -193,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({ @@ -202,7 +182,7 @@ describe('BaseService', function() { }) }) - context('On invalid data', function() { + context('On invalid data', function () { class ThrowingService extends DummyService { async handle() { return { @@ -211,12 +191,12 @@ describe('BaseService', function() { } } - it('Throws a validation error on invalid data', async function() { + it('Throws a validation error on invalid data', async function () { try { await ThrowingService.invoke( {}, { handleInternalErrors: false }, - { namedParamA: 'bar.bar.bar' } + { namedParamA: 'bar.bar.bar' }, ) expect.fail('Expected to throw') } catch (e) { @@ -229,12 +209,12 @@ describe('BaseService', function() { // Ensure debuggabillity. // https://github.com/badges/shields/issues/3784 - it('Includes the service class in the stack trace', async function() { + it('Includes the service class in the stack trace', async function () { try { await ThrowingService.invoke( {}, { handleInternalErrors: false }, - { namedParamA: 'bar.bar.bar' } + { namedParamA: 'bar.bar.bar' }, ) expect.fail('Expected to throw') } catch (e) { @@ -244,8 +224,8 @@ describe('BaseService', function() { }) }) - describe('Error handling', function() { - it('Handles internal errors', async function() { + describe('Error handling', function () { + it('Handles internal errors', async function () { class ThrowingService extends DummyService { async handle() { throw Error("I've made a huge mistake") @@ -255,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', @@ -265,15 +245,15 @@ describe('BaseService', function() { }) }) - describe('Handles known subtypes of ShieldsInternalError', function() { - it('handles NotFound errors', async function() { + describe('Handles known subtypes of ShieldsInternalError', function () { + it('handles NotFound errors', async function () { class ThrowingService extends DummyService { async handle() { throw new NotFound() } } expect( - await ThrowingService.invoke({}, {}, { namedParamA: 'bar.bar.bar' }) + await ThrowingService.invoke({}, {}, { namedParamA: 'bar.bar.bar' }), ).to.deep.equal({ isError: true, color: 'red', @@ -281,14 +261,14 @@ describe('BaseService', function() { }) }) - it('handles Inaccessible errors', async function() { + it('handles Inaccessible errors', async function () { class ThrowingService extends DummyService { async handle() { throw new Inaccessible() } } expect( - await ThrowingService.invoke({}, {}, { namedParamA: 'bar.bar.bar' }) + await ThrowingService.invoke({}, {}, { namedParamA: 'bar.bar.bar' }), ).to.deep.equal({ isError: true, color: 'lightgray', @@ -296,14 +276,14 @@ describe('BaseService', function() { }) }) - it('handles InvalidResponse errors', async function() { + it('handles InvalidResponse errors', async function () { class ThrowingService extends DummyService { async handle() { throw new InvalidResponse() } } expect( - await ThrowingService.invoke({}, {}, { namedParamA: 'bar.bar.bar' }) + await ThrowingService.invoke({}, {}, { namedParamA: 'bar.bar.bar' }), ).to.deep.equal({ isError: true, color: 'lightgray', @@ -311,29 +291,14 @@ 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() { + it('handles InvalidParameter errors', async function () { class ThrowingService extends DummyService { async handle() { throw new InvalidParameter() } } expect( - await ThrowingService.invoke({}, {}, { namedParamA: 'bar.bar.bar' }) + await ThrowingService.invoke({}, {}, { namedParamA: 'bar.bar.bar' }), ).to.deep.equal({ isError: true, color: 'red', @@ -343,37 +308,34 @@ describe('BaseService', function() { }) }) - describe('ScoutCamp integration', function() { - // TODO Strangly, without the useless escape the regexes do not match in Node 12. + describe('ScoutCamp integration', function () { // eslint-disable-next-line no-useless-escape - const expectedRouteRegex = /^\/foo\/([^\/]+?)(|\.svg|\.json)$/ + const expectedRouteRegex = /^\/foo(?:\/([^\/#\?]+?))(|\.svg|\.json)$/ let mockCamp let mockHandleRequest - beforeEach(function() { + beforeEach(function () { mockCamp = { route: sinon.spy(), } mockHandleRequest = sinon.spy() DummyService.register( { camp: mockCamp, handleRequest: mockHandleRequest }, - defaultConfig + defaultConfig, ) }) - it('registers the service', function() { + it('registers the service', function () { expect(mockCamp.route).to.have.been.calledOnce expect(mockCamp.route).to.have.been.calledWith(expectedRouteRegex) }) - it('handles the request', async function() { + it('handles the request', async function () { expect(mockHandleRequest).to.have.been.calledOnce - const { - queryParams: serviceQueryParams, - handler: requestHandler, - } = mockHandleRequest.getCall(0).args[1] + const { queryParams: serviceQueryParams, handler: requestHandler } = + mockHandleRequest.getCall(0).args[1] expect(serviceQueryParams).to.deep.equal([ 'queryParamA', 'legacyQueryParamA', @@ -390,13 +352,14 @@ describe('BaseService', function() { const expectedFormat = 'svg' expect(mockSendBadge).to.have.been.calledOnce expect(mockSendBadge).to.have.been.calledWith(expectedFormat, { - text: ['cat', 'Hello namedParamA: bar with queryParamA: ?'], + label: 'cat', + message: 'Hello namedParamA: bar with queryParamA: ?', color: 'lightgrey', - template: 'flat', + style: 'flat', namedLogo: undefined, logo: undefined, logoWidth: undefined, - logoPosition: undefined, + logoSize: undefined, links: [], labelColor: undefined, cacheLengthSeconds: undefined, @@ -404,101 +367,97 @@ describe('BaseService', function() { }) }) - describe('getDefinition', function() { - it('returns the expected result', function() { - const { - category, - name, - isDeprecated, - route, - examples, - } = DummyService.getDefinition() + describe('getDefinition', function () { + it('returns the expected result', function () { + 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) }) }) - describe('validate', function() { + describe('validate', function () { const dummySchema = Joi.object({ requiredString: Joi.string().required(), }).required() - it('throws error for invalid responses', function() { + it('throws error for invalid responses', function () { expect(() => DummyService._validate( { requiredString: ['this', "shouldn't", 'work'] }, - dummySchema - ) + dummySchema, + ), ) .to.throw() .instanceof(InvalidResponse) }) }) - describe('request', function() { - let sandbox - beforeEach(function() { - sandbox = sinon.createSandbox() + describe('request', function () { + beforeEach(function () { + sinon.stub(trace, 'logTrace') }) - afterEach(function() { - sandbox.restore() - }) - beforeEach(function() { - sandbox.stub(trace, 'logTrace') + afterEach(function () { + sinon.restore() }) - it('logs appropriate information', async function() { - const sendAndCacheRequest = async () => ({ + it('logs appropriate information', async function () { + 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', - options + `${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 () => ({ + it('handles errors', async function () { + const requestFetcher = async () => ({ buffer: '', res: { statusCode: 404 }, }) const serviceInstance = new DummyService( - { sendAndCacheRequest }, - defaultConfig + { requestFetcher }, + defaultConfig, ) try { @@ -512,67 +471,67 @@ describe('BaseService', function() { }) }) - describe('Metrics', function() { + describe('Metrics', function () { let register - beforeEach(function() { + beforeEach(function () { register = new prometheus.Registry() }) const url = 'some-url' - it('service response size metric is optional', async function() { + it('service response size metric is optional', async function () { const metricHelper = MetricHelper.create({ 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 - ) + const serviceInstance = + new DummyServiceWithServiceResponseSizeMetricEnabled( + { requestFetcher, metricHelper }, + defaultConfig, + ) await serviceInstance._request({ url }) - expect(register.getSingleMetricAsString('service_response_bytes')) + 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', ) }) - it('service response size metric is disabled by default', async function() { + it('service response size metric is disabled by default', async function () { const metricHelper = MetricHelper.create({ 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( - register.getSingleMetricAsString('service_response_bytes') + await register.getSingleMetricAsString('service_response_bytes'), ).to.not.contain('service_response_bytes_bucket') }) }) - describe('auth', function() { + + describe('auth', function () { class AuthService extends DummyService { - static get auth() { - return { - passKey: 'myci_pass', - serviceKey: 'myci', - isRequired: true, - } + static auth = { + passKey: 'myci_pass', + serviceKey: 'myci', + isRequired: true, } async handle() { @@ -582,7 +541,7 @@ describe('BaseService', function() { } } - it('when auth is configured properly, invoke() sets authHelper', async function() { + it('when auth is configured properly, invoke() sets authHelper', async function () { expect( await AuthService.invoke( {}, @@ -593,12 +552,12 @@ describe('BaseService', function() { }, private: { myci_pass: 'abc123' }, }, - { namedParamA: 'bar.bar.bar' } - ) + { namedParamA: 'bar.bar.bar' }, + ), ).to.deep.equal({ message: 'The CI password is abc123' }) }) - it('when auth is not configured properly, invoke() returns inacessible', async function() { + it('when auth is not configured properly, invoke() returns inacessible', async function () { expect( await AuthService.invoke( {}, @@ -611,8 +570,8 @@ describe('BaseService', function() { }, { namedParamA: 'bar.bar.bar', - } - ) + }, + ), ).to.deep.equal({ color: 'lightgray', isError: true, @@ -620,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 ba3455fdca0b3..bbecf6a53742e 100644 --- a/core/base-service/cache-headers.js +++ b/core/base-service/cache-headers.js @@ -1,15 +1,11 @@ -'use strict' - -const assert = require('assert') -const Joi = require('@hapi/joi') -const coalesce = require('./coalesce') +import assert from 'assert' +import Joi from 'joi' +import coalesce from './coalesce.js' const serverStartTimeGMTString = new Date().toGMTString() const serverStartTimestamp = Date.now() -const isOptionalNonNegativeInteger = Joi.number() - .integer() - .min(0) +const isOptionalNonNegativeInteger = Joi.number().integer().min(0) const queryParamSchema = Joi.object({ cacheSeconds: isOptionalNonNegativeInteger, @@ -43,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) @@ -69,7 +65,7 @@ function setHeadersForCacheLength(res, cacheLengthSeconds) { cacheControl = 'no-cache, no-store, must-revalidate' expires = nowGMTString } else { - cacheControl = `max-age=${cacheLengthSeconds}` + cacheControl = `max-age=${cacheLengthSeconds}, s-maxage=${cacheLengthSeconds}` expires = new Date(now.getTime() + cacheLengthSeconds * 1000).toGMTString() } @@ -94,8 +90,11 @@ function setCacheHeaders({ setHeadersForCacheLength(res, cacheLengthSeconds) } -const staticCacheControlHeader = `max-age=${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) } @@ -106,7 +105,7 @@ function serverHasBeenUpSinceResourceCached(req) { ) } -module.exports = { +export { coalesceCacheLength, setCacheHeaders, setHeadersForCacheLength, diff --git a/core/base-service/cache-headers.spec.js b/core/base-service/cache-headers.spec.js index ebe4e61df5d53..1c22e5e1c0d34 100644 --- a/core/base-service/cache-headers.spec.js +++ b/core/base-service/cache-headers.spec.js @@ -1,27 +1,24 @@ -'use strict' - -const { test, given } = require('sazerac') -const chai = require('chai') -const { expect } = require('chai') -const sinon = require('sinon') -const httpMocks = require('node-mocks-http') -const { +import { test, given } from 'sazerac' +import { expect, use } from 'chai' +import sinon from 'sinon' +import httpMocks from 'node-mocks-http' +import chaiDatetime from 'chai-datetime' +import { coalesceCacheLength, setHeadersForCacheLength, setCacheHeaders, setCacheHeadersForStaticResource, serverHasBeenUpSinceResourceCached, -} = require('./cache-headers') +} from './cache-headers.js' +use(chaiDatetime) -chai.use(require('chai-datetime')) - -describe('Cache header functions', function() { +describe('Cache header functions', function () { let res - beforeEach(function() { + beforeEach(function () { res = httpMocks.createResponse() }) - describe('coalesceCacheLength', function() { + describe('coalesceCacheLength', function () { const cacheHeaderConfig = { defaultCacheLengthSeconds: 777 } test(coalesceCacheLength, () => { given({ cacheHeaderConfig, queryParams: {} }).expect(777) @@ -77,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, @@ -101,18 +98,15 @@ describe('Cache header functions', function() { }) }) - describe('setHeadersForCacheLength', function() { - let sandbox - beforeEach(function() { - sandbox = sinon.createSandbox() - sandbox.useFakeTimers() + describe('setHeadersForCacheLength', function () { + beforeEach(function () { + sinon.useFakeTimers() }) - afterEach(function() { - sandbox.restore() - sandbox = undefined + afterEach(function () { + sinon.restore() }) - it('should set the correct Date header', function() { + it('should set the correct Date header', function () { // Confidence check. expect(res._headers.date).to.equal(undefined) @@ -124,43 +118,45 @@ describe('Cache header functions', function() { expect(res._headers.date).to.equal(now) }) - context('cacheLengthSeconds is zero', function() { - beforeEach(function() { + context('cacheLengthSeconds is zero', function () { + beforeEach(function () { setHeadersForCacheLength(res, 0) }) - it('should set the expected Cache-Control header', 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', ) }) - it('should set the expected Expires header', function() { + it('should set the expected Expires header', function () { expect(res._headers.expires).to.equal(new Date().toGMTString()) }) }) - context('cacheLengthSeconds is nonzero', function() { - beforeEach(function() { + context('cacheLengthSeconds is nonzero', function () { + beforeEach(function () { setHeadersForCacheLength(res, 123) }) - it('should set the expected Cache-Control header', function() { - expect(res._headers['cache-control']).to.equal('max-age=123') + it('should set the expected Cache-Control header', function () { + expect(res._headers['cache-control']).to.equal( + 'max-age=123, s-maxage=123', + ) }) - it('should set the expected Expires header', function() { + it('should set the expected Expires header', function () { const expires = new Date(Date.now() + 123 * 1000).toGMTString() expect(res._headers.expires).to.equal(expires) }) }) }) - describe('setCacheHeaders', function() { - it('sets the expected fields', function() { + describe('setCacheHeaders', 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({ @@ -173,41 +169,43 @@ 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), ) }) }) - describe('setCacheHeadersForStaticResource', function() { - beforeEach(function() { + describe('setCacheHeadersForStaticResource', function () { + beforeEach(function () { setCacheHeadersForStaticResource(res) }) - it('should set the expected Cache-Control header', function() { - expect(res._headers['cache-control']).to.equal(`max-age=${24 * 3600}`) + it('should set the expected Cache-Control header', function () { + expect(res._headers['cache-control']).to.equal( + `max-age=${24 * 3600}, s-maxage=${24 * 3600}`, + ) }) - it('should set the expected Last-Modified header', function() { + it('should set the expected Last-Modified header', function () { const lastModified = res._headers['last-modified'] expect(new Date(lastModified)).to.be.withinTime( // Within the last 60 seconds. new Date(Date.now() - 60 * 1000), - new Date() + new Date(), ) }) }) - describe('serverHasBeenUpSinceResourceCached', function() { + describe('serverHasBeenUpSinceResourceCached', function () { // The stringified req's are hard to understand. I thought Sazerac // provided a way to override the describe message, though I can't find it. - context('when there is no If-Modified-Since header', function() { - it('returns false', function() { + context('when there is no If-Modified-Since header', function () { + it('returns false', function () { const req = httpMocks.createRequest() expect(serverHasBeenUpSinceResourceCached(req)).to.equal(false) }) }) - context('when the If-Modified-Since header is invalid', function() { - it('returns false', function() { + context('when the If-Modified-Since header is invalid', function () { + it('returns false', function () { const req = httpMocks.createRequest({ headers: { 'If-Modified-Since': 'this-is-not-a-date' }, }) @@ -216,26 +214,26 @@ describe('Cache header functions', function() { }) context( 'when the If-Modified-Since header is before the process started', - function() { - it('returns false', function() { + function () { + it('returns false', function () { const req = httpMocks.createRequest({ headers: { 'If-Modified-Since': '2018-02-01T05:00:00.000Z' }, }) expect(serverHasBeenUpSinceResourceCached(req)).to.equal(false) }) - } + }, ) context( 'when the If-Modified-Since header is after the process started', - function() { - it('returns true', function() { + function () { + it('returns true', function () { const modifiedTimeStamp = new Date(Date.now() + 1800000) const req = httpMocks.createRequest({ headers: { 'If-Modified-Since': modifiedTimeStamp.toISOString() }, }) expect(serverHasBeenUpSinceResourceCached(req)).to.equal(true) }) - } + }, ) }) }) diff --git a/core/base-service/categories.js b/core/base-service/categories.js index cc91a808df0a6..166e5de0b25d8 100644 --- a/core/base-service/categories.js +++ b/core/base-service/categories.js @@ -1,19 +1,34 @@ -'use strict' +/** + * Helpers for validating service category identifiers. + * + * @module + */ -const Joi = require('@hapi/joi') -const categories = require('../../services/categories') +import Joi from 'joi' +import categories from '../../services/categories.js' const isRealCategory = Joi.equal(...categories.map(({ id }) => id)).required() +/** + * Joi validator for service categories registered by Shields, including the + * special `debug`, `dynamic`, and `static` categories. + * + * @type {Joi} + */ const isValidCategory = Joi.alternatives() .try(isRealCategory, Joi.equal('debug', 'dynamic', 'static').required()) .required() +/** + * Assert that a value is a valid Shields service category. + * + * @param {string} category - Service category identifier to validate. + * @param {string} [message] - Optional prefix for the validation error. + * @throws {Error} When the category is not recognized. + * @returns {void} + */ function assertValidCategory(category, message = undefined) { Joi.assert(category, isValidCategory, message) } -module.exports = { - isValidCategory, - assertValidCategory, -} +export { isValidCategory, assertValidCategory } diff --git a/core/base-service/check-error-response.js b/core/base-service/check-error-response.js index 1968e6eb056ef..463decebb3777 100644 --- a/core/base-service/check-error-response.js +++ b/core/base-service/check-error-response.js @@ -1,24 +1,48 @@ -'use strict' +/** + * @module + */ -const { NotFound, InvalidResponse, Inaccessible } = require('./errors') +import log from '../server/log.js' +import { NotFound, InvalidResponse, Inaccessible } from './errors.js' const defaultErrorMessages = { 404: 'not found', + 429: 'rate limited by upstream service', } -module.exports = function checkErrorResponse(errorMessages = {}) { - return async function({ buffer, res }) { +const headersToInclude = ['cf-ray'] + +/** + * Create a handler that validates HTTP status codes from upstream API responses. + * + * Used in the `got` response pipeline by {@link module:core/base-service/base~BaseService} + * and related helpers. On status 200 the handler returns the inputs unchanged. + * On other status codes it throws a {@link NotFound}, {@link InvalidResponse}, or + * {@link Inaccessible} exception. Thrown errors include `response` and `buffer` + * from the upstream call for debugging. + * + * @param {object} [httpErrors={}] Status-code-to-message map merged with defaults. + * Keys are numeric HTTP status codes. Values set the `prettyMessage` shown on + * the badge. Defaults include `404: 'not found'` and + * `429: 'rate limited by upstream service'`. + * @param {number[]} [logErrors=[429]] Status codes to log when encountered. + * Selected response headers (for example `cf-ray`) are attached as log tags. + * @returns {function({buffer: Buffer, res: object}): Promise<{buffer: Buffer, res: object}>} + * Async handler. Resolves with `{ buffer, res }` on 200; otherwise throws. + */ +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) @@ -26,6 +50,21 @@ module.exports = 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 9e55d8ed097c8..d5479d8b594ab 100644 --- a/core/base-service/check-error-response.spec.js +++ b/core/base-service/check-error-response.spec.js @@ -1,14 +1,12 @@ -'use strict' +import { expect } from 'chai' +import { NotFound, InvalidResponse, Inaccessible } from './errors.js' +import checkErrorResponse from './check-error-response.js' -const { expect } = require('chai') -const { NotFound, InvalidResponse, Inaccessible } = require('./errors') -const checkErrorResponse = require('./check-error-response') - -describe('async error handler', function() { +describe('async error handler', function () { const buffer = Buffer.from('some stuff') - context('when status is 200', function() { - it('passes through the inputs', async function() { + context('when status is 200', function () { + it('passes through the inputs', async function () { const res = { statusCode: 200 } expect(await checkErrorResponse()({ res, buffer })).to.deep.equal({ res, @@ -17,11 +15,11 @@ describe('async error handler', function() { }) }) - context('when status is 404', function() { + context('when status is 404', function () { const buffer = Buffer.from('some stuff') const res = { statusCode: 404 } - it('throws NotFound', async function() { + it('throws NotFound', async function () { try { await checkErrorResponse()({ res, buffer }) expect.fail('Expected to throw') @@ -34,7 +32,7 @@ describe('async error handler', function() { } }) - it('displays the custom not found message', async function() { + it('displays the custom not found message', async function () { const notFoundMessage = 'no goblins found' try { await checkErrorResponse({ 404: notFoundMessage })({ res, buffer }) @@ -47,8 +45,48 @@ describe('async error handler', function() { }) }) - context('when status is 4xx', function() { - it('throws InvalidResponse', async 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 } try { await checkErrorResponse()({ res, buffer }) @@ -56,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) @@ -64,7 +102,7 @@ describe('async error handler', function() { } }) - it('displays the custom error message', async function() { + it('displays the custom error message', async function () { const res = { statusCode: 403 } try { await checkErrorResponse({ 403: 'access denied' })({ res }) @@ -72,15 +110,15 @@ 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') } }) }) - context('when status is 5xx', function() { - it('throws Inaccessible', async function() { + context('when status is 5xx', function () { + it('throws Inaccessible', async function () { const res = { statusCode: 503 } try { await checkErrorResponse()({ res, buffer }) @@ -88,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) @@ -96,7 +134,7 @@ describe('async error handler', function() { } }) - it('displays the custom error message', async function() { + it('displays the custom error message', async function () { const res = { statusCode: 500 } try { await checkErrorResponse({ 500: 'server overloaded' })({ res, buffer }) @@ -104,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 ba664dfdc981e..3380b7eb65766 100644 --- a/core/base-service/coalesce-badge.js +++ b/core/base-service/coalesce-badge.js @@ -1,45 +1,44 @@ -'use strict' - -const { +import { decodeDataUrlFromQueryParam, prepareNamedLogo, -} = require('../../lib/logos') -const { svg2base64 } = require('../../lib/svg-helpers') -const coalesce = require('./coalesce') -const toArray = require('./to-array') +} from '../../lib/logos.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. -module.exports = function coalesceBadge( +/** + * 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 @@ -52,16 +51,12 @@ module.exports = 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') { @@ -78,8 +73,6 @@ module.exports = function coalesceBadge( if (typeof overrideLabelColor === 'number') { overrideLabelColor = `${overrideLabelColor}` } - overrideLogoWidth = +overrideLogoWidth || undefined - overrideLogoPosition = +overrideLogoPosition || undefined const { isError, @@ -90,8 +83,7 @@ module.exports = function coalesceBadge( logoSvg: serviceLogoSvg, namedLogo: serviceNamedLogo, logoColor: serviceLogoColor, - logoWidth: serviceLogoWidth, - logoPosition: serviceLogoPosition, + logoSize: serviceLogoSize, link: serviceLink, cacheSeconds: serviceCacheSeconds, style: serviceStyle, @@ -105,12 +97,6 @@ module.exports = 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', @@ -122,7 +108,7 @@ module.exports = 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) @@ -136,55 +122,67 @@ module.exports = 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 { - text: [ - // Use `coalesce()` to support empty labels and messages, as in the - // static badge. - coalesce(overrideLabel, serviceLabel, defaultLabel, category), - coalesce(serviceMessage, 'n/a'), - ], + const badgeData = { + // Use `coalesce()` to support empty labels and messages, as in the static + // badge. + label: coalesce(overrideLabel, serviceLabel, defaultLabel, category), + message: coalesce(serviceMessage, 'n/a'), color: coalesce( // In case of an error, disregard user's color override. 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, ), - template: style, + 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 443fec3db28a1..6f97c1ac9e62d 100644 --- a/core/base-service/coalesce-badge.spec.js +++ b/core/base-service/coalesce-badge.spec.js @@ -1,307 +1,315 @@ -'use strict' - -const { expect } = require('chai') -const { getShieldsIcon, getSimpleIcon } = require('../../lib/logos') -const coalesceBadge = require('./coalesce-badge') - -describe('coalesceBadge', function() { - describe('Label', function() { - it('uses the default label', function() { - expect(coalesceBadge({}, {}, { label: 'heyo' }).text).to.deep.equal([ - 'heyo', - 'n/a', - ]) +import { expect } from 'chai' +import { getSimpleIcon } from '../../lib/logos.js' +import coalesceBadge from './coalesce-badge.js' + +describe('coalesceBadge', function () { + describe('Label', function () { + it('uses the default label', function () { + expect(coalesceBadge({}, {}, { label: 'heyo' })).to.include({ + label: 'heyo', + }) }) // This behavior isn't great and we might want to remove it. - it('uses the category as a default label', function() { - expect( - coalesceBadge({}, {}, {}, { category: 'cat' }).text - ).to.deep.equal(['cat', 'n/a']) + it('uses the category as a default label', function () { + expect(coalesceBadge({}, {}, {}, { category: 'cat' })).to.include({ + label: 'cat', + }) }) - it('preserves an empty label', function() { + it('preserves an empty label', function () { + expect(coalesceBadge({}, { label: '', message: '10k' }, {})).to.include({ + label: '', + }) + }) + + it('overrides the label', function () { expect( - coalesceBadge({}, { label: '', message: '10k' }, {}).text - ).to.deep.equal(['', '10k']) + coalesceBadge({ label: 'purr count' }, { label: 'purrs' }, {}), + ).to.include({ label: 'purr count' }) }) - it('overrides the label', function() { + it('truncates really long labels', function () { expect( - coalesceBadge({ label: 'purr count' }, { label: 'purrs' }, {}).text - ).to.deep.equal(['purr count', 'n/a']) + 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() { - it('applies the service message', function() { - expect(coalesceBadge({}, { message: '10k' }, {}).text).to.deep.equal([ - undefined, - '10k', - ]) + describe('Message', function () { + it('applies the service message', function () { + expect(coalesceBadge({}, { message: '10k' }, {})).to.include({ + message: '10k', + }) }) - it('applies a numeric service message', function() { + // https://github.com/badges/shields/issues/1280 + it('converts a number to a string', function () { // While a number of badges use this, in the long run we may want // `render()` to always return a string. - expect(coalesceBadge({}, { message: 10 }, {}).text).to.deep.equal([ - undefined, - 10, - ]) + expect(coalesceBadge({}, { message: 10 }, {})).to.include({ + 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() { - it('uses the default color', function() { - expect(coalesceBadge({}, {}, {}).color).to.equal('lightgrey') + describe('Right color', function () { + it('uses the default color', function () { + expect(coalesceBadge({}, {}, {})).to.include({ color: 'lightgrey' }) }) - it('overrides the color', function() { + it('overrides the color', function () { expect( - coalesceBadge({ color: '10ADED' }, { color: 'red' }, {}).color - ).to.equal('10ADED') + coalesceBadge({ color: '10ADED' }, { color: 'red' }, {}), + ).to.include({ color: '10ADED' }) // also expected for legacy name expect( - coalesceBadge({ colorB: 'B0ADED' }, { color: 'red' }, {}).color - ).to.equal('B0ADED') + coalesceBadge({ colorB: 'B0ADED' }, { color: 'red' }, {}), + ).to.include({ color: 'B0ADED' }) }) - context('In case of an error', function() { - it('does not override the color', function() { + context('In case of an error', function () { + it('does not override the color', function () { expect( coalesceBadge( { color: '10ADED' }, { isError: true, color: 'lightgray' }, - {} - ).color - ).to.equal('lightgray') + {}, + ), + ).to.include({ color: 'lightgray' }) // also expected for legacy name expect( coalesceBadge( { colorB: 'B0ADED' }, { isError: true, color: 'lightgray' }, - {} - ).color - ).to.equal('lightgray') + {}, + ), + ).to.include({ color: 'lightgray' }) }) }) - it('applies the service color', function() { - expect(coalesceBadge({}, { color: 'red' }, {}).color).to.equal('red') + it('applies the service color', function () { + expect(coalesceBadge({}, { color: 'red' }, {})).to.include({ + color: 'red', + }) }) }) - describe('Left color', function() { - it('provides no default label color', function() { + describe('Left color', function () { + it('provides no default label color', function () { expect(coalesceBadge({}, {}, {}).labelColor).to.be.undefined }) - it('applies the service label color', function() { - expect(coalesceBadge({}, { labelColor: 'red' }, {}).labelColor).to.equal( - 'red' - ) + it('applies the service label color', function () { + expect(coalesceBadge({}, { labelColor: 'red' }, {})).to.include({ + labelColor: 'red', + }) }) - it('overrides the label color', function() { + it('overrides the label color', function () { expect( - coalesceBadge({ labelColor: '42f483' }, { color: 'green' }, {}) - .labelColor - ).to.equal('42f483') + coalesceBadge({ labelColor: '42f483' }, { color: 'green' }, {}), + ).to.include({ labelColor: '42f483' }) // also expected for legacy name expect( - coalesceBadge({ colorA: 'B2f483' }, { color: 'green' }, {}).labelColor - ).to.equal('B2f483') + coalesceBadge({ colorA: 'B2f483' }, { color: 'green' }, {}), + ).to.include({ labelColor: 'B2f483' }) }) - it('converts a query-string numeric color to a string', function() { + it('converts a query-string numeric color to a string', function () { expect( coalesceBadge( // Scoutcamp converts numeric query params to numbers. { color: 123 }, { color: 'green' }, - {} - ).color - ).to.equal('123') + {}, + ), + ).to.include({ color: '123' }) // also expected for legacy name expect( coalesceBadge( // Scoutcamp converts numeric query params to numbers. { colorB: 123 }, { color: 'green' }, - {} - ).color - ).to.equal('123') + {}, + ), + ).to.include({ color: '123' }) }) }) - describe('Named logos', function() { - it('when not a social badge, ignores the default named logo', function() { + describe('Named logos', function () { + it('when not a social badge, ignores the default named logo', function () { expect(coalesceBadge({}, {}, { namedLogo: 'appveyor' }).logo).to.be .undefined }) - it('when a social badge, uses the default named logo', function() { - // .not.be.empty for confidence that nothing has changed with `getShieldsIcon()`. + it('when a social badge, uses the default named logo', function () { + // .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 }) - it('applies the named logo', function() { - expect(coalesceBadge({}, { namedLogo: 'npm' }, {}).namedLogo).to.equal( - 'npm' - ) + it('applies the named logo', function () { + expect(coalesceBadge({}, { namedLogo: 'npm' }, {})).to.include({ + 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() { + 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() { + 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() { + 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() { + 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() { + 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 }) }) - describe('Custom logos', function() { - it('overrides the logo with custom svg', function() { + describe('Custom logos', function () { + it('overrides the logo with custom svg', function () { const logoSvg = 'data:image/svg+xml;base64,PHN2ZyB4bWxu' expect( - coalesceBadge({ logo: logoSvg }, { namedLogo: 'appveyor' }, {}).logo - ).to.equal(logoSvg) + coalesceBadge({ logo: logoSvg }, { namedLogo: 'appveyor' }, {}), + ).to.include({ logo: logoSvg }) }) - it('ignores the color when custom svg is provided', function() { + it('ignores the color when custom svg is provided', function () { const logoSvg = 'data:image/svg+xml;base64,PHN2ZyB4bWxu' expect( coalesceBadge( { logo: logoSvg, logoColor: 'brightgreen' }, { namedLogo: 'appveyor' }, - {} - ).logo - ).to.equal(logoSvg) + {}, + ), + ).to.include({ logo: logoSvg }) }) }) - describe('Logo width', function() { - it('overrides the logoWidth', function() { - expect(coalesceBadge({ logoWidth: 20 }, {}, {}).logoWidth).to.equal(20) - }) - - it('applies the logo width', function() { - expect( - coalesceBadge({}, { namedLogo: 'npm', logoWidth: 275 }, {}).logoWidth - ).to.equal(275) - }) - }) - - describe('Logo position', function() { - it('overrides the logoPosition', function() { - expect( - coalesceBadge({ logoPosition: -10 }, {}, {}).logoPosition - ).to.equal(-10) + describe('Logo size', function () { + it('overrides the logoSize', function () { + expect(coalesceBadge({ logoSize: 'auto' }, {}, {})).to.include({ + logoSize: 'auto', + }) }) - it('applies the logo position', function() { + it('applies the logo size', function () { expect( - coalesceBadge({}, { namedLogo: 'npm', logoPosition: -10 }, {}) - .logoPosition - ).to.equal(-10) + coalesceBadge({}, { namedLogo: 'npm', logoSize: 'auto' }, {}), + ).to.include({ logoSize: 'auto' }) }) }) - describe('Links', function() { - it('overrides the links', function() { + describe('Links', function () { + it('overrides the links', function () { expect( coalesceBadge( { link: 'https://circleci.com/gh/badges/daily-tests' }, { - link: - 'https://circleci.com/workflow-run/184ef3de-4836-4805-a2e4-0ceba099f92d', + link: 'https://circleci.com/workflow-run/184ef3de-4836-4805-a2e4-0ceba099f92d', }, - {} - ).links + {}, + ).links, ).to.deep.equal(['https://circleci.com/gh/badges/daily-tests']) }) }) - describe('Style', function() { - it('falls back to flat with invalid style', function() { - expect(coalesceBadge({ style: 'pill' }, {}, {}).template).to.equal('flat') - expect(coalesceBadge({ style: 7 }, {}, {}).template).to.equal('flat') - expect(coalesceBadge({ style: undefined }, {}, {}).template).to.equal( - 'flat' - ) - }) - - it('replaces legacy popout styles', function() { - expect(coalesceBadge({ style: 'popout' }, {}, {}).template).to.equal( - 'flat' - ) - expect( - coalesceBadge({ style: 'popout-square' }, {}, {}).template - ).to.equal('flat-square') + describe('Style', function () { + it('falls back to flat with invalid style', function () { + expect(coalesceBadge({ style: 'pill' }, {}, {})).to.include({ + style: 'flat', + }) + expect(coalesceBadge({ style: 7 }, {}, {})).to.include({ + style: 'flat', + }) + expect(coalesceBadge({ style: undefined }, {}, {})).to.include({ + style: 'flat', + }) }) }) - describe('Cache length', function() { - it('overrides the cache length', function() { + describe('Cache length', function () { + it('overrides the cache length', function () { expect( - coalesceBadge({ style: 'pill' }, { cacheSeconds: 123 }, {}) - .cacheLengthSeconds - ).to.equal(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 4875b409c7925..3a6fc456409da 100644 --- a/core/base-service/coalesce.js +++ b/core/base-service/coalesce.js @@ -1,5 +1,13 @@ -'use strict' - -module.exports = function coalesce(...candidates) { +/** + * 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 180f9e1e6aa1d..caafc31490374 100644 --- a/core/base-service/coalesce.spec.js +++ b/core/base-service/coalesce.spec.js @@ -1,14 +1,12 @@ -'use strict' - -const { test, given } = require('sazerac') -const coalesce = require('./coalesce') +import { test, given } from 'sazerac' +import coalesce from './coalesce.js' // Sticking with our one-line spread implementation, and defaulting to // `undefined` instead of `null`, though h/t to // https://github.com/royriojas/coalescy for these tests! -describe('coalesce', function() { - test(coalesce, function() { +describe('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 81942ca758632..0000000000000 --- a/core/base-service/deprecated-service.js +++ /dev/null @@ -1,63 +0,0 @@ -'use strict' - -const Joi = require('@hapi/joi') -const camelcase = require('camelcase') -const BaseService = require('./base') -const { isValidCategory } = require('./categories') -const { Deprecated } = require('./errors') -const { isValidRoute } = require('./route') - -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 get name() { - return name - ? `Deprecated${name}` - : `Deprecated${camelcase(route.base.replace(/\//g, '_'), { - pascalCase: true, - })}` - } - - static get category() { - return category - } - - static get isDeprecated() { - return true - } - - static get route() { - return route - } - - static get examples() { - return examples - } - - static get defaultBadgeData() { - return { label } - } - - async handle() { - throw new Deprecated({ prettyMessage: message }) - } - } -} - -module.exports = deprecatedService diff --git a/core/base-service/deprecated-service.spec.js b/core/base-service/deprecated-service.spec.js deleted file mode 100644 index d448bfde452f4..0000000000000 --- a/core/base-service/deprecated-service.spec.js +++ /dev/null @@ -1,69 +0,0 @@ -'use strict' - -const { expect } = require('chai') -const deprecatedService = require('./deprecated-service') - -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 5bbd709089df1..e8f8b29053535 100644 --- a/core/base-service/errors.js +++ b/core/base-service/errors.js @@ -4,8 +4,6 @@ * @module */ -'use strict' - /** * Base error class * @@ -34,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 */ @@ -44,6 +42,7 @@ class ShieldsRuntimeError extends Error { if (props.underlyingError) { this.stack = props.underlyingError.stack } + this.cacheSeconds = props.cacheSeconds } } @@ -62,7 +61,7 @@ class NotFound extends ShieldsRuntimeError { } /** - * @param {module:core/base-service/errors~RuntimeErrorProps} props + * @param {RuntimeErrorProps} props * Refer to individual attrs */ constructor(props = {}) { @@ -89,7 +88,7 @@ class InvalidResponse extends ShieldsRuntimeError { } /** - * @param {module:core/base-service/errors~RuntimeErrorProps} props + * @param {RuntimeErrorProps} props * Refer to individual attrs */ constructor(props = {}) { @@ -115,7 +114,7 @@ class Inaccessible extends ShieldsRuntimeError { } /** - * @param {module:core/base-service/errors~RuntimeErrorProps} props + * @param {RuntimeErrorProps} props * Refer to individual attrs */ constructor(props = {}) { @@ -140,7 +139,7 @@ class ImproperlyConfigured extends ShieldsRuntimeError { } /** - * @param {module:core/base-service/errors~RuntimeErrorProps} props + * @param {RuntimeErrorProps} props * Refer to individual attrs */ constructor(props = {}) { @@ -166,7 +165,7 @@ class InvalidParameter extends ShieldsRuntimeError { } /** - * @param {module:core/base-service/errors~RuntimeErrorProps} props + * @param {RuntimeErrorProps} props * Refer to individual attrs */ constructor(props = {}) { @@ -178,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) @@ -208,14 +185,16 @@ 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) */ -module.exports = { +export { ShieldsRuntimeError, NotFound, ImproperlyConfigured, InvalidResponse, Inaccessible, InvalidParameter, - Deprecated, } diff --git a/core/base-service/examples.js b/core/base-service/examples.js deleted file mode 100644 index 61f121bfbe4e8..0000000000000 --- a/core/base-service/examples.js +++ /dev/null @@ -1,173 +0,0 @@ -'use strict' - -const Joi = require('@hapi/joi') -const { pathToRegexp, compile } = require('path-to-regexp') -const categories = require('../../services/categories') -const coalesceBadge = require('./coalesce-badge') -const { makeFullUrl } = require('./route') - -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 { - text: [label, message], - color, - template: 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, - } -} - -module.exports = { - validateExample, - transformExample, -} diff --git a/core/base-service/examples.spec.js b/core/base-service/examples.spec.js deleted file mode 100644 index 387fec0456d16..0000000000000 --- a/core/base-service/examples.spec.js +++ /dev/null @@ -1,169 +0,0 @@ -'use strict' - -const { expect } = require('chai') -const { test, given } = require('sazerac') -const { validateExample, transformExample } = require('./examples') - -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 new file mode 100644 index 0000000000000..dcd079aaff4a7 --- /dev/null +++ b/core/base-service/got.js @@ -0,0 +1,79 @@ +import got from 'got' +import { Inaccessible, InvalidResponse } from './errors.js' +import { + fetchLimitBytes as fetchLimitBytesDefault, + getUserAgent, +} from './got-config.js' + +const userAgent = getUserAgent() + +async function sendRequest(gotWrapper, url, options = {}, systemErrors = {}) { + const gotOptions = Object.assign({}, options) + gotOptions.throwHttpErrors = false + 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.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 = 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 ( + progress.transferred > fetchLimitBytes && + // just accept the file if we've already finished downloading + // the entire file before we went over the limit + progress.percent !== 1 + ) { + abortController.abort('Maximum response size exceeded') + } + }) + + return promiseOrStream + }, + ], + }) + + return sendRequest.bind(sendRequest, gotWithLimit) +} + +const fetch = _fetchFactory() + +export { fetch, _fetchFactory } diff --git a/core/base-service/got.spec.js b/core/base-service/got.spec.js new file mode 100644 index 0000000000000..2a7f1f494bdfc --- /dev/null +++ b/core/base-service/got.spec.js @@ -0,0 +1,97 @@ +import { expect } from 'chai' +import nock from 'nock' +import { _fetchFactory } from './got.js' +import { Inaccessible, InvalidResponse } from './errors.js' + +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 { res } = await sendRequest('https://www.google.com/foo/bar') + expect(res.statusCode).to.equal(200) + }) + + it('should throw an InvalidResponse error if the response is > fetchLimitBytes', async function () { + nock('https://www.google.com') + .get('/foo/bar') + .once() + .reply(200, 'x'.repeat(101)) + const sendRequest = _fetchFactory(100) + return expect( + 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) + return expect( + 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) + return expect( + sendRequest('https://www.google.com/foo/bar'), + ).to.be.rejectedWith( + Inaccessible, + '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) + }) + ) + }) + + it('should pass a custom user agent header', async function () { + nock('https://www.google.com', { + reqheaders: { + 'user-agent': function (agent) { + return agent.startsWith('shields (self-hosted)') + }, + }, + }) + .get('/foo/bar') + .once() + .reply(200) + const sendRequest = _fetchFactory(1024) + await sendRequest('https://www.google.com/foo/bar') + }) + + afterEach(function () { + nock.cleanAll() + nock.enableNetConnect() + }) +}) diff --git a/core/base-service/graphql.js b/core/base-service/graphql.js index 3a78deb09a6d8..6c3d078df8736 100644 --- a/core/base-service/graphql.js +++ b/core/base-service/graphql.js @@ -1,4 +1,3 @@ -'use strict' /** * @module */ @@ -31,12 +30,12 @@ function mergeQueries(...queries) { parsedQuery.definitions.forEach(definition => { merged.definitions[0].directives = [ ...merged.definitions[0].directives, - ...definition.directives, + ...(definition.directives ?? []), ] merged.definitions[0].variableDefinitions = [ ...merged.definitions[0].variableDefinitions, - ...definition.variableDefinitions, + ...(definition.variableDefinitions ?? []), ] merged.definitions[0].selectionSet.selections = [ @@ -49,4 +48,4 @@ function mergeQueries(...queries) { return merged } -module.exports = { mergeQueries } +export { mergeQueries } diff --git a/core/base-service/graphql.spec.js b/core/base-service/graphql.spec.js index a77e40f85d88e..41dbd21d0eed3 100644 --- a/core/base-service/graphql.spec.js +++ b/core/base-service/graphql.spec.js @@ -1,35 +1,37 @@ -'use strict' +import { expect } from 'chai' +import gql from 'graphql-tag' +import { print } from 'graphql/language/printer.js' +import { mergeQueries } from './graphql.js' -const { expect } = require('chai') -const gql = require('graphql-tag') -const { print } = require('graphql/language/printer') -const { mergeQueries } = require('./graphql') +import '../register-chai-plugins.spec.js' -require('../register-chai-plugins.spec') - -describe('mergeQueries function', function() { - it('merges valid gql queries', function() { +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( print( mergeQueries( gql` - query($param: String!) { + query ($param: String!) { foo(param: $param) { bar } @@ -39,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( @@ -63,10 +72,18 @@ describe('mergeQueries function', function() { query { baz } - ` - ) - ) - ).to.equalIgnoreSpaces('{ foo bar baz }') + `, + ), + ), + ).to.equal( + print(gql` + { + foo + bar + baz + } + `), + ) expect( print( @@ -80,13 +97,20 @@ describe('mergeQueries function', function() { { bar } - ` - ) - ) - ).to.equalIgnoreSpaces('{ foo bar }') + `, + ), + ), + ).to.equal( + print(gql` + { + foo + bar + } + `), + ) }) - it('throws an error when passed invalid params', function() { + it('throws an error when passed invalid params', function () { expect(() => mergeQueries('', '')).to.throw(Error) expect(() => mergeQueries(undefined, 17, true)).to.throw(Error) expect(() => mergeQueries(gql``, gql`foo`)).to.throw(Error) diff --git a/core/base-service/index.js b/core/base-service/index.js index 5764be1200b55..b39f5259b95e2 100644 --- a/core/base-service/index.js +++ b/core/base-service/index.js @@ -1,37 +1,42 @@ -'use strict' - -const BaseService = require('./base') -const BaseJsonService = require('./base-json') -const BaseGraphqlService = require('./base-graphql') -const NonMemoryCachingBaseService = require('./base-non-memory-caching') -const BaseStaticService = require('./base-static') -const BaseSvgScrapingService = require('./base-svg-scraping') -const BaseXmlService = require('./base-xml') -const BaseYamlService = require('./base-yaml') -const deprecatedService = require('./deprecated-service') -const redirector = require('./redirector') -const { +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 retiredService from './retired-service.js' +import redirector from './redirector.js' +import { NotFound, InvalidResponse, Inaccessible, InvalidParameter, - Deprecated, -} = require('./errors') + ImproperlyConfigured, +} from './errors.js' +import { pathParam, pathParams, queryParam, queryParams } from './openapi.js' -module.exports = { +export { BaseService, BaseJsonService, + BaseJsonlService, BaseGraphqlService, - NonMemoryCachingBaseService, 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 6186b1e6b0b57..98e716b0604fe 100644 --- a/core/base-service/json.js +++ b/core/base-service/json.js @@ -1,10 +1,15 @@ -'use strict' - // See available emoji at http://emoji.muan.co/ -const emojic = require('emojic') -const { InvalidResponse } = require('./errors') -const trace = require('./trace') +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 @@ -23,6 +28,4 @@ function parseJson(buffer) { return json } -module.exports = { - parseJson, -} +export { parseJson } 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 3d3096a5ecda4..3a44642c1240c 100644 --- a/core/base-service/legacy-request-handler.js +++ b/core/base-service/legacy-request-handler.js @@ -1,35 +1,7 @@ -'use strict' - -const request = require('request') -const queryString = require('query-string') -const makeBadge = require('../../badge-maker/lib/make-badge') -const { setCacheHeaders } = require('./cache-headers') -const { - Inaccessible, - InvalidResponse, - ShieldsRuntimeError, -} = require('./errors') -const { makeSend } = require('./legacy-result-sender') -const LruCache = require('./lru-cache') -const coalesceBadge = require('./coalesce-badge') - -const userAgent = 'Shields.io/2003a' - -// We avoid calling the vendor's server for computation of the information in a -// number of badges. -const minAccuracy = 0.75 - -// The quotient of (vendor) data change frequency by badge request frequency -// must be lower than this to trigger sending the cached data *before* -// updating our data from the vendor's server. -// Indeed, the accuracy of our badges are: -// A(Δt) = 1 - min(# data change over Δt, # requests over Δt) -// / (# requests over Δt) -// = 1 - max(1, df) / rf -const freqRatioMax = 1 - minAccuracy - -// Request cache size of 5MB (~5000 bytes/image). -const requestCache = new LruCache(1000) +import makeBadge from '../../badge-maker/lib/make-badge.js' +import { setCacheHeaders } from './cache-headers.js' +import { makeSend } from './legacy-result-sender.js' +import coalesceBadge from './coalesce-badge.js' // These query parameters are available to any badge. They are handled by // `coalesceBadge`. @@ -39,8 +11,7 @@ const globalQueryParams = new Set([ 'link', 'logo', 'logoColor', - 'logoPosition', - 'logoWidth', + 'logoSize', 'link', 'colorA', 'colorB', @@ -56,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 @@ -101,22 +52,29 @@ function handleRequest(cacheHeaderConfig, handlerOptions) { } const allowedKeys = flattenQueryParams(handlerOptions.queryParams) - const { - cacheLength: serviceDefaultCacheLengthSeconds, - fetchLimitBytes, - } = handlerOptions + const { cacheLength: serviceDefaultCacheLengthSeconds } = handlerOptions return (queryParams, match, end, ask) => { - const reqTime = new Date() + /* + This is here for legacy reasons. The badge server and frontend used to live + on two different servers. When we merged them there was a conflict so we + did this to avoid moving the endpoint docs to another URL. + + Never ever do this again. + */ + if (match[0] === '/endpoint' && Object.keys(queryParams).length === 0) { + ask.res.statusCode = 301 + ask.res.setHeader('Location', '/badges/endpoint-badge') + ask.res.end() + return + } // `defaultCacheLengthSeconds` can be overridden by // `serviceDefaultCacheLengthSeconds` (either by category or on a badge- // by-badge basis). Then in turn that can be overridden by - // `serviceOverrideCacheLengthSeconds` (which we expect to be used only in - // the dynamic badge) but only if `serviceOverrideCacheLengthSeconds` is - // longer than `serviceDefaultCacheLengthSeconds` and then the `cacheSeconds` - // query param can also override both of those but again only if `cacheSeconds` - // is longer. + // `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. @@ -137,54 +95,15 @@ function handleRequest(cacheHeaderConfig, handlerOptions) { filteredQueryParams[key] = queryParams[key] }) - // Use sindresorhus query-string because it sorts the keys, whereas the - // builtin querystring module relies on the iteration order. - const stringified = queryString.stringify(filteredQueryParams) - const cacheIndex = `${match[0]}?${stringified}` - - // Should we return the data right away? - const cached = requestCache.get(cacheIndex) - let cachedVersionSent = false - if (cached !== undefined) { - // A request was made not long ago. - const tooSoon = +reqTime - cached.time < cached.interval - if (tooSoon || cached.dataChange / cached.reqs <= freqRatioMax) { - const svg = makeBadge(cached.data.badgeData) - setCacheHeadersOnResponse( - ask.res, - cached.data.badgeData.cacheLengthSeconds - ) - makeSend(cached.data.format, ask.res, end)(svg) - cachedVersionSent = true - // We do not wish to call the vendor servers. - if (tooSoon) { - return - } - } - } - // In case our vendor servers are unresponsive. let serverUnresponsive = false const serverResponsive = setTimeout(() => { serverUnresponsive = true - if (cachedVersionSent) { - return - } - if (requestCache.has(cacheIndex)) { - const cached = requestCache.get(cacheIndex) - const svg = makeBadge(cached.data.badgeData) - setCacheHeadersOnResponse( - ask.res, - cached.data.badgeData.cacheLengthSeconds - ) - makeSend(cached.data.format, ask.res, end)(svg) - return - } ask.res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate') const badgeData = coalesceBadge( filteredQueryParams, { label: 'vendor', message: 'unresponsive' }, - {} + {}, ) const svg = makeBadge(badgeData) const extension = (match.slice(-1)[0] || '.svg').replace(/^\./, '') @@ -192,92 +111,23 @@ function handleRequest(cacheHeaderConfig, handlerOptions) { makeSend(extension, ask.res, end)(svg) }, 25000) - // Only call vendor servers when last request is older than… - let cacheInterval = 5000 // milliseconds - function cachingRequest(uri, options, callback) { - if (typeof options === 'function' && !callback) { - callback = options - } - 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, (err, res, body) => { - if (res != null && res.headers != null) { - const cacheControl = res.headers['cache-control'] - if (cacheControl != null) { - const age = cacheControl.match(/max-age=([0-9]+)/) - // Would like to get some more test coverage on this before changing it. - // eslint-disable-next-line no-self-compare - if (age != null && +age[1] === +age[1]) { - cacheInterval = +age[1] * 1000 - } - } - } - callback(err, res, body) - }) - r.on('data', chunk => { - bufferLength += chunk.length - if (bufferLength > fetchLimitBytes) { - 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 } clearTimeout(serverResponsive) - // Check for a change in the data. - let dataHasChanged = false - if ( - cached !== undefined && - cached.data.badgeData.text[1] !== badgeData.text[1] - ) { - dataHasChanged = true - } // Add format to badge data. badgeData.format = format - // Update information in the cache. - const updatedCache = { - reqs: cached ? cached.reqs + 1 : 1, - dataChange: cached ? cached.dataChange + (dataHasChanged ? 1 : 0) : 1, - time: +reqTime, - interval: cacheInterval, - data: { format, badgeData }, - } - requestCache.set(cacheIndex, updatedCache) - if (!cachedVersionSent) { - const svg = makeBadge(badgeData) - setCacheHeadersOnResponse(ask.res, badgeData.cacheLengthSeconds) - makeSend(format, ask.res, end)(svg) - } + const svg = makeBadge(badgeData) + setCacheHeadersOnResponse(ask.res, badgeData.cacheLengthSeconds) + makeSend(format, ask.res, end)(svg) }, - cachingRequest ) if (result && result.catch) { + // eslint-disable-next-line promise/prefer-await-to-then result.catch(err => { throw err }) @@ -285,15 +135,4 @@ function handleRequest(cacheHeaderConfig, handlerOptions) { } } -function clearRequestCache() { - requestCache.clear() -} - -module.exports = { - handleRequest, - promisify, - clearRequestCache, - // Expose for testing. - _requestCache: requestCache, - userAgent, -} +export { handleRequest } diff --git a/core/base-service/legacy-request-handler.spec.js b/core/base-service/legacy-request-handler.spec.js index 4ca4b5ec24aaf..7bdd8e897399e 100644 --- a/core/base-service/legacy-request-handler.spec.js +++ b/core/base-service/legacy-request-handler.spec.js @@ -1,16 +1,9 @@ -'use strict' - -const { expect } = require('chai') -const nock = require('nock') -const portfinder = require('portfinder') -const Camp = require('@shields_io/camp') -const got = require('../got-test-client') -const coalesceBadge = require('./coalesce-badge') -const { - handleRequest, - clearRequestCache, - _requestCache, -} = require('./legacy-request-handler') +import { expect } from 'chai' +import portfinder from 'portfinder' +import Camp from '@shields_io/camp' +import got from '../got-test-client.js' +import coalesceBadge from './coalesce-badge.js' +import { handleRequest } from './legacy-request-handler.js' async function performTwoRequests(baseUrl, first, second) { expect((await got(`${baseUrl}${first}`)).statusCode).to.equal(200) @@ -25,7 +18,7 @@ function fakeHandler(queryParams, match, sendBadge, request) { label: 'testing', message: someValue, }, - {} + {}, ) sendBadge(format, badgeData) } @@ -42,48 +35,25 @@ 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() { +describe('The request handler', function () { let port, baseUrl - beforeEach(async function() { + beforeEach(async function () { port = await portfinder.getPortPromise() baseUrl = `http://127.0.0.1:${port}` }) let camp - beforeEach(function(done) { + beforeEach(function (done) { camp = Camp.start({ port, hostname: '::' }) camp.on('listening', () => done()) }) - afterEach(function(done) { - clearRequestCache() + afterEach(function (done) { if (camp) { camp.close(() => done()) camp = null @@ -92,41 +62,17 @@ describe('The request handler', function() { const standardCacheHeaders = { defaultCacheLengthSeconds: 120 } - describe('the options object calling style', function() { - beforeEach(function() { - camp.route( - /^\/testing\/([^/]+)\.(svg|png|gif|jpg|json)$/, - handleRequest(standardCacheHeaders, { handler: fakeHandler }) - ) - }) - - it('should return the expected response', async function() { - const { statusCode, body } = await got(`${baseUrl}/testing/123.json`, { - json: true, - }) - expect(statusCode).to.equal(200) - expect(body).to.deep.equal({ - name: 'testing', - value: '123', - label: 'testing', - message: '123', - color: 'lightgrey', - link: [], - }) - }) - }) - - describe('the function shorthand calling style', function() { - beforeEach(function() { + describe('the options object calling style', function () { + beforeEach(function () { camp.route( /^\/testing\/([^/]+)\.(svg|png|gif|jpg|json)$/, - handleRequest(standardCacheHeaders, fakeHandler) + handleRequest(standardCacheHeaders, { handler: fakeHandler }), ) }) - it('should return the expected response', async function() { + it('should return the expected response', async function () { const { statusCode, body } = await got(`${baseUrl}/testing/123.json`, { - json: true, + responseType: 'json', }) expect(statusCode).to.equal(200) expect(body).to.deep.equal({ @@ -140,24 +86,17 @@ describe('The request handler', function() { }) }) - describe('the response size limit', function() { - beforeEach(function() { + describe('the function shorthand calling style', function () { + beforeEach(function () { camp.route( /^\/testing\/([^/]+)\.(svg|png|gif|jpg|json)$/, - handleRequest(standardCacheHeaders, { - handler: fakeHandlerWithNetworkIo, - fetchLimitBytes: 100, - }) + handleRequest(standardCacheHeaders, fakeHandler), ) }) - 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)) + it('should return the expected response', async function () { const { statusCode, body } = await got(`${baseUrl}/testing/123.json`, { - json: true, + responseType: 'json', }) expect(statusCode).to.equal(200) expect(body).to.deep.equal({ @@ -169,95 +108,33 @@ describe('The request handler', function() { 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`, { - json: true, - }) - 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() { - let handlerCallCount - beforeEach(function() { - handlerCallCount = 0 - }) - + describe('caching', function () { + describe('standard query parameters', function () { function register({ cacheHeaderConfig }) { camp.route( /^\/testing\/([^/]+)\.(svg|png|gif|jpg|json)$/, handleRequest( cacheHeaderConfig, (queryParams, match, sendBadge, request) => { - ++handlerCallCount fakeHandler(queryParams, match, sendBadge, request) - } - ) + }, + ), ) } - context('With standard cache settings', function() { - beforeEach(function() { - register({ cacheHeaderConfig: standardCacheHeaders }) - }) - - it('should cache identical requests', async function() { - await performTwoRequests( - baseUrl, - '/testing/123.svg', - '/testing/123.svg' - ) - expect(handlerCallCount).to.equal(1) - }) - - it('should differentiate known query parameters', async function() { - await performTwoRequests( - baseUrl, - '/testing/123.svg?label=foo', - '/testing/123.svg?label=bar' - ) - expect(handlerCallCount).to.equal(2) - }) - - it('should ignore unknown query parameters', async function() { - await performTwoRequests( - baseUrl, - '/testing/123.svg?foo=1', - '/testing/123.svg?foo=2' - ) - expect(handlerCallCount).to.equal(1) - }) - }) - - it('should set the expires header to current time + defaultCacheLengthSeconds', async function() { + it('should set the expires header to current time + defaultCacheLengthSeconds', async function () { register({ cacheHeaderConfig: { defaultCacheLengthSeconds: 900 } }) const { headers } = await got(`${baseUrl}/testing/123.json`) const expectedExpiry = new Date( - +new Date(headers.date) + 900000 + +new Date(headers.date) + 900000, ).toGMTString() expect(headers.expires).to.equal(expectedExpiry) - expect(headers['cache-control']).to.equal('max-age=900') + expect(headers['cache-control']).to.equal('max-age=900, s-maxage=900') }) - it('should set the expected cache headers on cached responses', async function() { + it('should set the expected cache headers on cached responses', async function () { register({ cacheHeaderConfig: { defaultCacheLengthSeconds: 900 } }) // Make first request. @@ -265,106 +142,89 @@ 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') + 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( { defaultCacheLengthSeconds: 300 }, (queryParams, match, sendBadge, request) => { - ++handlerCallCount createFakeHandlerWithCacheLength(400)( queryParams, match, sendBadge, - request + request, ) - } - ) + }, + ), ) const { headers } = await got(`${baseUrl}/testing/123.json`) - expect(headers['cache-control']).to.equal('max-age=400') + 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( { defaultCacheLengthSeconds: 300 }, (queryParams, match, sendBadge, request) => { - ++handlerCallCount createFakeHandlerWithCacheLength(200)( queryParams, match, sendBadge, - request + request, ) - } - ) + }, + ), ) const { headers } = await got(`${baseUrl}/testing/123.json`) - expect(headers['cache-control']).to.equal('max-age=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() { + 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') + expect(headers['cache-control']).to.equal('max-age=3600, s-maxage=3600') }) - it('should ignore cacheSeconds when shorter than defaultCacheLengthSeconds', async 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') + expect(headers['cache-control']).to.equal('max-age=600, s-maxage=600') }) - it('should set Cache-Control: no-cache, no-store, must-revalidate if cache seconds is 0', async function() { + it('should set Cache-Control: no-cache, no-store, must-revalidate if cache seconds is 0', async function () { register({ cacheHeaderConfig: { defaultCacheLengthSeconds: 0 } }) const { headers } = await got(`${baseUrl}/testing/123.json`) expect(headers.expires).to.equal(headers.date) expect(headers['cache-control']).to.equal( - 'no-cache, no-store, must-revalidate' + 'no-cache, no-store, must-revalidate', ) }) - - describe('the cache key', function() { - beforeEach(function() { - register({ cacheHeaderConfig: standardCacheHeaders }) - }) - const expectedCacheKey = '/testing/123.json?color=123&label=foo' - it('should match expected and use canonical order - 1', async function() { - await got(`${baseUrl}/testing/123.json?color=123&label=foo`) - expect(_requestCache.cache).to.have.keys(expectedCacheKey) - }) - it('should match expected and use canonical order - 2', async function() { - await got(`${baseUrl}/testing/123.json?label=foo&color=123`) - expect(_requestCache.cache).to.have.keys(expectedCacheKey) - }) - }) }) - describe('custom query parameters', function() { + describe('custom query parameters', function () { let handlerCallCount - beforeEach(function() { + beforeEach(function () { handlerCallCount = 0 camp.route( /^\/testing\/([^/]+)\.(svg|png|gif|jpg|json)$/, @@ -374,15 +234,15 @@ describe('The request handler', function() { ++handlerCallCount fakeHandler(queryParams, match, sendBadge, request) }, - }) + }), ) }) - it('should differentiate them', async function() { + it('should differentiate them', async 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 01ffc800cc6d9..6d2d030323e53 100644 --- a/core/base-service/legacy-result-sender.js +++ b/core/base-service/legacy-result-sender.js @@ -1,6 +1,4 @@ -'use strict' - -const stream = require('stream') +import stream from 'stream' function streamFromString(str) { const newStream = new stream.Readable() @@ -13,25 +11,31 @@ 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}`) } } -module.exports = { - makeSend, -} +export { makeSend } diff --git a/core/base-service/loader-test-fixtures/empty-array.fixture.js b/core/base-service/loader-test-fixtures/empty-array.fixture.js index d0c6276a37eb4..9859f0797256b 100644 --- a/core/base-service/loader-test-fixtures/empty-array.fixture.js +++ b/core/base-service/loader-test-fixtures/empty-array.fixture.js @@ -1,3 +1 @@ -'use strict' - -module.exports = [] +export default [] diff --git a/core/base-service/loader-test-fixtures/empty-no-export.fixture.js b/core/base-service/loader-test-fixtures/empty-no-export.fixture.js index 883cb52b674f6..e4e8dbb5a4129 100644 --- a/core/base-service/loader-test-fixtures/empty-no-export.fixture.js +++ b/core/base-service/loader-test-fixtures/empty-no-export.fixture.js @@ -1,4 +1,2 @@ /* eslint-disable */ -'use strict' - -class BadService {} +class BadService {} // lgtm [js/unused-local-variable] diff --git a/core/base-service/loader-test-fixtures/empty-object.fixture.js b/core/base-service/loader-test-fixtures/empty-object.fixture.js index 545992ebbbabe..336ce12bb9106 100644 --- a/core/base-service/loader-test-fixtures/empty-object.fixture.js +++ b/core/base-service/loader-test-fixtures/empty-object.fixture.js @@ -1,3 +1 @@ -'use strict' - -module.exports = {} +export {} diff --git a/core/base-service/loader-test-fixtures/empty-undefined.fixture.js b/core/base-service/loader-test-fixtures/empty-undefined.fixture.js index 3ad27782bc55c..edb72725b9e7c 100644 --- a/core/base-service/loader-test-fixtures/empty-undefined.fixture.js +++ b/core/base-service/loader-test-fixtures/empty-undefined.fixture.js @@ -1,3 +1 @@ -'use strict' - -module.exports = undefined +export default undefined 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 7cacacc9033ed..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,9 +1,10 @@ -'use strict' - -const BaseJsonService = require('../base-json') +import BaseJsonService from '../base-json.js' class BadBaseService {} -class GoodService extends BaseJsonService {} -class BadService extends BadBaseService {} +class GoodMixedService extends BaseJsonService { + static category = 'build' + static route = { base: 'it/is', pattern: 'good' } +} +class BadMixedService extends BadBaseService {} -module.exports = [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 2a8a55c48effb..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,5 +1,3 @@ -'use strict' +class BadNoBaseService {} -class BadService {} - -module.exports = 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 89b1451d9616f..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,6 +1,4 @@ -'use strict' - class BadBaseService {} -class BadService extends BadBaseService {} +class BadChildService extends BadBaseService {} -module.exports = 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 b15fba31e5f08..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,30 +1,12 @@ -'use strict' +import BaseJsonService from '../base-json.js' -const BaseJsonService = require('../base-json') - -class GoodServiceOne extends BaseJsonService { - static get category() { - return 'build' - } - - static get route() { - return { - base: 'good', - pattern: 'one', - } - } +class GoodServiceArrayOne extends BaseJsonService { + static category = 'build' + static route = { base: 'good', pattern: 'one' } } -class GoodServiceTwo extends BaseJsonService { - static get category() { - return 'build' - } - - static get route() { - return { - base: 'good', - pattern: 'two', - } - } +class GoodServiceArrayTwo extends BaseJsonService { + static category = 'build' + static route = { base: 'good', pattern: 'two' } } -module.exports = [GoodServiceOne, GoodServiceTwo] +export default [GoodServiceArrayOne, GoodServiceArrayTwo] diff --git a/core/base-service/loader-test-fixtures/valid-class.fixture.js b/core/base-service/loader-test-fixtures/valid-class.fixture.js index 227b9868a23f0..0b193a9405df7 100644 --- a/core/base-service/loader-test-fixtures/valid-class.fixture.js +++ b/core/base-service/loader-test-fixtures/valid-class.fixture.js @@ -1,18 +1,8 @@ -'use strict' - -const BaseJsonService = require('../base-json') +import BaseJsonService from '../base-json.js' class GoodService extends BaseJsonService { - static get category() { - return 'build' - } - - static get route() { - return { - base: 'it/is', - pattern: 'good', - } - } + static category = 'build' + static route = { base: 'it/is', pattern: 'good' } } -module.exports = GoodService +export default GoodService 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 e6f24c8ea420a..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,30 +1,12 @@ -'use strict' +import BaseJsonService from '../base-json.js' -const BaseJsonService = require('../base-json') - -class GoodServiceOne extends BaseJsonService { - static get category() { - return 'build' - } - - static get route() { - return { - base: 'good', - pattern: 'one', - } - } +class GoodServiceObjectOne extends BaseJsonService { + static category = 'build' + static route = { base: 'good', pattern: 'one' } } -class GoodServiceTwo extends BaseJsonService { - static get category() { - return 'build' - } - - static get route() { - return { - base: 'good', - pattern: 'two', - } - } +class GoodServiceObjectTwo extends BaseJsonService { + static category = 'build' + static route = { base: 'good', pattern: 'two' } } -module.exports = { GoodServiceOne, GoodServiceTwo } +export { GoodServiceObjectOne, GoodServiceObjectTwo } diff --git a/core/base-service/loader.js b/core/base-service/loader.js index c2404b8c38da8..9b72355436299 100644 --- a/core/base-service/loader.js +++ b/core/base-service/loader.js @@ -1,13 +1,24 @@ -'use strict' - -const path = require('path') -const glob = require('glob') -const countBy = require('lodash.countby') -const categories = require('../../services/categories') -const BaseService = require('./base') -const { assertValidServiceDefinitionExport } = require('./service-definitions') - -const serviceDir = path.join(__dirname, '..', '..', 'services') +import path from 'path' +import { fileURLToPath } from 'url' +import { globSync } from 'glob' +import countBy from 'lodash.countby' +import categories from '../../services/categories.js' +import BaseService from './base.js' +import { assertValidServiceDefinitionExport } from './service-definitions.js' + +const serviceDir = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', + '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) { @@ -16,56 +27,8 @@ class InvalidService extends Error { } } -function loadServiceClasses(servicePaths) { - if (!servicePaths) { - servicePaths = glob.sync(path.join(serviceDir, '**', '*.service.js')) - } - - let serviceClasses = [] - servicePaths.forEach(servicePath => { - const module = require(servicePath) - - const theseServiceClasses = [] - if ( - !module || - (module.constructor === Array && module.length === 0) || - (module.constructor === Object && Object.keys(module).length === 0) - ) { - throw new InvalidService( - `Expected ${servicePath} to export a service or a collection of services` - ) - } else if (module.prototype instanceof BaseService) { - theseServiceClasses.push(module) - } else if (module.constructor === Array || module.constructor === Object) { - for (const key in module) { - const serviceClass = module[key] - if (serviceClass.prototype instanceof BaseService) { - theseServiceClasses.push(serviceClass) - } else { - throw new InvalidService( - `Expected ${servicePath} to export a service or a collection of services; one of them was ${serviceClass}` - ) - } - } - } else { - throw new InvalidService( - `Expected ${servicePath} to export a service or a collection of services; got ${module}` - ) - } - - // Decorate each service class with the directory that contains it. - theseServiceClasses.forEach(serviceClass => { - serviceClass.serviceFamily = servicePath - .replace(serviceDir, '') - .split(path.sep)[1] - }) - - serviceClasses = serviceClasses.concat(theseServiceClasses) - }) - - serviceClasses.forEach(ServiceClass => ServiceClass.validateDefinition()) - - return serviceClasses +function getServicePaths(pattern) { + return globSync(toUnixPath(path.join(serviceDir, '**', pattern))).sort() } function assertNamesUnique(names, { message }) { @@ -80,18 +43,63 @@ function assertNamesUnique(names, { message }) { } } -function checkNames() { - const services = loadServiceClasses() +async function loadServiceClasses(servicePaths) { + if (!servicePaths) { + servicePaths = getServicePaths('*.service.js') + } + + const serviceClasses = [] + for await (const servicePath of servicePaths) { + const currentServiceClasses = Object.values( + await import(`file://${servicePath}`), + ).flatMap(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`, + ) + } + currentServiceClasses.forEach(serviceClass => { + if (serviceClass && serviceClass.prototype instanceof BaseService) { + // Decorate each service class with the directory that contains it. + serviceClass.serviceFamily = servicePath + .replace(serviceDir, '') + .split(path.sep)[1] + serviceClass.validateDefinition() + return serviceClasses.push(serviceClass) + } + throw new InvalidService( + `Expected ${servicePath} to export a service or a collection of services; one of them was ${serviceClass}`, + ) + }) + } + 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 } -function collectDefinitions() { - const services = loadServiceClasses() +async function collectDefinitions() { + const services = (await loadServiceClasses()) // flatMap. .map(ServiceClass => ServiceClass.getDefinition()) .reduce((accum, these) => accum.concat(these), []) @@ -103,16 +111,18 @@ function collectDefinitions() { return result } -function loadTesters() { - return glob - .sync(path.join(serviceDir, '**', '*.tester.js')) - .map(path => require(path)) +async function loadTesters() { + return Promise.all( + getServicePaths('*.tester.js').map( + async path => await import(`file://${path}`), + ), + ) } -module.exports = { +export { InvalidService, loadServiceClasses, - checkNames, + getServicePaths, collectDefinitions, loadTesters, } diff --git a/core/base-service/loader.spec.js b/core/base-service/loader.spec.js index a8a0443ad164f..5973acf4d766b 100644 --- a/core/base-service/loader.spec.js +++ b/core/base-service/loader.spec.js @@ -1,59 +1,88 @@ -'use strict' +import path from 'path' +import { fileURLToPath } from 'url' +import { expect, use } from 'chai' +import chaiAsPromised from 'chai-as-promised' +import { + loadServiceClasses, + getServicePaths, + InvalidService, +} from './loader.js' +use(chaiAsPromised) -const { expect } = require('chai') -const { loadServiceClasses, InvalidService } = require('./loader') +const fixturesDir = path.join( + path.dirname(fileURLToPath(import.meta.url)), + 'loader-test-fixtures', +) -describe('loadServiceClasses function', function() { - it('throws if module exports empty', function() { - expect(() => - loadServiceClasses(['./loader-test-fixtures/empty-undefined.fixture.js']) - ).to.throw(InvalidService) - expect(() => - loadServiceClasses(['./loader-test-fixtures/empty-array.fixture.js']) - ).to.throw() - expect(() => - loadServiceClasses(['./loader-test-fixtures/empty-object.fixture.js']) - ).to.throw(InvalidService) - expect(() => - loadServiceClasses(['./loader-test-fixtures/empty-no-export.fixture.js']) - ).to.throw(InvalidService) - expect(() => +describe('loadServiceClasses function', function () { + it('throws if module exports empty', async function () { + await expect( loadServiceClasses([ - './loader-test-fixtures/valid-array.fixture.js', - './loader-test-fixtures/valid-class.fixture.js', - './loader-test-fixtures/empty-array.fixture.js', - ]) - ).to.throw(InvalidService) + path.join(fixturesDir, 'empty-undefined.fixture.js'), + ]), + ).to.be.rejectedWith(InvalidService) + await expect( + loadServiceClasses([path.join(fixturesDir, 'empty-array.fixture.js')]), + ).to.be.rejectedWith(InvalidService) + await expect( + loadServiceClasses([path.join(fixturesDir, 'empty-object.fixture.js')]), + ).to.be.rejectedWith(InvalidService) + await expect( + 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', function() { - expect(() => - loadServiceClasses(['./loader-test-fixtures/invalid-no-base.fixture.js']) - ).to.throw(InvalidService) - expect(() => + it('throws if module exports invalid', async function () { + await expect( loadServiceClasses([ - './loader-test-fixtures/invalid-wrong-base.fixture.js', - ]) - ).to.throw(InvalidService) - expect(() => - loadServiceClasses(['./loader-test-fixtures/invalid-mixed.fixture.js']) - ).to.throw(InvalidService) - expect(() => + path.join(fixturesDir, 'invalid-no-base.fixture.js'), + ]), + ).to.be.rejectedWith(InvalidService) + await expect( loadServiceClasses([ - './loader-test-fixtures/valid-array.fixture.js', - './loader-test-fixtures/valid-class.fixture.js', - './loader-test-fixtures/invalid-no-base.fixture.js', - ]) - ).to.throw(InvalidService) + path.join(fixturesDir, 'invalid-wrong-base.fixture.js'), + ]), + ).to.be.rejectedWith(InvalidService) + await expect( + 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) }) - it('registers services if module exports valid service classes', function() { - expect( + it('registers services if module exports valid service classes', async function () { + await expect( loadServiceClasses([ - './loader-test-fixtures/valid-array.fixture.js', - './loader-test-fixtures/valid-object.fixture.js', - './loader-test-fixtures/valid-class.fixture.js', - ]) - ).to.have.length(5) + 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/lru-cache.js b/core/base-service/lru-cache.js deleted file mode 100644 index 3aacf7277dc8d..0000000000000 --- a/core/base-service/lru-cache.js +++ /dev/null @@ -1,136 +0,0 @@ -'use strict' - -// In-memory KV, remove the oldest data when the capacity is reached. - -const typeEnum = { - unit: 0, - heap: 1, -} - -// In bytes. -let heapSize -function computeHeapSize() { - return (heapSize = process.memoryUsage().heapTotal) -} - -let heapSizeTimeout -function getHeapSize() { - if (heapSizeTimeout == null) { - // Compute the heap size every 60 seconds. - heapSizeTimeout = setInterval(computeHeapSize, 60 * 1000) - return computeHeapSize() - } else { - return heapSize - } -} - -function CacheSlot(key, value) { - this.key = key - this.value = value - this.older = null // Newest slot that is older than this slot. - this.newer = null // Oldest slot that is newer than this slot. -} - -function Cache(capacity, type) { - type = type || 'unit' - this.capacity = capacity - this.type = typeEnum[type] - this.cache = new Map() // Maps cache keys to CacheSlots. - this.newest = null // Newest slot in the cache. - this.oldest = null -} - -Cache.prototype = { - set: function addToCache(cacheKey, cached) { - let slot = this.cache.get(cacheKey) - if (slot === undefined) { - slot = new CacheSlot(cacheKey, cached) - this.cache.set(cacheKey, slot) - } - this.makeNewest(slot) - const numItemsToRemove = this.limitReached() - if (numItemsToRemove > 0) { - for (let i = 0; i < numItemsToRemove; i++) { - this.removeOldest() - } - } - }, - - get: function getFromCache(cacheKey) { - const slot = this.cache.get(cacheKey) - if (slot !== undefined) { - this.makeNewest(slot) - return slot.value - } - }, - - has: function hasInCache(cacheKey) { - return this.cache.has(cacheKey) - }, - - makeNewest: function makeNewestSlot(slot) { - const previousNewest = this.newest - if (previousNewest === slot) { - return - } - const older = slot.older - const newer = slot.newer - - if (older !== null) { - older.newer = newer - } else if (newer !== null) { - this.oldest = newer - } - if (newer !== null) { - newer.older = older - } - this.newest = slot - - if (previousNewest !== null) { - slot.older = previousNewest - slot.newer = null - previousNewest.newer = slot - } else { - // If previousNewest is null, the cache used to be empty. - this.oldest = slot - } - }, - - removeOldest: function removeOldest() { - const cacheKey = this.oldest.key - if (this.oldest !== null) { - this.oldest = this.oldest.newer - if (this.oldest !== null) { - this.oldest.older = null - } - } - this.cache.delete(cacheKey) - }, - - // Returns the number of elements to remove if we're past the limit. - limitReached: function heuristic() { - if (this.type === typeEnum.unit) { - // Remove the excess. - return Math.max(0, this.cache.size - this.capacity) - } else if (this.type === typeEnum.heap) { - if (getHeapSize() >= this.capacity) { - console.log('LRU HEURISTIC heap:', getHeapSize()) - // Remove half of them. - return this.cache.size >> 1 - } else { - return 0 - } - } else { - console.error(`Unknown heuristic '${this.type}' for LRU cache.`) - return 1 - } - }, - - clear: function() { - this.cache.clear() - this.newest = null - this.oldest = null - }, -} - -module.exports = Cache diff --git a/core/base-service/lru-cache.spec.js b/core/base-service/lru-cache.spec.js deleted file mode 100644 index 203eb8e156c91..0000000000000 --- a/core/base-service/lru-cache.spec.js +++ /dev/null @@ -1,134 +0,0 @@ -'use strict' - -const { expect } = require('chai') -const LRU = require('./lru-cache') - -function expectCacheSlots(cache, keys) { - expect(cache.cache.size).to.equal(keys.length) - - const slots = keys.map(k => cache.cache.get(k)) - - const first = slots[0] - const last = slots.slice(-1)[0] - - expect(cache.oldest).to.equal(first) - expect(cache.newest).to.equal(last) - - expect(first.older).to.be.null - expect(last.newer).to.be.null - - for (let i = 0; i + 1 < slots.length; ++i) { - const current = slots[i] - const next = slots[i + 1] - expect(current.newer).to.equal(next) - expect(next.older).to.equal(current) - } -} - -describe('The LRU cache', function() { - it('should support a zero capacity', function() { - const cache = new LRU(0) - cache.set('key', 'value') - expect(cache.cache.size).to.equal(0) - }) - - it('should support a one capacity', function() { - const cache = new LRU(1) - cache.set('key1', 'value1') - expectCacheSlots(cache, ['key1']) - cache.set('key2', 'value2') - expectCacheSlots(cache, ['key2']) - expect(cache.get('key1')).to.be.undefined - expect(cache.get('key2')).to.equal('value2') - }) - - it('should remove the oldest element when reaching capacity', function() { - const cache = new LRU(2) - - cache.set('key1', 'value1') - cache.set('key2', 'value2') - cache.set('key3', 'value3') - cache.cache.get('key1') - - expectCacheSlots(cache, ['key2', 'key3']) - expect(cache.cache.get('key1')).to.be.undefined - expect(cache.get('key1')).to.be.undefined - expect(cache.get('key2')).to.equal('value2') - expect(cache.get('key3')).to.equal('value3') - }) - - it('should make sure that resetting a key in cache makes it newest', function() { - const cache = new LRU(2) - - cache.set('key', 'value') - cache.set('key2', 'value2') - - expectCacheSlots(cache, ['key', 'key2']) - - cache.set('key', 'value') - - expectCacheSlots(cache, ['key2', 'key']) - }) - - describe('getting a key in the cache', function() { - context('when the requested key is oldest', function() { - it('should leave the keys in the expected order', function() { - const cache = new LRU(2) - cache.set('key1', 'value1') - cache.set('key2', 'value2') - - expectCacheSlots(cache, ['key1', 'key2']) - - expect(cache.get('key1')).to.equal('value1') - - expectCacheSlots(cache, ['key2', 'key1']) - }) - }) - - context('when the requested key is newest', function() { - it('should leave the keys in the expected order', function() { - const cache = new LRU(2) - cache.set('key1', 'value1') - cache.set('key2', 'value2') - - expect(cache.get('key2')).to.equal('value2') - - expectCacheSlots(cache, ['key1', 'key2']) - }) - }) - - context('when the requested key is in the middle', function() { - it('should leave the keys in the expected order', function() { - const cache = new LRU(3) - cache.set('key1', 'value1') - cache.set('key2', 'value2') - cache.set('key3', 'value3') - - expectCacheSlots(cache, ['key1', 'key2', 'key3']) - - expect(cache.get('key2')).to.equal('value2') - - expectCacheSlots(cache, ['key1', 'key3', 'key2']) - }) - }) - }) - - it('should clear', function() { - // Set up. - const cache = new LRU(2) - cache.set('key1', 'value1') - cache.set('key2', 'value2') - - // Confidence check. - expect(cache.get('key1')).to.equal('value1') - expect(cache.get('key2')).to.equal('value2') - - // Run. - cache.clear() - - // Test. - expect(cache.get('key1')).to.be.undefined - expect(cache.get('key2')).to.be.undefined - expect(cache.cache.size).to.equal(0) - }) -}) diff --git a/core/base-service/metric-helper.js b/core/base-service/metric-helper.js index aab44226318fb..646aa721b5a28 100644 --- a/core/base-service/metric-helper.js +++ b/core/base-service/metric-helper.js @@ -1,6 +1,4 @@ -'use strict' - -const { performance } = require('perf_hooks') +import { performance } from 'perf_hooks' class MetricHelper { constructor({ metricInstance }, { category, serviceFamily, name }) { @@ -11,13 +9,12 @@ class MetricHelper { serviceFamily, name, }) - this.serviceResponseSizeHistogram = metricInstance.createServiceResponseSizeHistogram( - { + this.serviceResponseSizeHistogram = + metricInstance.createServiceResponseSizeHistogram({ category, serviceFamily, name, - } - ) + }) } else { this.metricInstance = undefined this.serviceRequestCounter = undefined @@ -60,4 +57,4 @@ const MetricNames = Object.freeze({ SERVICE_RESPONSE_SIZE: Symbol('service-response-size'), }) -module.exports = { MetricHelper, MetricNames } +export { MetricHelper, MetricNames } 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 29ede9a73e55c..8bcc4f12d7d81 100644 --- a/core/base-service/redirector.js +++ b/core/base-service/redirector.js @@ -1,67 +1,59 @@ -'use strict' - -const camelcase = require('camelcase') -const emojic = require('emojic') -const Joi = require('@hapi/joi') -const queryString = require('query-string') -const BaseService = require('./base') -const { +import camelcase from 'camelcase' +import emojic from 'emojic' +import Joi from 'joi' +import qs from 'qs' +import BaseService from './base.js' +import { serverHasBeenUpSinceResourceCached, setCacheHeadersForStaticResource, -} = require('./cache-headers') -const { isValidCategory } = require('./categories') -const { MetricHelper } = require('./metric-helper') -const { isValidRoute, prepareRoute, namedParamsForMatch } = require('./route') -const trace = require('./trace') +} from './cache-headers.js' +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, + isRetired: Joi.boolean().default(true), route: isValidRoute, + 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(), overrideTransformedQueryParams: Joi.bool().optional(), }).required() -module.exports = function redirector(attrs) { +export default function redirector(attrs) { const { name, category, + isRetired, route, + openApi, transformPath, transformQueryParams, overrideTransformedQueryParams, } = Joi.attempt(attrs, attrSchema, `Redirector for ${attrs.route.base}`) return class Redirector extends BaseService { - static get name() { - if (name) { - return name - } else { - return `${camelcase(route.base.replace(/\//g, '_'), { - pascalCase: true, - })}Redirect` - } - } - - static get category() { - return category - } + static name = + name || + `${camelcase(route.base.replace(/\//g, '_'), { + pascalCase: true, + })}Redirect` - static get isDeprecated() { - return true - } - - static get route() { - return route - } + static category = category + static isRetired = isRetired + static route = route + static openApi = openApi static register({ camp, metricInstance }, { rasterUrl }) { const { regex, captureNames } = prepareRoute({ @@ -89,23 +81,27 @@ module.exports = 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) - const targetPath = transformPath(namedParams) + const targetPath = encodeURI(transformPath(namedParams)) trace.logTrace('validate', emojic.dart, 'Target', targetPath) 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}` } @@ -119,8 +115,12 @@ module.exports = 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 40eeab816af1a..0273d85ee3f30 100644 --- a/core/base-service/redirector.spec.js +++ b/core/base-service/redirector.spec.js @@ -1,12 +1,10 @@ -'use strict' +import Camp from '@shields_io/camp' +import portfinder from 'portfinder' +import { expect } from 'chai' +import got from '../got-test-client.js' +import redirector from './redirector.js' -const Camp = require('@shields_io/camp') -const portfinder = require('portfinder') -const { expect } = require('chai') -const got = require('../got-test-client') -const redirector = require('./redirector') - -describe('Redirector', function() { +describe('Redirector', function () { const route = { base: 'very/old/service', pattern: ':namedParamA', @@ -16,50 +14,50 @@ 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() { + it('has the expected name', function () { expect(redirector(attrs).name).to.equal('VeryOldServiceRedirect') }) - it('overrides the name', function() { + it('overrides the name', function () { expect( redirector({ ...attrs, name: 'ShinyRedirect', - }).name + }).name, ).to.equal('ShinyRedirect') }) - it('sets specified route', function() { + it('sets specified route', function () { expect(redirector(attrs).route).to.deep.equal(route) }) - it('sets specified category', function() { + it('sets specified category', function () { expect(redirector(attrs).category).to.equal(category) }) - it('throws the expected error when dateAdded is missing', 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') }) - describe('ScoutCamp integration', function() { + describe('ScoutCamp integration', function () { let port, baseUrl - beforeEach(async function() { + beforeEach(async function () { port = await portfinder.getPortPromise() baseUrl = `http://127.0.0.1:${port}` }) let camp - beforeEach(async function() { + beforeEach(async function () { camp = Camp.start({ port, hostname: '::' }) await new Promise(resolve => camp.on('listening', () => resolve())) }) - afterEach(async function() { + afterEach(async function () { if (camp) { await new Promise(resolve => camp.close(resolve)) camp = undefined @@ -68,7 +66,7 @@ describe('Redirector', function() { const transformPath = ({ namedParamA }) => `/new/service/${namedParamA}` - beforeEach(function() { + beforeEach(function () { const ServiceClass = redirector({ category, route, @@ -77,58 +75,72 @@ describe('Redirector', function() { }) ServiceClass.register( { camp }, - { rasterUrl: 'http://raster.example.test' } + { rasterUrl: 'http://raster.example.test' }, ) }) - it('should redirect as configured', async function() { + it('should redirect as configured', async function () { const { statusCode, headers } = await got( `${baseUrl}/very/old/service/hello-world.svg`, { followRedirect: false, - } + }, ) expect(statusCode).to.equal(301) expect(headers.location).to.equal('/new/service/hello-world.svg') }) - it('should redirect raster extensions to the canonical path as configured', async function() { + it('should redirect raster extensions to the canonical path as configured', async function () { const { statusCode, headers } = await got( `${baseUrl}/very/old/service/hello-world.png`, { followRedirect: false, - } + }, ) 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', ) }) - it('should forward the query params', async function() { + it('should forward the query params', async function () { const { statusCode, headers } = await got( `${baseUrl}/very/old/service/hello-world.svg?color=123&style=flat-square`, { followRedirect: false, - } + }, + ) + + expect(statusCode).to.equal(301) + expect(headers.location).to.equal( + '/new/service/hello-world.svg?color=123&style=flat-square', + ) + }) + + it('should correctly encode the redirect URL', async function () { + const { statusCode, headers } = await got( + `${baseUrl}/very/old/service/hello%0Dworld.svg?foobar=a%0Db`, + { + followRedirect: false, + }, ) expect(statusCode).to.equal(301) expect(headers.location).to.equal( - '/new/service/hello-world.svg?color=123&style=flat-square' + '/new/service/hello%0Dworld.svg?foobar=a%0Db', ) }) - describe('transformQueryParams', function() { + describe('transformQueryParams', function () { const route = { base: 'another/old/service', pattern: 'token/:token/:namedParamA', } const transformQueryParams = ({ token }) => ({ token }) - beforeEach(function() { + beforeEach(function () { const ServiceClass = redirector({ category, route, @@ -139,49 +151,49 @@ describe('Redirector', function() { ServiceClass.register({ camp }, {}) }) - it('should forward the transformed query params', async function() { + it('should forward the transformed query params', async function () { const { statusCode, headers } = await got( `${baseUrl}/another/old/service/token/abc123/hello-world.svg`, { followRedirect: false, - } + }, ) expect(statusCode).to.equal(301) expect(headers.location).to.equal( - '/new/service/hello-world.svg?token=abc123' + '/new/service/hello-world.svg?token=abc123', ) }) - it('should forward the specified and transformed query params', async function() { + it('should forward the specified and transformed query params', async function () { const { statusCode, headers } = await got( `${baseUrl}/another/old/service/token/abc123/hello-world.svg?color=123&style=flat-square`, { followRedirect: false, - } + }, ) 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', ) }) - it('should use transformed query params on param conflicts by default', async function() { + it('should use transformed query params on param conflicts by default', async function () { const { statusCode, headers } = await got( `${baseUrl}/another/old/service/token/abc123/hello-world.svg?color=123&style=flat-square&token=def456`, { followRedirect: false, - } + }, ) 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', ) }) - it('should use specified query params on param conflicts when configured', async function() { + it('should use specified query params on param conflicts when configured', async function () { const route = { base: 'override/service', pattern: 'token/:token/:namedParamA', @@ -199,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 a785f80dadd54..7c497fc872eac 100644 --- a/core/base-service/route.js +++ b/core/base-service/route.js @@ -1,17 +1,13 @@ -'use strict' - -const escapeStringRegexp = require('escape-string-regexp') -const Joi = require('@hapi/joi') -const { pathToRegexp } = require('path-to-regexp') +import escapeStringRegexp from 'escape-string-regexp' +import Joi from 'joi' +import { pathToRegexp } from 'path-to-regexp' function makeFullUrl(base, partialUrl) { return `/${[base, partialUrl].filter(Boolean).join('/')}` } const isValidRoute = Joi.object({ - base: Joi.string() - .allow('') - .required(), + base: Joi.string().allow('').required(), pattern: Joi.string().allow(''), format: Joi.string(), capture: Joi.alternatives().conditional('format', { @@ -56,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})`, ) } @@ -76,7 +72,7 @@ function getQueryParamNames({ queryParamSchema }) { } } -module.exports = { +export { makeFullUrl, isValidRoute, assertValidRoute, diff --git a/core/base-service/route.spec.js b/core/base-service/route.spec.js index 113162d9c93fd..932a9f1da25ae 100644 --- a/core/base-service/route.spec.js +++ b/core/base-service/route.spec.js @@ -1,16 +1,14 @@ -'use strict' - -const { expect } = require('chai') -const Joi = require('@hapi/joi') -const { test, given, forCases } = require('sazerac') -const { +import { expect } from 'chai' +import Joi from 'joi' +import { test, given, forCases } from 'sazerac' +import { prepareRoute, namedParamsForMatch, getQueryParamNames, -} = require('./route') +} from './route.js' -describe('Route helpers', function() { - context('A `pattern` with a named param is declared', function() { +describe('Route helpers', function () { + context('A `pattern` with a named param is declared', function () { const { regex, captureNames } = prepareRoute({ base: 'foo', pattern: ':namedParamA', @@ -36,7 +34,7 @@ describe('Route helpers', function() { }) }) - context('A `format` with a named param is declared', function() { + context('A `format` with a named param is declared', function () { const { regex, captureNames } = prepareRoute({ base: 'foo', format: '([^/]+?)', @@ -62,7 +60,7 @@ describe('Route helpers', function() { }) }) - context('No named params are declared', function() { + context('No named params are declared', function () { const { regex, captureNames } = prepareRoute({ base: 'foo', format: '(?:[^/]+)', @@ -78,7 +76,7 @@ describe('Route helpers', function() { }) }) - context('The wrong number of params are declared', function() { + context('The wrong number of params are declared', function () { const { regex, captureNames } = prepareRoute({ base: 'foo', format: '([^/]+)/([^/]+)', @@ -88,24 +86,24 @@ 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)', ) }) - it('getQueryParamNames', function() { + it('getQueryParamNames', 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 c89f151bc28e2..c5730ec2a66d8 100644 --- a/core/base-service/service-definitions.js +++ b/core/base-service/service-definitions.js @@ -1,23 +1,49 @@ -'use strict' +/** + * @module + */ +import Joi from 'joi' -const Joi = require('@hapi/joi') +const arrayOfStrings = Joi.array().items(Joi.string()).min(0).required() -// 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(), @@ -26,37 +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({ @@ -66,22 +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(), + 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) } -module.exports = { - serviceDefinition, +export { assertValidServiceDefinition, - serviceDefinitionExport, assertValidServiceDefinitionExport, + openApiSchema, } diff --git a/core/base-service/to-array.js b/core/base-service/to-array.js index e22a7227b39dd..79669ed6fc530 100644 --- a/core/base-service/to-array.js +++ b/core/base-service/to-array.js @@ -1,6 +1,14 @@ -'use strict' - -module.exports = function toArray(val) { +/** + * 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 [] } else if (Object(val) instanceof Array) { diff --git a/core/base-service/trace.js b/core/base-service/trace.js index 97ef96c66cbb8..025259cf9e37d 100644 --- a/core/base-service/trace.js +++ b/core/base-service/trace.js @@ -1,7 +1,6 @@ -'use strict' - -const chalk = require('chalk') -const config = require('config').util.toObject() +import chalk from 'chalk' +import config from 'config' +const objectConfig = config.util.toObject() // Config is loaded globally but it would be better to inject it. To do that, // there needs to be one instance of the service created at registration time, @@ -10,8 +9,15 @@ const config = require('config').util.toObject() // thereby gaining access to the injected config. const { services: { trace: enableTraceLogging }, -} = config.public +} = 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, @@ -23,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) { @@ -37,6 +54,6 @@ function logTrace(stage, symbol, label, content, { deep = false } = {}) { } } -module.exports = { +export default { logTrace, } diff --git a/core/base-service/validate.js b/core/base-service/validate.js index 0bfc4a8773269..7a445f42214f0 100644 --- a/core/base-service/validate.js +++ b/core/base-service/validate.js @@ -1,8 +1,6 @@ -'use strict' - -const emojic = require('emojic') -const Joi = require('@hapi/joi') -const trace = require('./trace') +import emojic from 'emojic' +import Joi from 'joi' +import trace from './trace.js' function validate( { @@ -11,26 +9,22 @@ function validate( includeKeys = false, traceErrorMessage = 'Data did not match schema', traceSuccessMessage = 'Data after validation', - allowAndStripUnknownKeys = true, }, data, - schema + schema, ) { if (!schema || !Joi.isSchema(schema)) { throw Error('A Joi schema is required') } - const options = { abortEarly: false } - if (allowAndStripUnknownKeys) { - options.allowUnknown = true - options.stripUnknown = true - } + const options = { abortEarly: false, allowUnknown: true, stripUnknown: true } + const { error, value } = schema.validate(data, options) if (error) { trace.logTrace( 'validate', emojic.womanShrugging, traceErrorMessage, - error.message + error.message, ) let prettyMessage = prettyErrorMessage @@ -50,4 +44,4 @@ function validate( } } -module.exports = validate +export default validate diff --git a/core/base-service/validate.spec.js b/core/base-service/validate.spec.js index a7c2280ed586b..a8c757466a556 100644 --- a/core/base-service/validate.spec.js +++ b/core/base-service/validate.spec.js @@ -1,26 +1,20 @@ -'use strict' +import Joi from 'joi' +import { expect } from 'chai' +import sinon from 'sinon' +import trace from './trace.js' +import { InvalidParameter } from './errors.js' +import validate from './validate.js' -const Joi = require('@hapi/joi') -const { expect } = require('chai') -const sinon = require('sinon') -const trace = require('./trace') -const { InvalidParameter } = require('./errors') -const validate = require('./validate') - -describe('validate', function() { +describe('validate', function () { const schema = Joi.object({ requiredString: Joi.string().required(), }).required() - let sandbox - beforeEach(function() { - sandbox = sinon.createSandbox() - }) - afterEach(function() { - sandbox.restore() + beforeEach(function () { + sinon.stub(trace, 'logTrace') }) - beforeEach(function() { - sandbox.stub(trace, 'logTrace') + afterEach(function () { + sinon.restore() }) const ErrorClass = InvalidParameter @@ -35,8 +29,8 @@ describe('validate', function() { traceSuccessMessage, } - context('schema is not provided', function() { - it('throws the expected programmer error', function() { + context('schema is not provided', function () { + it('throws the expected programmer error', function () { try { validate(options, { requiredString: 'bar' }, undefined) expect.fail('Expected to throw') @@ -47,32 +41,32 @@ describe('validate', function() { }) }) - context('data matches schema', function() { - it('logs the data', function() { + context('data matches schema', function () { + it('logs the data', function () { validate(options, { requiredString: 'bar' }, schema) expect(trace.logTrace).to.be.calledWithMatch( 'validate', sinon.match.string, traceSuccessMessage, { requiredString: 'bar' }, - { deep: true } + { deep: true }, ) }) }) - context('data does not match schema', function() { - it('logs the data and throws the expected error', function() { + context('data does not match schema', function () { + it('logs the data and throws the expected error', function () { try { 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) } @@ -80,48 +74,40 @@ describe('validate', function() { 'validate', sinon.match.string, traceErrorMessage, - '"requiredString" must be a string' + '"requiredString" must be a string', ) }) - context('with includeKeys: true', function() { - it('includes keys in the error text', function() { + context('with includeKeys: true', function () { + it('includes keys in the error text', function () { try { validate( { ...options, includeKeys: true }, { 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`, ) } }) }) }) - it('allowAndStripUnknownKeys', function() { - try { - validate( - { ...options, allowAndStripUnknownKeys: false, includeKeys: true }, - { requiredString: 'bar', extra: 'nonsense', more: 'bogus' }, - 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' - ) - expect(e.prettyMessage).to.equal(`${prettyErrorMessage}: extra, more`) - } + it('allows but strips unknown keys', function () { + const result = validate( + { ...options, includeKeys: true }, + { requiredString: 'bar', extra: 'nonsense', more: 'bogus' }, + schema, + ) + expect(result).to.deep.equal({ requiredString: 'bar' }) }) }) diff --git a/core/got-test-client.js b/core/got-test-client.js index 70af889af97bc..f6349308c4601 100644 --- a/core/got-test-client.js +++ b/core/got-test-client.js @@ -1,6 +1,4 @@ -'use strict' - -const got = require('got') +import got from 'got' // https://github.com/nock/nock/issues/1523 -module.exports = 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 e32e05c412d1f..0000000000000 --- a/core/legacy/regular-update.js +++ /dev/null @@ -1,101 +0,0 @@ -'use strict' - -const { Inaccessible, InvalidResponse } = require('../base-service/errors') - -// 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: -// -// const { promisify } = require('util') -// const { regularUpdate } = require('../../core/legacy/regular-update') -// -// function getThing() { -// return promisify(regularUpdate)({ -// url: ..., -// ... -// }) -// } -// -// in handle(): -// -// const thing = await getThing() - -function regularUpdate( - { - url, - intervalMillis, - json = true, - scraper = buffer => buffer, - options = {}, - request = require('request'), - }, - 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) -} - -module.exports = { - regularUpdate, - clearRegularUpdateCache, -} diff --git a/core/register-chai-plugins.spec.js b/core/register-chai-plugins.spec.js index 0873f4c86344f..8ca325781b6fa 100644 --- a/core/register-chai-plugins.spec.js +++ b/core/register-chai-plugins.spec.js @@ -1,6 +1,3 @@ -'use strict' - -const { use } = require('chai') - -use(require('chai-string')) -use(require('sinon-chai')) +import { use } from 'chai' +import sinonChai from 'sinon-chai' +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/in-process-server-test-helpers.js b/core/server/in-process-server-test-helpers.js index 85280a1a31629..f27bc447c2031 100644 --- a/core/server/in-process-server-test-helpers.js +++ b/core/server/in-process-server-test-helpers.js @@ -1,18 +1,14 @@ -'use strict' - -const merge = require('deepmerge') -const config = require('config').util.toObject() -const portfinder = require('portfinder') -const Server = require('./server') +import merge from 'deepmerge' +import config from 'config' +import portfinder from 'portfinder' +import Server from './server.js' async function createTestServer(customConfig = {}) { - const mergedConfig = merge(config, customConfig) + const mergedConfig = merge(config.util.toObject(), customConfig) if (!mergedConfig.public.bind.port) { mergedConfig.public.bind.port = await portfinder.getPortPromise() } return new Server(mergedConfig) } -module.exports = { - createTestServer, -} +export { createTestServer } diff --git a/core/server/influx-metrics.js b/core/server/influx-metrics.js index 46e10a4f37896..ad61b8ab26d17 100644 --- a/core/server/influx-metrics.js +++ b/core/server/influx-metrics.js @@ -1,13 +1,10 @@ -'use strict' -const os = require('os') -const { promisify } = require('util') -const { post } = require('request') -const postAsync = promisify(post) -const generateInstanceId = require('./instance-id-generator') -const { promClientJsonToInfluxV2 } = require('./metrics/format-converters') -const log = require('./log') +import os from 'os' +import got from 'got' +import generateInstanceId from './instance-id-generator.js' +import { promClientJsonToInfluxV2 } from './metrics/format-converters.js' +import log from './log.js' -module.exports = class InfluxMetrics { +export default class InfluxMetrics { constructor(metricInstance, config) { this._metricInstance = metricInstance this._config = config @@ -15,31 +12,30 @@ module.exports = class InfluxMetrics { } async sendMetrics() { - const auth = { - user: this._config.username, - pass: this._config.password, - } const request = { - uri: this._config.url, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: this.metrics(), - timeout: this._config.timeoutMillseconds, - auth, + body: await this.metrics(), + timeout: { request: this._config.timeoutMillseconds }, + username: this._config.username, + password: this._config.password, + throwHttpErrors: false, } let response try { - response = await postAsync(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. ${response.request.href} responded with status code ${response.statusCode}` - ) + `Cannot push metrics. ${this._config.url} responded with status code ${response.statusCode}`, + ), ) } } @@ -47,12 +43,12 @@ module.exports = class InfluxMetrics { startPushingMetrics() { this._intervalId = setInterval( () => this.sendMetrics(), - this._config.intervalSeconds * 1000 + this._config.intervalSeconds * 1000, ) } - metrics() { - return promClientJsonToInfluxV2(this._metricInstance.metrics(), { + async metrics() { + return promClientJsonToInfluxV2(await this._metricInstance.metrics(), { env: this._config.envLabel, application: 'shields', instance: this._instanceId, diff --git a/core/server/influx-metrics.spec.js b/core/server/influx-metrics.spec.js index fc472b6bc2886..8862142749ffd 100644 --- a/core/server/influx-metrics.spec.js +++ b/core/server/influx-metrics.spec.js @@ -1,12 +1,12 @@ -'use strict' -const os = require('os') -const nock = require('nock') -const sinon = require('sinon') -const { expect } = require('chai') -const log = require('./log') -const InfluxMetrics = require('./influx-metrics') -require('../register-chai-plugins.spec') -describe('Influx metrics', function() { +import os from 'os' +import nock from 'nock' +import sinon from 'sinon' +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() { return [ @@ -20,45 +20,47 @@ describe('Influx metrics', function() { ] }, } - describe('"metrics" function', function() { + describe('"metrics" function', function () { let osHostnameStub - afterEach(function() { + afterEach(function () { nock.enableNetConnect() delete process.env.INSTANCE_ID if (osHostnameStub) { osHostnameStub.restore() } }) - it('should use an environment variable value as an instance label', async function() { + it('should use an environment variable value as an instance label', async function () { process.env.INSTANCE_ID = 'instance3' const influxMetrics = new InfluxMetrics(metricInstance, { instanceIdFrom: 'env-var', instanceIdEnvVarName: 'INSTANCE_ID', }) - expect(influxMetrics.metrics()).to.contain('instance=instance3') + expect(await influxMetrics.metrics()).to.contain('instance=instance3') }) - it('should use a hostname as an instance label', async function() { + it('should use a hostname as an instance label', async function () { osHostnameStub = sinon.stub(os, 'hostname').returns('test-hostname') const customConfig = { instanceIdFrom: 'hostname', } const influxMetrics = new InfluxMetrics(metricInstance, customConfig) - expect(influxMetrics.metrics()).to.be.contain('instance=test-hostname') + expect(await influxMetrics.metrics()).to.be.contain( + 'instance=test-hostname', + ) }) - it('should use a random string as an instance label', async function() { + it('should use a random string as an instance label', async function () { const customConfig = { instanceIdFrom: 'random', } const influxMetrics = new InfluxMetrics(metricInstance, customConfig) - expect(influxMetrics.metrics()).to.be.match(/instance=\w+ /) + expect(await influxMetrics.metrics()).to.be.match(/instance=\w+ /) }) - it('should use a hostname alias as an instance label', async function() { + it('should use a hostname alias as an instance label', async function () { osHostnameStub = sinon.stub(os, 'hostname').returns('test-hostname') const customConfig = { instanceIdFrom: 'hostname', @@ -66,18 +68,18 @@ describe('Influx metrics', function() { } const influxMetrics = new InfluxMetrics(metricInstance, customConfig) - expect(influxMetrics.metrics()).to.be.contain( - 'instance=test-hostname-alias' + expect(await influxMetrics.metrics()).to.be.contain( + 'instance=test-hostname-alias', ) }) }) - describe('startPushingMetrics', function() { + describe('startPushingMetrics', function () { let influxMetrics, clock - beforeEach(function() { + beforeEach(function () { clock = sinon.useFakeTimers() }) - afterEach(function() { + afterEach(function () { influxMetrics.stopPushingMetrics() nock.cleanAll() nock.enableNetConnect() @@ -85,8 +87,8 @@ describe('Influx metrics', function() { clock.restore() }) - it('should send metrics', async function() { - const scope = nock('http://shields-metrics.io/', { + it('should send metrics', async function () { + const scope = nock('https://shields-metrics.io/', { reqheaders: { 'Content-Type': 'application/x-www-form-urlencoded', }, @@ -94,13 +96,13 @@ 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) process.env.INSTANCE_ID = 'instance2' influxMetrics = new InfluxMetrics(metricInstance, { - url: 'http://shields-metrics.io/metrics', + url: 'https://shields-metrics.io/metrics', timeoutMillseconds: 100, intervalSeconds: 0.001, username: 'metrics-username', @@ -115,29 +117,29 @@ describe('Influx metrics', function() { await clock.tickAsync(10) expect(scope.isDone()).to.be.equal( true, - `pending mocks: ${scope.pendingMocks()}` + `pending mocks: ${scope.pendingMocks()}`, ) }) }) - describe('sendMetrics', function() { - beforeEach(function() { + describe('sendMetrics', function () { + beforeEach(function () { sinon.stub(log, 'error') }) - afterEach(function() { + afterEach(function () { log.error.restore() nock.cleanAll() nock.enableNetConnect() }) const influxMetrics = new InfluxMetrics(metricInstance, { - url: 'http://shields-metrics.io/metrics', + url: 'https://shields-metrics.io/metrics', timeoutMillseconds: 50, intervalSeconds: 0, username: 'metrics-username', password: 'metrics-password', }) - it('should log errors', async function() { + it('should log errors', async function () { nock.disableNetConnect() await influxMetrics.sendMetrics() @@ -148,17 +150,14 @@ describe('Influx metrics', function() { .and( sinon.match.has( 'message', - 'Cannot push metrics. Cause: NetConnectNotAllowedError: Nock: Disallowed net connect for "shields-metrics.io:80/metrics"' - ) - ) + 'Cannot push metrics. Cause: RequestError: Nock: Disallowed net connect for "shields-metrics.io:443/metrics"', + ), + ), ) }) - it('should log error responses', async function() { - nock('http://shields-metrics.io/') - .persist() - .post('/metrics') - .reply(400) + it('should log error responses', async function () { + nock('https://shields-metrics.io/').persist().post('/metrics').reply(400) await influxMetrics.sendMetrics() @@ -168,9 +167,9 @@ describe('Influx metrics', function() { .and( sinon.match.has( 'message', - 'Cannot push metrics. http://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 0036df2648842..4a1dbfa153482 100644 --- a/core/server/instance-id-generator.js +++ b/core/server/instance-id-generator.js @@ -1,10 +1,11 @@ -'use strict' - +/** + * 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) + return Math.random().toString(36).substr(2, 9) } -module.exports = generateInstanceId +export default generateInstanceId diff --git a/core/server/log.js b/core/server/log.js index 46cb98a24ac1e..d8ed46eec5c28 100644 --- a/core/server/log.js +++ b/core/server/log.js @@ -1,5 +1,4 @@ -'use strict' -const Sentry = require('@sentry/node') +import * as Sentry from '@sentry/node-core/light' const listeners = [] @@ -23,27 +22,34 @@ function date() { ) } -module.exports = function log(...msg) { +const log = (...msg) => { const d = date() listeners.forEach(f => f(d, ...msg)) console.log(d, ...msg) } -module.exports.error = function 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) } -module.exports.addListener = function addListener(func) { - listeners.push(func) -} +const addListener = func => listeners.push(func) -module.exports.removeListener = function removeListener(func) { +const removeListener = func => { const index = listeners.indexOf(func) if (index < 0) { return } listeners.splice(index, 1) } + +export default { + log, + error, + addListener, + removeListener, +} diff --git a/core/server/metrics/format-converters.js b/core/server/metrics/format-converters.js index e6ac8e2332c01..4e363845e7308 100644 --- a/core/server/metrics/format-converters.js +++ b/core/server/metrics/format-converters.js @@ -1,27 +1,29 @@ -'use strict' -const groupBy = require('lodash.groupby') - function promClientJsonToInfluxV2(metrics, extraLabels = {}) { - // TODO Replace with Array.prototype.flatMap() after migrating to Node.js >= 11 - const flatMap = (f, arr) => arr.reduce((acc, x) => acc.concat(f(x)), []) - return flatMap(metric => { - const valuesByLabels = groupBy(metric.values, value => - JSON.stringify(Object.entries(value.labels).sort()) - ) - return Object.values(valuesByLabels).map(metricsWithSameLabel => { - const labels = Object.entries(metricsWithSameLabel[0].labels) - .concat(Object.entries(extraLabels)) - .sort((a, b) => a[0].localeCompare(b[0])) - .map(labelEntry => `${labelEntry[0]}=${labelEntry[1]}`) - .join(',') - const labelsFormatted = labels ? `,${labels}` : '' - const values = metricsWithSameLabel - .sort((a, b) => a.metricName.localeCompare(b.metricName)) - .map(value => `${value.metricName || metric.name}=${value.value}`) - .join(',') - return `prometheus${labelsFormatted} ${values}` - }) - }, metrics).join('\n') + return metrics + .flatMap(metric => { + 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)) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(labelEntry => `${labelEntry[0]}=${labelEntry[1]}`) + .join(',') + const labelsFormatted = labels ? `,${labels}` : '' + const values = metricsWithSameLabel + .sort((a, b) => a.metricName.localeCompare(b.metricName)) + .map(value => `${value.metricName || metric.name}=${value.value}`) + .join(',') + return `prometheus${labelsFormatted} ${values}` + }) + }, metrics) + .join('\n') } -module.exports = { promClientJsonToInfluxV2 } +export { promClientJsonToInfluxV2 } diff --git a/core/server/metrics/format-converters.spec.js b/core/server/metrics/format-converters.spec.js index 373c3d9cdab2b..0b5fde9c4d59c 100644 --- a/core/server/metrics/format-converters.spec.js +++ b/core/server/metrics/format-converters.spec.js @@ -1,12 +1,10 @@ -'use strict' +import { expect } from 'chai' +import prometheus from 'prom-client' +import { promClientJsonToInfluxV2 } from './format-converters.js' -const { expect } = require('chai') -const prometheus = require('prom-client') -const { promClientJsonToInfluxV2 } = require('./format-converters') - -describe('Metric format converters', function() { - describe('prom-client JSON to InfluxDB line protocol (version 2)', function() { - it('converts a counter', function() { +describe('Metric format converters', function () { + describe('prom-client JSON to InfluxDB line protocol (version 2)', function () { + it('converts a counter', function () { const json = [ { help: 'counter 1 help', @@ -22,7 +20,7 @@ describe('Metric format converters', function() { expect(influx).to.be.equal('prometheus counter1=11') }) - it('converts a counter (from prometheus registry)', function() { + it('converts a counter (from prometheus registry)', async function () { const register = new prometheus.Registry() const counter = new prometheus.Counter({ name: 'counter1', @@ -31,12 +29,12 @@ describe('Metric format converters', function() { }) counter.inc(11) - const influx = promClientJsonToInfluxV2(register.getMetricsAsJSON()) + const influx = promClientJsonToInfluxV2(await register.getMetricsAsJSON()) expect(influx).to.be.equal('prometheus counter1=11') }) - it('converts a gauge', function() { + it('converts a gauge', function () { const json = [ { help: 'gause 1 help', @@ -52,7 +50,7 @@ describe('Metric format converters', function() { expect(influx).to.be.equal('prometheus gauge1=20') }) - it('converts a gauge (from prometheus registry)', function() { + it('converts a gauge (from prometheus registry)', async function () { const register = new prometheus.Registry() const gauge = new prometheus.Gauge({ name: 'gauge1', @@ -61,18 +59,14 @@ describe('Metric format converters', function() { }) gauge.inc(20) - const influx = promClientJsonToInfluxV2(register.getMetricsAsJSON()) + const influx = promClientJsonToInfluxV2(await register.getMetricsAsJSON()) expect(influx).to.be.equal('prometheus gauge1=20') }) - const sortLines = text => - text - .split('\n') - .sort() - .join('\n') + const sortLines = text => text.split('\n').sort().join('\n') - it('converts a histogram', function() { + it('converts a histogram', function () { const json = [ { name: 'histogram1', @@ -101,11 +95,11 @@ 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`), ) }) - it('converts a histogram (from prometheus registry)', function() { + it('converts a histogram (from prometheus registry)', async function () { const register = new prometheus.Registry() const histogram = new prometheus.Histogram({ name: 'histogram1', @@ -117,18 +111,18 @@ prometheus histogram1_count=3,histogram1_sum=111`) histogram.observe(10) histogram.observe(1) - const influx = promClientJsonToInfluxV2(register.getMetricsAsJSON()) + const influx = promClientJsonToInfluxV2(await register.getMetricsAsJSON()) expect(sortLines(influx)).to.be.equal( sortLines(`prometheus,le=+Inf histogram1_bucket=3 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`), ) }) - it('converts a summary', function() { + it('converts a summary', function () { const json = [ { name: 'summary1', @@ -151,11 +145,11 @@ 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`), ) }) - it('converts a summary (from prometheus registry)', function() { + it('converts a summary (from prometheus registry)', async function () { const register = new prometheus.Registry() const summary = new prometheus.Summary({ name: 'summary1', @@ -167,17 +161,17 @@ prometheus summary1_count=3,summary1_sum=111`) summary.observe(10) summary.observe(1) - const influx = promClientJsonToInfluxV2(register.getMetricsAsJSON()) + const influx = promClientJsonToInfluxV2(await register.getMetricsAsJSON()) expect(sortLines(influx)).to.be.equal( 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`), ) }) - it('converts a counter and skip a timestamp', function() { + it('converts a counter and skip a timestamp', function () { const json = [ { help: 'counter 4 help', @@ -193,7 +187,7 @@ prometheus summary1_count=3,summary1_sum=111`) expect(influx).to.be.equal('prometheus counter4=11') }) - it('converts a counter and adds extra labels', function() { + it('converts a counter and adds extra labels', function () { const json = [ { help: 'counter 1 help', @@ -210,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/monitor.js b/core/server/monitor.js deleted file mode 100644 index 96ef183af760d..0000000000000 --- a/core/server/monitor.js +++ /dev/null @@ -1,103 +0,0 @@ -'use strict' - -const config = require('config').util.toObject() -const secretIsValid = require('./secret-is-valid') -const RateLimit = require('./rate-limit') -const log = require('./log') - -function secretInvalid(req, res) { - if (!secretIsValid(req.password)) { - // An unknown entity tries to connect. Let the connection linger for a minute. - setTimeout(() => { - res.json({ errors: [{ code: 'invalid_secrets' }] }) - }, 10000) - return true - } - return false -} - -function setRoutes({ rateLimit }, { server, metricInstance }) { - const ipRateLimit = new RateLimit({ - // Exclude IPs for GitHub Camo, determined experimentally by running e.g. - // `curl --insecure -u ":shields-secret" https://s0.shields-server.com/sys/rate-limit` - whitelist: /^(?:192\.30\.252\.\d+)|(?:140\.82\.115\.\d+)$/, - }) - const badgeTypeRateLimit = new RateLimit({ maxHitsPerPeriod: 3000 }) - const refererRateLimit = new RateLimit({ - maxHitsPerPeriod: 300, - whitelist: /^https?:\/\/shields\.io\/$/, - }) - - server.handle((req, res, next) => { - if (req.url.startsWith('/sys/')) { - if (secretInvalid(req, res)) { - return - } - } - - if (rateLimit) { - const ip = - (req.headers['x-forwarded-for'] || '').split(', ')[0] || - req.socket.remoteAddress - const badgeType = req.url - .split(/[/-]/) - .slice(0, 3) - .join('') - const referer = req.headers.referer - - if (ipRateLimit.isBanned(ip, req, res)) { - metricInstance.noteRateLimitExceeded('ip') - return - } - if (badgeTypeRateLimit.isBanned(badgeType, req, res)) { - metricInstance.noteRateLimitExceeded('badge_type') - return - } - if (refererRateLimit.isBanned(referer, req, res)) { - metricInstance.noteRateLimitExceeded('referrer') - return - } - } - - next() - }) - - server.get('/sys/network', (req, res) => { - res.json({ ips: config.public.shields_ips }) - }) - - server.ws('/sys/logs', socket => { - const listener = (...msg) => socket.send(msg.join(' ')) - socket.on('close', () => log.removeListener(listener)) - socket.on('message', msg => { - let req - try { - req = JSON.parse(msg) - } catch (e) { - return - } - if (!secretIsValid(req.secret)) { - return socket.close() - } - log.addListener(listener) - }) - }) - - server.get('/sys/rate-limit', (req, res) => { - res.json({ - ip: ipRateLimit.toJSON(), - badgeType: badgeTypeRateLimit.toJSON(), - referer: refererRateLimit.toJSON(), - }) - }) - - return function() { - ipRateLimit.stop() - badgeTypeRateLimit.stop() - refererRateLimit.stop() - } -} - -module.exports = { - setRoutes, -} diff --git a/core/server/prometheus-metrics.js b/core/server/prometheus-metrics.js index 69748f52cb19f..6b8cc71ca4912 100644 --- a/core/server/prometheus-metrics.js +++ b/core/server/prometheus-metrics.js @@ -1,9 +1,7 @@ -'use strict' +import decamelize from 'decamelize' +import prometheus from 'prom-client' -const decamelize = require('decamelize') -const prometheus = require('prom-client') - -module.exports = class PrometheusMetrics { +export default class PrometheusMetrics { constructor({ register } = {}) { this.register = register || new prometheus.Registry() this.counters = { @@ -19,46 +17,12 @@ module.exports = class PrometheusMetrics { // 250 ms increments up to 2 seconds, then 500 ms increments up to 8 // seconds, then 1 second increments up to 15 seconds. buckets: [ - 250, - 500, - 750, - 1000, - 1250, - 1500, - 1750, - 2000, - 2250, - 2500, - 2750, - 3000, - 3250, - 3500, - 3750, - 4000, - 4500, - 5000, - 5500, - 6000, - 6500, - 7000, - 7500, - 8000, - 9000, - 10000, - 11000, - 12000, - 13000, - 14000, - 15000, + 250, 500, 750, 1000, 1250, 1500, 1750, 2000, 2250, 2500, 2750, 3000, + 3250, 3500, 3750, 4000, 4500, 5000, 5500, 6000, 6500, 7000, 7500, + 8000, 9000, 10000, 11000, 12000, 13000, 14000, 15000, ], registers: [this.register], }), - rateLimitExceeded: new prometheus.Counter({ - name: 'rate_limit_exceeded_total', - help: 'Count of rate limit exceeded by type', - labelNames: ['rate_limit_type'], - registers: [this.register], - }), serviceResponseSize: new prometheus.Histogram({ name: 'service_response_bytes', help: 'Service response size in bytes', @@ -67,18 +31,61 @@ module.exports = 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$/, (data, match, end, ask) => { - ask.res.setHeader('Content-Type', register.contentType) - ask.res.end(register.metrics()) + server.route(/^\/metrics$/, async (data, match, end, ask) => { + if (enabled) { + ask.res.setHeader('Content-Type', register.contentType) + ask.res.end(await register.metrics()) + } else { + ask.res.statusCode = 404 + ask.res.end() + } }) } @@ -90,11 +97,15 @@ module.exports = class PrometheusMetrics { } } - metrics() { - return this.register.getMetricsAsJSON() + async metrics() { + return await this.register.getMetricsAsJSON() } /** + * @param {object} attrs Refer to individual attrs + * @param {string} attrs.category e.g: 'build' + * @param {string} attrs.serviceFamily e.g: 'npm' + * @param {string} attrs.name e.g: 'NpmVersion' * @returns {object} `{ inc() {} }`. */ createNumRequestCounter({ category, serviceFamily, name }) { @@ -106,16 +117,42 @@ module.exports = class PrometheusMetrics { return this.counters.responseTime.observe(responseTime) } - noteRateLimitExceeded(rateLimitType) { - return this.counters.rateLimitExceeded.labels(rateLimitType).inc() - } - createServiceResponseSizeHistogram({ category, serviceFamily, name }) { const service = decamelize(name) 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/prometheus-metrics.spec.js b/core/server/prometheus-metrics.spec.js index 49660334df167..31fa40afb0bad 100644 --- a/core/server/prometheus-metrics.spec.js +++ b/core/server/prometheus-metrics.spec.js @@ -1,20 +1,18 @@ -'use strict' +import { expect } from 'chai' +import Camp from '@shields_io/camp' +import portfinder from 'portfinder' +import got from '../got-test-client.js' +import Metrics from './prometheus-metrics.js' -const { expect } = require('chai') -const Camp = require('@shields_io/camp') -const portfinder = require('portfinder') -const got = require('../got-test-client') -const Metrics = require('./prometheus-metrics') - -describe('Prometheus metrics route', function() { +describe('Prometheus metrics route', function () { let port, baseUrl, camp, metrics - beforeEach(async function() { + beforeEach(async function () { port = await portfinder.getPortPromise() baseUrl = `http://127.0.0.1:${port}` camp = Camp.start({ port, hostname: '::' }) await new Promise(resolve => camp.on('listening', () => resolve())) }) - afterEach(async function() { + afterEach(async function () { if (metrics) { metrics.stop() } @@ -24,7 +22,7 @@ describe('Prometheus metrics route', function() { } }) - it('returns default metrics', async function() { + it('returns default metrics', async function () { metrics = new Metrics() metrics.registerMetricsEndpoint(camp) 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/rate-limit.js b/core/server/rate-limit.js deleted file mode 100644 index a3378d0901574..0000000000000 --- a/core/server/rate-limit.js +++ /dev/null @@ -1,60 +0,0 @@ -'use strict' - -// A rate limit ensures that a request parameter gets flagged if it goes -// above a limit. -module.exports = class RateLimit { - constructor(options = {}) { - // this.hits: Map from request parameters to the number of hits. - this.hits = new Map() - this.period = options.period || 200 // 3 min ⅓, in seconds - this.maxHitsPerPeriod = options.maxHitsPerPeriod || 500 - this.banned = new Set() - this.bannedUrls = new Set() - this.whitelist = options.whitelist || /(?!)/ // Matches nothing by default. - this.interval = setInterval(this.resetHits.bind(this), this.period * 1000) - } - - stop() { - clearInterval(this.interval) - this.interval = undefined - } - - resetHits() { - this.hits.clear() - this.banned.clear() - this.bannedUrls.clear() - } - - isBanned(reqParam, req, res) { - const hitsInCurrentPeriod = this.hits.get(reqParam) || 0 - if ( - reqParam != null && - !this.whitelist.test(reqParam) && - hitsInCurrentPeriod > this.maxHitsPerPeriod - ) { - this.banned.add(reqParam) - } - - if (this.banned.has(reqParam)) { - res.statusCode = 429 - res.setHeader('Retry-After', String(this.period)) - res.end( - `Exceeded limit ${this.maxHitsPerPeriod} requests ` + - `per ${this.period} seconds` - ) - this.bannedUrls.add(req.url) - return true - } - - this.hits.set(reqParam, hitsInCurrentPeriod + 1) - return false - } - - toJSON() { - return { - banned: [...this.banned], - hits: [...this.hits], - urls: [...this.bannedUrls], - } - } -} diff --git a/core/server/secret-is-valid.js b/core/server/secret-is-valid.js deleted file mode 100644 index d0846a061ad72..0000000000000 --- a/core/server/secret-is-valid.js +++ /dev/null @@ -1,21 +0,0 @@ -'use strict' - -const serverSecrets = require('../../lib/server-secrets') - -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 -} - -module.exports = function secretIsValid(secret = '') { - return ( - serverSecrets.shields_secret && - constEq(secret, serverSecrets.shields_secret) - ) -} diff --git a/core/server/server.js b/core/server/server.js index 62e2e704ccd27..dd855b09b9064 100644 --- a/core/server/server.js +++ b/core/server/server.js @@ -1,29 +1,31 @@ -'use strict' /** * @module */ -const path = require('path') -const url = require('url') +import path from 'path' +import url, { fileURLToPath } from 'url' +import { bootstrap } from 'global-agent' +import cloudflareMiddleware from 'cloudflare-middleware' +import Camp from '@shields_io/camp' +import originalJoi from 'joi' +import makeBadge from '../../badge-maker/lib/make-badge.js' +import GithubConstellation from '../../services/github/github-constellation.js' +import LibrariesIoConstellation from '../../services/librariesio/librariesio-constellation.js' +import { 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 { clearResourceCache } from '../base-service/resource-cache.js' +import { rasterRedirectUrl } from '../badge-urls/make-badge-url.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' const { URL } = url -const bytes = require('bytes') -const Camp = require('@shields_io/camp') -const originalJoi = require('@hapi/joi') -const makeBadge = require('../../badge-maker/lib/make-badge') -const GithubConstellation = require('../../services/github/github-constellation') -const suggest = require('../../services/suggest') -const { loadServiceClasses } = require('../base-service/loader') -const { makeSend } = require('../base-service/legacy-result-sender') -const { - handleRequest, - clearRequestCache, -} = require('../base-service/legacy-request-handler') -const { clearRegularUpdateCache } = require('../legacy/regular-update') -const { rasterRedirectUrl } = require('../badge-urls/make-badge-url') -const log = require('./log') -const sysMonitor = require('./monitor') -const PrometheusMetrics = require('./prometheus-metrics') -const InfluxMetrics = require('./influx-metrics') const Joi = originalJoi .extend(base => ({ @@ -57,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: [], @@ -68,15 +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().ip().required(), + Joi.string().hostname().required(), ), }, metrics: { @@ -93,10 +89,10 @@ const publicConfigSchema = Joi.object({ .integer() .min(1) .when('enabled', { is: true, then: Joi.required() }), - intervalSeconds: Joi.number() - .integer() - .min(1) - .when('enabled', { is: true, then: Joi.required() }), + intervalSeconds: Joi.number().integer().min(1).when('enabled', { + is: true, + then: Joi.required(), + }), instanceIdFrom: Joi.string() .equal('hostname', 'env-var', 'random') .when('enabled', { is: true, then: Joi.required() }), @@ -118,27 +114,20 @@ const publicConfigSchema = Joi.object({ }, redirectUrl: optionalUrl, rasterUrl: optionalUrl, - cors: { - allowedOrigin: Joi.array() - .items(optionalUrl) - .required(), - }, - persistence: { - dir: Joi.string().required(), - }, services: Joi.object({ + bitbucket: defaultService, bitbucketServer: defaultService, drone: defaultService, github: { baseUri: requiredUrl, debug: { enabled: Joi.boolean().required(), - intervalSeconds: Joi.number() - .integer() - .min(1) - .required(), + intervalSeconds: Joi.number().integer().min(1).required(), }, + restApiVersion: Joi.date().raw().required(), }, + gitea: defaultService, + gitlab: defaultService, jira: defaultService, jenkins: Joi.object({ authorizedOrigins: origins, @@ -147,55 +136,96 @@ const publicConfigSchema = Joi.object({ }).default({ authorizedOrigins: [] }), nexus: defaultService, npm: defaultService, + obs: defaultService, + pypi: { + baseUri: requiredUrl, + }, sonar: defaultService, teamcity: defaultService, + weblate: defaultService, trace: Joi.boolean().required(), }).required(), - cacheHeaders: { - defaultCacheLengthSeconds: Joi.number() - .integer() - .required(), - }, - rateLimit: Joi.boolean().required(), + cacheHeaders: { defaultCacheLengthSeconds: nonNegativeInteger }, handleInternalErrors: Joi.boolean().required(), - fetchLimit: Joi.string().regex(/^[0-9]+(b|kb|mb|gb|tb)$/i), - shieldsProductionHerokuHacks: Joi.boolean(), + fetchLimitBytes: fileSizeBytes, + userAgentBase: Joi.string().required(), + requestTimeoutSeconds: nonNegativeInteger, + requestTimeoutMaxAgeSeconds: nonNegativeInteger, + documentRoot: Joi.string().default( + path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', + 'public', + ), + ), + allowUnsecuredEndpointRequests: Joi.boolean().required(), + requireCloudflare: Joi.boolean().required(), }).required() const privateConfigSchema = Joi.object({ azure_devops_token: Joi.string(), - bintray_user: Joi.string(), - bintray_apikey: 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(), - redis_url: Joi.string().uri({ scheme: ['redis', 'rediss'] }), + obs_user: Joi.string(), + obs_pass: Joi.string(), + opencollective_token: Joi.string(), + pepy_key: Joi.string(), + postgres_url: Joi.string().uri({ scheme: 'postgresql' }), + readthedocs_token: Joi.string(), + reddit_client_id: Joi.string(), + reddit_client_secret: Joi.string(), sentry_dsn: Joi.string(), - shields_ips: Joi.array().items(Joi.string().ip()), - shields_secret: Joi.string(), sl_insight_userUuid: Joi.string(), sl_insight_apiToken: Joi.string(), 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(), + youtube_api_key: Joi.string(), }).required() const privateMetricsInfluxConfigSchema = privateConfigSchema.append({ influx_username: Joi.string().required(), influx_password: Joi.string().required(), }) + +function addHandlerAtIndex(camp, index, handlerFn) { + camp.stack.splice(index, 0, handlerFn) +} + +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. @@ -216,7 +246,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 @@ -225,7 +255,7 @@ class Server { if (publicConfig.metrics.influx && publicConfig.metrics.influx.enabled) { this.validatePrivateConfig( config.private, - privateMetricsInfluxConfigSchema + privateMetricsInfluxConfigSchema, ) } this.config = { @@ -234,8 +264,12 @@ class Server { } this.githubConstellation = new GithubConstellation({ - persistence: publicConfig.persistence, service: publicConfig.services.github, + metricsIntervalSeconds: publicConfig.metrics.influx.intervalSeconds, + private: privateConfig, + }) + + this.librariesioConstellation = new LibrariesIoConstellation({ private: privateConfig, }) @@ -247,7 +281,7 @@ class Server { Object.assign({}, publicConfig.metrics.influx, { username: privateConfig.influx_username, password: privateConfig.influx_password, - }) + }), ) } } @@ -260,8 +294,8 @@ class Server { const badPaths = e.details.map(({ path }) => path) throw Error( `Private configuration is invalid. Check these paths: ${badPaths.join( - ',' - )}` + ',', + )}`, ) } } @@ -288,6 +322,31 @@ class Server { }) } + // See https://www.viget.com/articles/heroku-cloudflare-the-right-way/ + requireCloudflare() { + // Set `req.ip`, which is expected by `cloudflareMiddleware()`. This is set + // by Express but not Scoutcamp. + addHandlerAtIndex(this.camp, 0, function (req, res, next) { + if (isOnHeroku()) { + // On Heroku, `req.socket.remoteAddress` is the Heroku router. However, + // the router ensures that the last item in the `X-Forwarded-For` header + // is the real origin. + // https://stackoverflow.com/a/18517550/893113 + req.ip = req.headers['x-forwarded-for'].split(', ').pop() + } else if (isOnFly()) { + // On Fly we can use the Fly-Client-IP header + // https://fly.io/docs/reference/runtime-environment/#request-headers + req.ip = req.headers['fly-client-ip'] + ? req.headers['fly-client-ip'] + : req.socket.remoteAddress + } else { + req.ip = req.socket.remoteAddress + } + next() + }) + addHandlerAtIndex(this.camp, 1, cloudflareMiddleware()) + } + /** * Set up Scoutcamp routes for 404/not found responses */ @@ -297,51 +356,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({ - text: ['410', `${format} no longer available`], + 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({ - text: ['404', '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({ - text: ['404', 'badge not found'], + label: '404', + message: 'badge not found', color: 'red', format, - }) + }), ) }) } @@ -361,18 +437,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) { @@ -388,27 +467,50 @@ class Server { * Iterate all the service classes defined in /services, * load each service and register a Scoutcamp route for each service. */ - registerServices() { + async registerServices() { const { config, camp, metricInstance } = this const { apiProvider: githubApiProvider } = this.githubConstellation - - loadServiceClasses().forEach(serviceClass => + 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, - shieldsProductionHerokuHacks: - config.public.shieldsProductionHerokuHacks, - } - ) + }, + ), ) } + bootstrapAgent() { + /* + Bootstrap global agent. + This allows self-hosting users to configure a proxy with + HTTP_PROXY, HTTPS_PROXY, NO_PROXY variables + */ + if (!('GLOBAL_AGENT_ENVIRONMENT_VARIABLE_NAMESPACE' in process.env)) { + process.env.GLOBAL_AGENT_ENVIRONMENT_VARIABLE_NAMESPACE = '' + } + + const proxyPrefix = process.env.GLOBAL_AGENT_ENVIRONMENT_VARIABLE_NAMESPACE + const HTTP_PROXY = process.env[`${proxyPrefix}HTTP_PROXY`] || null + const HTTPS_PROXY = process.env[`${proxyPrefix}HTTPS_PROXY`] || null + + if (HTTP_PROXY || HTTPS_PROXY) { + bootstrap() + } + } + /** * Start the HTTP server: * Bootstrap Scoutcamp, @@ -419,45 +521,65 @@ class Server { const { bind: { port, address: hostname }, ssl: { isSecure: secure, cert, key }, - cors: { allowedOrigin }, - rateLimit, + requireCloudflare, } = this.config.public - log(`Server is starting up: ${this.baseUrl}`) + this.bootstrapAgent() + + log.log(`Server is starting up: ${this.baseUrl}`) const camp = (this.camp = Camp.create({ - documentRoot: path.resolve(__dirname, '..', '..', 'public'), + documentRoot: this.config.public.documentRoot, port, hostname, secure, + staticMaxAge: 300, cert, key, })) - const { metricInstance } = this - this.cleanupMonitor = sysMonitor.setRoutes( - { rateLimit }, - { server: camp, metricInstance } - ) + if (requireCloudflare) { + this.requireCloudflare() + } - const { githubConstellation } = this - githubConstellation.initialize(camp) + const { githubConstellation, metricInstance } = this + 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 - suggest.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() - this.registerServices() - + await this.registerServices() + + camp.timeout = this.config.public.requestTimeoutSeconds * 1000 + if (this.config.public.requestTimeoutSeconds > 0) { + camp.on('timeout', socket => { + const maxAge = this.config.public.requestTimeoutMaxAgeSeconds + socket.write('HTTP/1.1 408 Request Timeout\r\n') + socket.write('Content-Type: text/html; charset=UTF-8\r\n') + socket.write('Content-Encoding: UTF-8\r\n') + socket.write(`Cache-Control: max-age=${maxAge}, s-maxage=${maxAge}\r\n`) + socket.write('Connection: close\r\n\r\n') + socket.write('Request Timeout') + socket.end() + }) + } camp.listenAsConfigured() await new Promise(resolve => camp.on('listening', () => resolve())) @@ -466,8 +588,7 @@ class Server { static resetGlobalState() { // This state should be migrated to instance state. When possible, do not add new // global state. - clearRequestCache() - clearRegularUpdateCache() + clearResourceCache() } reset() { @@ -502,4 +623,4 @@ class Server { } } -module.exports = Server +export default Server diff --git a/core/server/server.spec.js b/core/server/server.spec.js index c2482b5d41779..d53b8aea328e0 100644 --- a/core/server/server.spec.js +++ b/core/server/server.spec.js @@ -1,30 +1,39 @@ -'use strict' - -const { expect } = require('chai') -const isSvg = require('is-svg') -const config = require('config') -const got = require('../got-test-client') -const Server = require('./server') -const { createTestServer } = require('./in-process-server-test-helpers') - -describe('The server', function() { - describe('running', function() { +import path from 'path' +import { fileURLToPath } from 'url' +import { expect } from 'chai' +import isSvg from 'is-svg' +import config from 'config' +import nock from 'nock' +import sinon from 'sinon' +import got from '../got-test-client.js' +import Server from './server.js' +import { createTestServer } from './in-process-server-test-helpers.js' + +describe('The server', function () { + describe('running', function () { let server, baseUrl - before('Start the server', async function() { + before('Start the server', async function () { // Fixes https://github.com/badges/shields/issues/2611 this.timeout(10000) - server = await createTestServer() + server = await createTestServer({ + public: { + documentRoot: path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + 'test-public', + ), + }, + }) baseUrl = server.baseUrl await server.start() }) - after('Shut down the server', async function() { + after('Shut down the server', async function () { if (server) { await server.stop() } server = undefined }) - it('should allow strings for port', async function() { + it('should allow strings for port', async function () { // fixes #4391 - This allows the app to be run using iisnode, which uses a named pipe for the port. const pipeServer = await createTestServer({ public: { @@ -36,7 +45,7 @@ describe('The server', function() { expect(pipeServer).to.not.be.undefined }) - it('should produce colorscheme badges', async function() { + it('should produce colorscheme badges', async function () { const { statusCode, body } = await got(`${baseUrl}:fruit-apple-green.svg`) expect(statusCode).to.equal(200) expect(body) @@ -45,50 +54,122 @@ describe('The server', function() { .and.to.include('apple') }) - it('should redirect colorscheme PNG badges as configured', async function() { + it('should serve front-end with default maxAge', async function () { + const { headers } = await got(`${baseUrl}/`) + expect(headers['cache-control']).to.equal('max-age=300, s-maxage=300') + }) + + it('should serve 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 () { const { statusCode, headers } = await got( `${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, - }) + it('should redirect modern PNG badges as configured', async function () { + 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() }) - it('should preserve label case', async function() { + 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) - expect(body) - .to.satisfy(isSvg) - .and.to.include('fRuiT') + expect(body).to.satisfy(isSvg).and.to.include('fRuiT') }) // https://github.com/badges/shields/pull/1319 - it('should not crash with a numeric logo', async function() { + 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) @@ -97,9 +178,9 @@ describe('The server', function() { .and.to.include('apple') }) - it('should not crash with a numeric link', async 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) @@ -108,9 +189,9 @@ describe('The server', function() { .and.to.include('apple') }) - it('should not crash with a boolean link', async 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) @@ -119,35 +200,35 @@ describe('The server', function() { .and.to.include('apple') }) - it('should return the 404 badge for unknown badges', async function() { + it('should return the 404 badge for unknown badges', async function () { const { statusCode, body } = await got( `${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') .and.to.include('badge not found') }) - it('should redirect the root as configured', async function() { + it('should redirect the root as configured', async function () { const { statusCode, headers } = await got(baseUrl, { followRedirect: false, }) @@ -157,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) @@ -170,15 +264,88 @@ describe('The server', function() { }) }) - describe('configuration', function() { + context('`requireCloudflare` is enabled', function () { let server - afterEach(async function() { + afterEach(async function () { if (server) { server.stop() } }) - it('should allow to enable prometheus metrics', async function() { + it('should reject requests from localhost with an empty 200 response', async function () { + this.timeout(10000) + server = await createTestServer({ public: { requireCloudflare: true } }) + await server.start() + + const { statusCode, body } = await got( + `${server.baseUrl}badge/foo-bar-blue.svg`, + ) + + expect(statusCode).to.be.equal(200) + expect(body).to.equal('') + }) + }) + + describe('`requestTimeoutSeconds` setting', function () { + let server + + beforeEach(async function () { + this.timeout(10000) + + // configure server to time out requests that take >2 seconds + server = await createTestServer({ public: { requestTimeoutSeconds: 2 } }) + await server.start() + + // /fast returns a 200 OK after a 1 second delay + server.camp.route(/^\/fast$/, (data, match, end, ask) => { + setTimeout(() => { + ask.res.statusCode = 200 + ask.res.end() + }, 1000) + }) + + // /slow returns a 200 OK after a 3 second delay + server.camp.route(/^\/slow$/, (data, match, end, ask) => { + setTimeout(() => { + ask.res.statusCode = 200 + ask.res.end() + }, 3000) + }) + }) + + afterEach(async function () { + if (server) { + server.stop() + } + server = undefined + }) + + it('should time out slow requests', async function () { + this.timeout(10000) + const { statusCode, body } = await got(`${server.baseUrl}slow`, { + throwHttpErrors: false, + }) + expect(statusCode).to.be.equal(408) + expect(body).to.equal('Request Timeout') + }) + + it('should not time out fast requests', async function () { + this.timeout(10000) + const { statusCode, body } = await got(`${server.baseUrl}fast`) + expect(statusCode).to.be.equal(200) + expect(body).to.equal('') + }) + }) + + describe('configuration', function () { + let server + afterEach(async function () { + if (server) { + server.stop() + } + }) + + it('should allow to enable prometheus metrics', async function () { // Fixes https://github.com/badges/shields/issues/2611 this.timeout(10000) server = await createTestServer({ @@ -193,7 +360,7 @@ describe('The server', function() { expect(statusCode).to.be.equal(200) }) - it('should allow to disable prometheus metrics', async function() { + it('should allow to disable prometheus metrics', async function () { // Fixes https://github.com/badges/shields/issues/2611 this.timeout(10000) server = await createTestServer({ @@ -211,10 +378,10 @@ describe('The server', function() { }) }) - describe('configuration validation', function() { - describe('influx', function() { + describe('configuration validation', function () { + describe('influx', function () { let customConfig - beforeEach(function() { + beforeEach(function () { customConfig = config.util.toObject() customConfig.public.metrics.influx = { enabled: true, @@ -232,103 +399,171 @@ describe('The server', function() { } }) - it('should not require influx configuration', function() { + it('should not require influx configuration', function () { delete customConfig.public.metrics.influx expect(() => new Server(config.util.toObject())).to.not.throw() }) - it('should require url when influx configuration is enabled', 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', ) }) - it('should not require url when influx configuration is disabled', function() { + it('should not require url when influx configuration is disabled', function () { customConfig.public.metrics.influx.enabled = false delete customConfig.public.metrics.influx.url expect(() => new Server(customConfig)).to.not.throw() }) - it('should require timeoutMilliseconds when influx configuration is enabled', 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() { + 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() { + 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', ) }) - it('should require instanceIdEnvVarName when instanceIdFrom is env-var', function() { + it('should require instanceIdEnvVarName when instanceIdFrom is env-var', 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', ) }) - it('should allow instanceIdFrom = hostname', function() { + it('should allow instanceIdFrom = hostname', function () { customConfig.public.metrics.influx.instanceIdFrom = 'hostname' expect(() => new Server(customConfig)).to.not.throw() }) - it('should allow instanceIdFrom = env-var', function() { + it('should allow instanceIdFrom = env-var', function () { customConfig.public.metrics.influx.instanceIdFrom = 'env-var' expect(() => new Server(customConfig)).to.not.throw() }) - it('should allow instanceIdFrom = random', function() { + it('should allow instanceIdFrom = random', function () { customConfig.public.metrics.influx.instanceIdFrom = 'random' expect(() => new Server(customConfig)).to.not.throw() }) - it('should require envLabel when influx configuration is enabled', 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', ) }) - it('should not require hostnameAliases', function() { + it('should not require hostnameAliases', function () { delete customConfig.public.metrics.influx.hostnameAliases expect(() => new Server(customConfig)).to.not.throw() }) - it('should allow empty hostnameAliases', function() { + it('should allow empty hostnameAliases', function () { customConfig.public.metrics.influx.hostnameAliases = {} expect(() => new Server(customConfig)).to.not.throw() }) - it('should require username when influx configuration is enabled', 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() { + 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', ) }) - it('should allow other private keys', function() { + it('should allow other private keys', function () { customConfig.private.gh_token = 'my-token' expect(() => new Server(customConfig)).to.not.throw() }) }) }) + + describe('running with metrics enabled', function () { + let server, baseUrl, scope, clock + const metricsPushIntervalSeconds = 1 + before('Start the server', async function () { + // Fixes https://github.com/badges/shields/issues/2611 + this.timeout(10000) + process.env.INSTANCE_ID = 'test-instance' + server = await createTestServer({ + public: { + metrics: { + prometheus: { enabled: true }, + influx: { + enabled: true, + url: 'http://localhost:1112/metrics', + instanceIdFrom: 'env-var', + instanceIdEnvVarName: 'INSTANCE_ID', + envLabel: 'localhost-env', + intervalSeconds: metricsPushIntervalSeconds, + }, + }, + }, + private: { + influx_username: 'influx-username', + influx_password: 'influx-password', + }, + }) + clock = sinon.useFakeTimers({ toFake: ['setInterval'] }) + baseUrl = server.baseUrl + await server.start() + }) + after('Shut down the server', async function () { + if (server) { + await server.stop() + } + server = undefined + nock.cleanAll() + delete process.env.INSTANCE_ID + clock.restore() + }) + + it('should push custom metrics', async function () { + const { promise: sentReq, resolve: markSentReq } = Promise.withResolvers() + scope = nock('http://localhost:1112', { + reqheaders: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }) + .post( + '/metrics', + /prometheus,application=shields,category=static,env=localhost-env,family=static-badge,instance=test-instance,service=static_badge service_requests_total=1\n/, + ) + .basicAuth({ user: 'influx-username', pass: 'influx-password' }) + .reply(200, () => { + markSentReq() + return '' + }) + + 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()}`, + ) + }) + }) }) 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 new file mode 100644 index 0000000000000..74b953fd05e3c --- /dev/null +++ b/core/server/test-public/index.html @@ -0,0 +1,10 @@ + + + + + shields.io + + + concise, consistent, legible + + diff --git a/core/service-test-runner/cli.js b/core/service-test-runner/cli.js index 822dff9f88b71..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,26 +54,39 @@ // Relying on npm scripts is safer. Using "pre" makes it impossible to run // the second step without the first. -'use strict' +import fs from 'fs' +import { parseArgs } from 'util' +import { createTestServer } from '../server/in-process-server-test-helpers.js' +import Runner from './runner.js' -const minimist = require('minimist') -const envFlag = require('node-env-flag') -const readAllStdinSync = require('read-all-stdin-sync') -const { createTestServer } = require('../server/in-process-server-test-helpers') -const Runner = require('./runner') - -require('../unhandled-rejection.spec') +import('../unhandled-rejection.spec.js') const retry = {} retry.count = parseInt(process.env.RETRY_COUNT) || 0 retry.backoff = parseInt(process.env.RETRY_BACKOFF) || 0 + +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 = fs.readFileSync(0, 'utf-8').trim() + onlyServices = allStdin ? allStdin.split('\n') : [] +} else if (onlyOption) { + onlyServices = onlyOption.split(',') +} + let baseUrl, server if (process.env.TESTED_SERVER_URL) { baseUrl = process.env.TESTED_SERVER_URL } else { const port = 1111 baseUrl = 'http://localhost:1111' - before('Start running the server', async function() { + before('Start running the server', async function () { server = await createTestServer({ public: { bind: { @@ -81,18 +94,18 @@ if (process.env.TESTED_SERVER_URL) { }, }, }) - server.start() + await server.start() }) - after('Shut down the server', async function() { + after('Shut down the server', async function () { if (server) { await server.stop() } }) } -const skipIntercepted = envFlag(process.env.SKIP_INTERCEPTED, false) +const skipIntercepted = process.env.SKIP_INTERCEPTED === 'true' const runner = new Runner({ baseUrl, skipIntercepted, retry }) -runner.prepare() +await runner.prepare() // The server's request cache causes side effects between tests. if (!process.env.TESTED_SERVER_URL) { @@ -101,21 +114,6 @@ if (!process.env.TESTED_SERVER_URL) { } } -const args = minimist(process.argv.slice(3)) -const stdinOption = args.stdin -const onlyOption = args.only - -let onlyServices - -if (stdinOption && onlyOption) { - console.error('Do not use --only with --stdin') -} else if (stdinOption) { - const allStdin = readAllStdinSync().trim() - onlyServices = allStdin ? allStdin.split('\n') : [] -} else if (onlyOption) { - onlyServices = onlyOption.split(',') -} - if (typeof onlyServices === 'undefined' || onlyServices.includes('*****')) { console.info('Running all service tests.') } else if (onlyServices.length === 0) { @@ -124,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 4f90a614c4300..ed03ef1505201 100644 --- a/core/service-test-runner/create-service-tester.js +++ b/core/service-test-runner/create-service-tester.js @@ -1,11 +1,10 @@ -'use strict' /** * @module */ -const caller = require('caller') -const BaseService = require('../base-service/base') -const ServiceTester = require('./service-tester') +import caller from 'caller' +import BaseService from '../base-service/base.js' +import ServiceTester from './service-tester.js' /** * Automatically create a ServiceTester. @@ -16,18 +15,18 @@ const ServiceTester = require('./service-tester') * 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 */ -function createServiceTester() { +async function createServiceTester() { const servicePath = caller().replace('.tester.js', '.service.js') - const ServiceClass = require(servicePath) + 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) } -module.exports = createServiceTester +export default createServiceTester diff --git a/core/service-test-runner/icedfrisby-shields.js b/core/service-test-runner/icedfrisby-shields.js index a1ce0a8d85caa..982911e90d824 100644 --- a/core/service-test-runner/icedfrisby-shields.js +++ b/core/service-test-runner/icedfrisby-shields.js @@ -1,10 +1,9 @@ -'use strict' /** * @module */ -const Joi = require('@hapi/joi') -const { expect } = require('chai') +import Joi from 'joi' +import { expect } from 'chai' /** * Factory which wraps an "icedfrisby-nock" with some additional functionality: @@ -20,6 +19,7 @@ const factory = superclass => constructor(message) { super(message) this.intercepted = false + super.networkOn() } get(uri, options = { followRedirect: false }) { @@ -32,6 +32,7 @@ const factory = superclass => intercept(setup) { super.intercept(setup) + super.networkOff() this.intercepted = true return this } @@ -48,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) }) } @@ -75,14 +91,14 @@ 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", ) } } } -module.exports = factory +export default factory 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 c9c4695fbeeef..0000000000000 --- a/core/service-test-runner/infer-pull-request.js +++ /dev/null @@ -1,105 +0,0 @@ -'use strict' -/** - * @module - */ - -const { URL, format: urlFormat } = require('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 - */ - -module.exports = { - 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 e3f46d3255593..0000000000000 --- a/core/service-test-runner/infer-pull-request.spec.js +++ /dev/null @@ -1,50 +0,0 @@ -'use strict' - -const { test, given, forCases } = require('sazerac') -const { - parseGithubPullRequestUrl, - inferPullRequest, -} = require('./infer-pull-request') - -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 5d7adbd7c3176..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,56 +8,26 @@ // Output: // travis // sonar -// -// Example: -// -// TRAVIS=1 TRAVIS_REPO_SLUG=badges/shields TRAVIS_PULL_REQUEST=1108 npm run test:services:pr:prepare - -'use strict' - -const got = require('got') -const { inferPullRequest } = require('./infer-pull-request') -const servicesForTitle = require('./services-for-title') -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}`, - }, - json: true, - } - ) - return title -} - -async function main() { - const { owner, repo, pullRequest, slug } = inferPullRequest() - console.error(`PR: ${slug}`) +import servicesForTitle from './services-for-title.js' - const title = await getTitle(owner, repo, pullRequest) +let title - 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 44e4718267414..4f37bee3cae37 100644 --- a/core/service-test-runner/runner.js +++ b/core/service-test-runner/runner.js @@ -1,9 +1,8 @@ -'use strict' /** * @module */ -const { loadTesters } = require('../base-service/loader') +import { loadTesters } from '../base-service/loader.js' /** * Load a collection of ServiceTester objects and register them with Mocha. @@ -24,8 +23,10 @@ class Runner { /** * Prepare the runner by loading up all the ServiceTester objects. */ - prepare() { - this.testers = loadTesters() + async prepare() { + this.testers = (await loadTesters()).flatMap(testerModule => + Object.values(testerModule), + ) this.testers.forEach(tester => { tester.beforeEach = () => { this.beforeEach() @@ -72,4 +73,4 @@ class Runner { testers.forEach(tester => tester.toss({ baseUrl, skipIntercepted, retry })) } } -module.exports = Runner +export default Runner diff --git a/core/service-test-runner/service-tester.js b/core/service-test-runner/service-tester.js index 8bc23ba517e9a..35f2fbf8c0751 100644 --- a/core/service-test-runner/service-tester.js +++ b/core/service-test-runner/service-tester.js @@ -1,14 +1,13 @@ -'use strict' /** * @module */ -const emojic = require('emojic') -const trace = require('../base-service/trace') -const frisby = require('./icedfrisby-shields')( - // eslint-disable-next-line import/order - require('icedfrisby-nock')(require('icedfrisby')) -) +import emojic from 'emojic' +import icedfrisbyNockModule from 'icedfrisby-nock' +import icedfrisbyModule from 'icedfrisby' +import trace from '../base-service/trace.js' +import icedfrisbyShieldsModule from './icedfrisby-shields.js' +const frisby = icedfrisbyShieldsModule(icedfrisbyNockModule(icedfrisbyModule)) /** * Encapsulate a suite of tests. Create new tests using create() and register @@ -25,7 +24,7 @@ class ServiceTester { * Specifies which tests to run from the CLI or pull requests * @param {string} attrs.title * Prints in the Mocha output - * @param {string} attrs.path + * @param {string} attrs.pathPrefix * Prefix which is automatically prepended to each tested URI. * The default is `/${attrs.id}`. */ @@ -47,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) { @@ -84,8 +83,8 @@ class ServiceTester { .before(() => { this.beforeEach() }) - // eslint-disable-next-line mocha/prefer-arrow-callback - .finally(function() { + // eslint-disable-next-line promise/prefer-await-to-then + .finally(function () { // `this` is the IcedFrisby instance. let responseBody try { @@ -119,20 +118,20 @@ class ServiceTester { * @param {number} attrs.retry.count number of times to retry test * @param {number} attrs.retry.backoff number of milliseconds to add to the wait between each retry */ - toss({ baseUrl, skipIntercepted, retry }) { + toss({ baseUrl, skipIntercepted, retry: { count, backoff } }) { const { specs, pathPrefix } = this const testerBaseUrl = `${baseUrl}${pathPrefix}` const fn = this._only ? describe.only : describe // eslint-disable-next-line mocha/prefer-arrow-callback - fn(this.title, function() { + fn(this.title, function () { specs.forEach(spec => { spec._message = `[${spec.hasIntercept ? 'mocked' : 'live'}] ${ spec._message }` if (!skipIntercepted || !spec.intercepted) { spec.baseUri(testerBaseUrl) - spec.retry(retry.count, retry.backoff) + spec.retry(count, backoff) spec.toss() } }) @@ -140,4 +139,4 @@ class ServiceTester { } } -module.exports = ServiceTester +export default ServiceTester diff --git a/core/service-test-runner/services-for-title.js b/core/service-test-runner/services-for-title.js index f5c6fcb46c23b..473afef8d46e0 100644 --- a/core/service-test-runner/services-for-title.js +++ b/core/service-test-runner/services-for-title.js @@ -1,10 +1,7 @@ -'use strict' /** * @module */ -const difference = require('lodash.difference') - /** * Given a pull request title like * '[Travis Sonar] Support user token authentication' @@ -28,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)) } -module.exports = servicesForTitle +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 ca2960235ee58..3d24af8170894 100644 --- a/core/service-test-runner/services-for-title.spec.js +++ b/core/service-test-runner/services-for-title.spec.js @@ -1,9 +1,7 @@ -'use strict' +import { test, given } from 'sazerac' +import servicesForTitle from './services-for-title.js' -const { test, given } = require('sazerac') -const servicesForTitle = require('./services-for-title') - -describe('Services from PR title', function() { +describe('Services from PR title', function () { test(servicesForTitle, () => { given('[Travis] Fix timeout issues').expect(['travis']) given('[Travis Sonar] Support user token authentication').expect([ @@ -12,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/fs-token-persistence.js b/core/token-pooling/fs-token-persistence.js deleted file mode 100644 index aa7294d61e956..0000000000000 --- a/core/token-pooling/fs-token-persistence.js +++ /dev/null @@ -1,51 +0,0 @@ -'use strict' - -const fsos = require('fsos') -const TokenPersistence = require('./token-persistence') - -class FsTokenPersistence extends TokenPersistence { - constructor({ path }) { - super() - this.path = path - } - - async initialize() { - let contents - try { - contents = await fsos.get(this.path) - } catch (e) { - if (e.code === 'ENOENT') { - contents = '[]' - } else { - throw e - } - } - - const tokens = JSON.parse(contents) - this._tokens = new Set(tokens) - return tokens - } - - async save() { - const tokens = Array.from(this._tokens) - await fsos.set(this.path, JSON.stringify(tokens)) - } - - async onTokenAdded(token) { - if (!this._tokens) { - throw Error('initialize() has not been called') - } - this._tokens.add(token) - await this.save() - } - - async onTokenRemoved(token) { - if (!this._tokens) { - throw Error('initialize() has not been called') - } - this._tokens.delete(token) - await this.save() - } -} - -module.exports = FsTokenPersistence diff --git a/core/token-pooling/fs-token-persistence.spec.js b/core/token-pooling/fs-token-persistence.spec.js deleted file mode 100644 index fbed1cb31525d..0000000000000 --- a/core/token-pooling/fs-token-persistence.spec.js +++ /dev/null @@ -1,72 +0,0 @@ -'use strict' - -const fs = require('fs') -const tmp = require('tmp') -const readFile = require('fs-readfile-promise') -const { expect } = require('chai') -const FsTokenPersistence = require('./fs-token-persistence') - -describe('File system token persistence', function() { - let path, persistence - beforeEach(function() { - path = tmp.tmpNameSync() - persistence = new FsTokenPersistence({ path }) - }) - - context('when the file does not exist', function() { - it('does nothing', async function() { - const tokens = await persistence.initialize() - expect(tokens).to.deep.equal([]) - }) - - it('saving creates an empty file', async function() { - await persistence.initialize() - - await persistence.save() - - const json = JSON.parse(await readFile(path)) - expect(json).to.deep.deep.equal([]) - }) - }) - - context('when the file exists', function() { - const initialTokens = ['a', 'b', 'c'].map(char => char.repeat(40)) - - beforeEach(async function() { - fs.writeFileSync(path, JSON.stringify(initialTokens)) - }) - - it('loads the contents', async function() { - const tokens = await persistence.initialize() - expect(tokens).to.deep.equal(initialTokens) - }) - - context('when tokens are added', function() { - it('saves the change', async function() { - const newToken = 'e'.repeat(40) - const expected = Array.from(initialTokens) - expected.push(newToken) - - await persistence.initialize() - await persistence.noteTokenAdded(newToken) - - const savedTokens = JSON.parse(await readFile(path)) - expect(savedTokens).to.deep.equal(expected) - }) - }) - - context('when tokens are removed', function() { - it('saves the change', async function() { - const expected = Array.from(initialTokens) - const toRemove = expected.pop() - - await persistence.initialize() - - await persistence.noteTokenRemoved(toRemove) - - const savedTokens = JSON.parse(await readFile(path)) - expect(savedTokens).to.deep.equal(expected) - }) - }) - }) -}) 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 363ef27086998..0000000000000 --- a/core/token-pooling/redis-token-persistence.integration.js +++ /dev/null @@ -1,97 +0,0 @@ -'use strict' - -const RedisServer = require('redis-server') -const Redis = require('ioredis') -const { expect } = require('chai') -const RedisTokenPersistence = require('./redis-token-persistence') - -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 992dd7e8dddde..0000000000000 --- a/core/token-pooling/redis-token-persistence.js +++ /dev/null @@ -1,43 +0,0 @@ -'use strict' - -const { URL } = require('url') -const Redis = require('ioredis') -const log = require('../server/log') -const TokenPersistence = require('./token-persistence') - -module.exports = class RedisTokenPersistence extends TokenPersistence { - constructor({ url, key }) { - super() - this.url = url - this.key = key - } - - 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) - } -} 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..50ac22619787e --- /dev/null +++ b/core/token-pooling/sql-token-persistence.integration.js @@ -0,0 +1,145 @@ +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 = [ + { token: 'a'.repeat(40), scopes: null }, + { token: 'b'.repeat(40), scopes: ['read:packages', 'read:user'] }, + { token: 'c'.repeat(40), scopes: null }, + ] + + beforeEach(async function () { + await Promise.all( + initialTokens.map(({ token, scopes }) => + pool.query( + `INSERT INTO pg_temp.${tableName} (token, scopes) VALUES ($1::text, $2::text[]);`, + [token, scopes], + ), + ), + ) + }) + + it('loads the contents', async function () { + const tokens = await persistence.initialize(pool) + expect( + tokens.sort((a, b) => a.token.localeCompare(b.token)), + ).to.deep.equal(initialTokens) + }) + + context('when tokens are added', function () { + it('saves the change', async function () { + const newToken = 'e'.repeat(40) + const expected = initialTokens.map(({ token }) => token) + 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) + }) + + it('saves token scopes', async function () { + const newToken = 'e'.repeat(40) + + await persistence.initialize(pool) + await persistence.noteTokenAdded(newToken, { + scopes: ['read:packages'], + }) + + const result = await pool.query( + `SELECT token, scopes FROM pg_temp.${tableName} WHERE token=$1::text;`, + [newToken], + ) + expect(result.rows).to.deep.equal([ + { token: newToken, scopes: ['read:packages'] }, + ]) + }) + + it('updates token scopes when a known token is re-added with scopes', async function () { + const knownToken = initialTokens[0].token + + await persistence.initialize(pool) + await persistence.noteTokenAdded(knownToken, { + scopes: ['read:packages'], + }) + + const result = await pool.query( + `SELECT token, scopes FROM pg_temp.${tableName} WHERE token=$1::text;`, + [knownToken], + ) + expect(result.rows).to.deep.equal([ + { token: knownToken, scopes: ['read:packages'] }, + ]) + }) + }) + + context('when tokens are removed', function () { + it('saves the change', async function () { + const expected = initialTokens.map(({ token }) => token) + 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..2bd4e196262ba --- /dev/null +++ b/core/token-pooling/sql-token-persistence.js @@ -0,0 +1,65 @@ +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, scopes FROM ${this.table} ORDER BY RANDOM();`, + ) + return result.rows.map(({ token, scopes }) => ({ token, scopes })) + } + + async stop() { + if (this.pool) { + await this.pool.end() + } + } + + async onTokenAdded(token, { scopes } = {}) { + return await this.pool.query( + ` + INSERT INTO ${this.table} (token, scopes) + VALUES ($1::text, $2::text[]) + ON CONFLICT (token) DO UPDATE + SET scopes = EXCLUDED.scopes + WHERE EXCLUDED.scopes IS NOT NULL; + `, + [token, scopes], + ) + } + + async onTokenRemoved(token) { + return await this.pool.query( + `DELETE FROM ${this.table} WHERE token=$1::text;`, + [token], + ) + } + + async noteTokenAdded(token, data) { + try { + await this.onTokenAdded(token, data) + } 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.spec.js b/core/token-pooling/sql-token-persistence.spec.js new file mode 100644 index 0000000000000..d22b55d54f36b --- /dev/null +++ b/core/token-pooling/sql-token-persistence.spec.js @@ -0,0 +1,50 @@ +import { expect } from 'chai' +import sinon from 'sinon' +import SqlTokenPersistence from './sql-token-persistence.js' + +describe('SQL token persistence unit', function () { + const tableName = 'github_user_tokens' + + let pool + let persistence + + beforeEach(function () { + pool = { query: sinon.stub() } + persistence = new SqlTokenPersistence({ + url: 'postgres://example.test/shields', + table: tableName, + }) + }) + + it('loads token scopes', async function () { + pool.query.resolves({ + rows: [ + { token: 'unscoped-token', scopes: null }, + { token: 'scoped-token', scopes: ['read:packages'] }, + ], + }) + + const tokens = await persistence.initialize(pool) + + expect(tokens).to.deep.equal([ + { token: 'unscoped-token', scopes: null }, + { token: 'scoped-token', scopes: ['read:packages'] }, + ]) + sinon.assert.calledOnceWithExactly( + pool.query, + `SELECT token, scopes FROM ${tableName} ORDER BY RANDOM();`, + ) + }) + + it('saves token scopes', async function () { + pool.query.resolves({ rows: [] }) + await persistence.initialize(pool) + + await persistence.onTokenAdded('scoped-token', { + scopes: ['read:packages'], + }) + + const [, params] = pool.query.secondCall.args + expect(params).to.deep.equal(['scoped-token', ['read:packages']]) + }) +}) diff --git a/core/token-pooling/token-persistence.js b/core/token-pooling/token-persistence.js deleted file mode 100644 index 685ccf97eb46b..0000000000000 --- a/core/token-pooling/token-persistence.js +++ /dev/null @@ -1,44 +0,0 @@ -'use strict' - -const log = require('../server/log') - -// This is currently bound to the legacy github auth code. That will be -// replaced with a dependency-injected token provider. -class TokenPersistence { - constructor() { - this.noteTokenAdded = this.noteTokenAdded.bind(this) - this.noteTokenRemoved = this.noteTokenRemoved.bind(this) - } - - async initialize() { - throw Error('initialize() is not implemented') - } - - async stop() {} - - async onTokenAdded(token) { - throw Error('onTokenAdded() is not implemented') - } - - async noteTokenAdded(token) { - try { - await this.onTokenAdded(token) - } catch (e) { - log.error(e) - } - } - - async onTokenRemoved(token) { - throw Error('onTokenRemoved() is not implemented') - } - - async noteTokenRemoved(token) { - try { - await this.onTokenRemoved(token) - } catch (e) { - log.error(e) - } - } -} - -module.exports = TokenPersistence diff --git a/core/token-pooling/token-pool.js b/core/token-pooling/token-pool.js index 5061c8dbf6c16..e7eaacdc40ee3 100644 --- a/core/token-pooling/token-pool.js +++ b/core/token-pooling/token-pool.js @@ -1,10 +1,9 @@ -'use strict' /** * @module */ -const crypto = require('crypto') -const PriorityQueue = require('priorityqueuejs') +import crypto from 'crypto' +import PriorityQueue from 'priorityqueuejs' /** * Compute a one-way hash of the input string. @@ -13,10 +12,7 @@ const PriorityQueue = require('priorityqueuejs') * @returns {string} hash */ function sanitizeToken(id) { - return crypto - .createHash('sha256') - .update(id, 'utf-8') - .digest('hex') + return crypto.createHash('sha256').update(id, 'utf-8').digest('hex') } function getUtcEpochSeconds() { @@ -49,6 +45,7 @@ class Token { _nextReset: nextReset, _isValid: true, _isFrozen: false, + _failedAttempts: 0, }) } @@ -76,6 +73,10 @@ class Token { return this._isFrozen } + get failedAttempts() { + return this._failedAttempts + } + get hasReset() { return getUtcEpochSeconds() >= this.nextReset } @@ -84,6 +85,21 @@ class Token { return this.usesRemaining <= 0 && !this.hasReset } + get decrementedUsesRemaining() { + return this._usesRemaining - 1 + } + + /** + * Update user-provided data associated with the token. + * + * Callers should only invoke this when they have fresh data to store. + * + * @param {*} data reserved for future use + */ + updateData(data) { + this._data = data + } + /** * Update the uses remaining and next reset time for a token. * @@ -128,6 +144,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. @@ -184,8 +217,8 @@ class TokenPool { this.currentBatch = { currentToken: null, remaining: 0 } - // A set of IDs used for deduplication. - this.tokenIds = new Set() + // A map of IDs to tokens, used for deduplication and metadata updates. + this.tokensById = new Map() // See discussion on the FIFO and priority queues in `next()`. this.fifoQueue = [] @@ -195,9 +228,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 @@ -206,6 +239,10 @@ class TokenPool { /** * Add a token with user-provided ID and data. * + * Adding an existing valid token updates its data only when `data` is + * provided. Adding an invalid token creates a replacement, allowing stale + * queue entries to remain invalid until they are discarded on access. + * * @param {string} id token string * @param {*} data reserved for future use * @param {number} usesRemaining @@ -213,21 +250,29 @@ class TokenPool { * @param {number} nextReset * Time when the token can be used again even if it's exhausted (unix timestamp) * - * @returns {boolean} Was the token added to the pool? + * @returns {boolean} Was a previously unknown token added to the pool? */ add(id, data, usesRemaining, nextReset) { - if (this.tokenIds.has(id)) { + const existingToken = this.tokensById.get(id) + if (existingToken?.isValid) { + if (data !== undefined) { + existingToken.updateData(data) + } return false } - this.tokenIds.add(id) + + if (data === undefined && existingToken) { + data = existingToken.data + } usesRemaining = usesRemaining === undefined ? this.batchSize : usesRemaining nextReset = nextReset === undefined ? Token.nextResetNever : nextReset const token = new Token(id, data, usesRemaining, nextReset) + this.tokensById.set(id, token) this.fifoQueue.push(token) - return true + return existingToken === undefined } // Prepare to start a new batch by obtaining and returning the next usable @@ -252,7 +297,7 @@ class TokenPool { (next = this.priorityQueue.peek()) ) { if (!next.isValid) { - // Discard, and + this.priorityQueue.deq() continue } else if (next.isExhausted) { // No need to check any more tokens, since they all reset after this @@ -295,7 +340,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 @@ -317,6 +362,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. * @@ -333,32 +392,29 @@ 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, } } } -module.exports = { - sanitizeToken, - Token, - TokenPool, -} +export { sanitizeToken, Token, TokenPool } diff --git a/core/token-pooling/token-pool.spec.js b/core/token-pooling/token-pool.spec.js index 1c62f2707f321..24737da96dfe1 100644 --- a/core/token-pooling/token-pool.spec.js +++ b/core/token-pooling/token-pool.spec.js @@ -1,9 +1,8 @@ -'use strict' +import { expect } from 'chai' +import sinon from 'sinon' +import { Token, TokenPool } from './token-pool.js' -const { expect } = require('chai') -const sinon = require('sinon') -const times = require('lodash.times') -const { Token, TokenPool } = require('./token-pool') +const times = (n, fn) => [...Array(n)].map(() => fn()) function expectPoolToBeExhausted(pool) { expect(() => { @@ -11,98 +10,152 @@ function expectPoolToBeExhausted(pool) { }).to.throw(Error, /^Token pool is exhausted$/) } -describe('The token pool', function() { +describe('The token pool', function () { const ids = ['1', '2', '3', '4', '5'] const batchSize = 3 let tokenPool - beforeEach(function() { + beforeEach(function () { tokenPool = new TokenPool({ batchSize }) 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() { + 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() { + 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, - }) - }) + it('updates data when an existing token is added again', function () { + const tokenPool = new TokenPool() + + expect(tokenPool.add('token', { scopes: [] })).to.equal(true) + expect(tokenPool.add('token', { scopes: ['read:packages'] })).to.equal( + false, + ) + + expect(tokenPool.next().data).to.deep.equal({ + scopes: ['read:packages'], }) + }) - 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, - } - ) - }) + it('preserves existing data when an existing token is added again without data', function () { + const tokenPool = new TokenPool() + + expect(tokenPool.add('token', { scopes: ['read:packages'] })).to.equal(true) + expect(tokenPool.add('token')).to.equal(false) + + expect(tokenPool.next().data).to.deep.equal({ + scopes: ['read:packages'], + }) + }) + + it('replaces invalid tokens when they are added again', function () { + const tokenPool = new TokenPool() + expect(tokenPool.add('token', { scopes: [] })).to.equal(true) + + tokenPool.next().invalidate() + expectPoolToBeExhausted(tokenPool) + + expect(tokenPool.add('token', { scopes: ['read:packages'] })).to.equal( + false, + ) + + const token = tokenPool.next() + expect(token.id).to.equal('token') + expect(token.data).to.deep.equal({ scopes: ['read:packages'] }) + }) + + it('isolates re-added tokens from stale references', function () { + const tokenPool = new TokenPool() + expect(tokenPool.add('token', { scopes: [] })).to.equal(true) + + const staleToken = tokenPool.next() + staleToken.invalidate() + expect(tokenPool.add('token', { scopes: ['read:packages'] })).to.equal( + false, + ) + + staleToken.update(0, Token.nextResetNever) + + const token = tokenPool.next() + expect(token).to.not.equal(staleToken) + expect(token.data).to.deep.equal({ scopes: ['read:packages'] }) + }) + + it('preserves data when an invalid token is replaced without new data', function () { + const tokenPool = new TokenPool() + tokenPool.add('token', { scopes: ['read:packages'] }) + tokenPool.next().invalidate() + + expect(tokenPool.add('token')).to.equal(false) + + expect(tokenPool.next().data).to.deep.equal({ + scopes: ['read:packages'], + }) + }) + + it('does not duplicate queued tokens when they are replaced', function () { + const tokenPool = new TokenPool({ batchSize: 2 }) + tokenPool.add('first') + tokenPool.add('second') + + tokenPool.next().invalidate() + tokenPool.add('first') + + expect(tokenPool.next().id).to.equal('second') + expect(tokenPool.next().id).to.equal('second') + expect(tokenPool.next().id).to.equal('first') + expect(tokenPool.next().id).to.equal('first') + }) + + it('does not duplicate exhausted tokens when they are replaced', function () { + const tokenPool = new TokenPool() + tokenPool.add('first') + tokenPool.add('second') + + const first = tokenPool.next() + first.update(0, Token.nextResetNever) + tokenPool.next() + + first.invalidate() + tokenPool.add('first') + + const { allTokenDebugInfo } = tokenPool.serializeDebugInfo({ + sanitize: false, }) + const tokenIds = allTokenDebugInfo.map(({ id }) => id) + + expect(tokenIds).to.have.members(['first', 'second']) + expect( + allTokenDebugInfo.find(({ id }) => id === 'first').isFrozen, + ).to.equal(false) }) - context('tokens are marked exhausted immediately', function() { - it('should be exhausted', function() { + it('removes invalid priority queue entries on access', function () { + const tokenPool = new TokenPool() + tokenPool.add('token', undefined, 0, Token.nextResetNever) + const token = tokenPool.tokensById.get('token') + + expectPoolToBeExhausted(tokenPool) + token.invalidate() + expect(tokenPool.priorityQueue.size()).to.equal(1) + + expectPoolToBeExhausted(tokenPool) + expect(tokenPool.priorityQueue.size()).to.equal(0) + }) + + context('tokens are marked exhausted immediately', function () { + it('should be exhausted', function () { ids.forEach(() => { const token = tokenPool.next() token.update(0, Token.nextResetNever) @@ -112,8 +165,8 @@ describe('The token pool', function() { }) }) - context('tokens are marked after the last request', function() { - it('should be exhausted', function() { + context('tokens are marked after the last request', function () { + it('should be exhausted', function () { ids.forEach(() => { const token = times(batchSize, () => tokenPool.next()).pop() token.update(0, Token.nextResetNever) @@ -123,8 +176,8 @@ describe('The token pool', function() { }) }) - context('tokens are renewed', function() { - it('should keep using them', function() { + context('tokens are renewed', function () { + it('should keep using them', function () { const tokensToRenew = ['2', '4'] const renewalCount = 3 @@ -149,16 +202,16 @@ describe('The token pool', function() { }) }) - context('tokens reset', function() { + context('tokens reset', function () { let clock - beforeEach(function() { + beforeEach(function () { clock = sinon.useFakeTimers() }) - afterEach(function() { + afterEach(function () { clock.restore() }) - it('should start using them', function() { + it('should start using them', function () { const tokensToReset = ['2', '4'] const futureTime = 1440 @@ -183,10 +236,96 @@ describe('The token pool', function() { }) }) - context('when empty', function() { - it('next() should return the expected error', function() { + context('when empty', function () { + it('next() should return the expected error', function () { const tokenPool = new TokenPool() 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/core/unhandled-rejection.spec.js b/core/unhandled-rejection.spec.js index b155793b9b04f..17c42ac09beb1 100644 --- a/core/unhandled-rejection.spec.js +++ b/core/unhandled-rejection.spec.js @@ -1,5 +1,3 @@ -'use strict' - // Cause unhandled promise rejections to fail unit tests, and print with stack // traces. process.on('unhandledRejection', error => { 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..82da11598bc90 --- /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 c75c0763a0a69..0000000000000 --- a/cypress/integration/main-page.spec.js +++ /dev/null @@ -1,67 +0,0 @@ -'use strict' - -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']`) - }) -}) diff --git a/dangerfile.js b/dangerfile.js index ce5461dc5cf93..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,21 +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', - 'lib/all-badge-examples.js', - 'frontend/components/usage.js', - 'frontend/pages/endpoint.js' + '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') @@ -35,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}`) } @@ -50,7 +45,7 @@ if (documentation.edited) { [ 'Thanks for contributing to our documentation. ', 'We :heart: our [documentarians](http://www.writethedocs.org/)!', - ].join('') + ].join(''), ) } @@ -65,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(''), ) } @@ -76,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(''), ) } @@ -96,7 +80,7 @@ if (capitals.created || underscores.created) { [ 'JavaScript source files should be named with `kebab-case` ', '(dash-separated lowercase).', - ].join('') + ].join(''), ) } @@ -112,16 +96,13 @@ if (allFiles.length > 100) { // eslint-disable-next-line promise/prefer-await-to-then danger.git.diffForFile(file).then(({ diff }) => { - if ( - (diff.includes('authHelper') || diff.includes('serverSecrets')) && - !secretsDocs.modified - ) { + 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(''), ) } @@ -130,17 +111,17 @@ 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(''), ) } - if (diff.includes("require('joi')")) { + if (diff.includes("from '@hapi/joi'")) { fail( [ - `Found import of 'joi' in \`${file}\`.
`, - "Joi must be imported as '@hapi/joi'.", - ].join('') + `Found import of '@hapi/joi' in \`${file}\`.
`, + "Joi must be imported as 'joi'.", + ].join(''), ) } }) @@ -173,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 bfb028bc4cf60..42d6719bac9e1 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,116 +14,83 @@ 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 10 or later is required. If you don't already have them, -install node and npm: https://nodejs.org/en/download/ +Node 24 and NPM 11.x or 12.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]. +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) - implements methods for performing requests to a JSON API and schema validation. -- [BaseXmlService](https://contributing.shields.io/module-core_base-service_base-xml-basexmlservice) - implements methods for performing requests to an XML API and schema validation. -- [BaseYamlService](https://contributing.shields.io/module-core_base-service_base-yaml-baseyamlservice) - 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) - implements methods for retrieving information from existing third-party badges. -- [BaseGraphqlService](https://contributing.shields.io/module-core_base-service_base-graphql-basegraphqlservice) - 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. +- [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-baseservice +[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) -'use strict' +import { BaseService } from '../index.js' + // (2) -const { BaseService } = require('..') +export default class Example extends BaseService { + // (3) + static category = 'build' -// (3) -module.exports = class Example extends BaseService { // (4) - static get category() { - return 'build' - } + static route = { base: 'example', pattern: ':text' } // (5) - static get route() { - return { - base: 'example', - pattern: ':text', - } - } - - // (6) async handle({ text }) { return { label: 'example', @@ -141,18 +103,14 @@ module.exports = class Example extends BaseService { Description of the code: -1. We declare strict mode at the start of each file. This prevents certain classes of error such as undeclared variables. -2. Our service badge class will extend `BaseService` so we need to require it. Variables are declared with `const` and `let` in preference to `var`. -3. Our module must export a class which extends `BaseService`. -4. 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. `route()` declares the URL path at which the service operates. It also maps components of the URL path to handler parameters. +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-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] - - There is additional documentation on conventions for [designing badge URLs](badge-urls) -6. 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: + - `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: - `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 @@ -162,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 @@ -178,45 +134,33 @@ This example is based on the [Ruby Gems version](https://github.com/badges/shiel ```js // (1) -'use strict' - +import { renderVersionBadge } from '../version.js' // (2) -const { renderVersionBadge } = require('..//version') -// (3) -const { BaseJsonService } = require('..') +import { BaseJsonService } from '../index.js' -// (4) -const Joi = require('@hapi/joi') +// (3) +import Joi from 'joi' const schema = Joi.object({ version: Joi.string().required(), }).required() -// (5) -module.exports = class GemVersion extends BaseJsonService { +// (4) +export default class GemVersion extends BaseJsonService { + // (5) + static category = 'version' + // (6) - static get category() { - return 'version' - } + static route = { base: 'gem/v', pattern: ':gem' } // (7) - static get route() { - return { - base: 'gem/v', - pattern: ':gem', - } - } + static defaultBadgeData = { label: 'gem' } - // (8) - static get defaultBadgeData() { - return { label: 'gem' } - } - - // (11) + // (10) static render({ version }) { return renderVersionBadge({ version }) } - // (10) + // (9) async fetch({ gem }) { return this._requestJson({ schema, @@ -224,7 +168,7 @@ module.exports = class GemVersion extends BaseJsonService { }) } - // (9) + // (8) async handle({ gem }) { const { version } = await this.fetch({ gem }) return this.constructor.render({ version }) @@ -234,39 +178,37 @@ module.exports = class GemVersion extends BaseJsonService { Description of the code: -1. As with the first example, we declare strict mode at the start of each file. -2. In this case we are making a version badge, which is a common pattern. Instead of directly returning an object in this badge we will use a helper function to format our data consistently. There are a variety of helper functions to help with common tasks in `/services`. Some useful generic helpers can be found in: +1. In this case we are making a version badge, which is a common pattern. Instead of directly returning an object in this badge we will use a helper function to format our data consistently. There are a variety of helper functions to help with common tasks in `/services`. Some useful generic helpers can be found in: - [build-status.js](https://github.com/badges/shields/blob/master/services/build-status.js) - [color-formatters.js](https://github.com/badges/shields/blob/master/services/color-formatters.js) - [licenses.js](https://github.com/badges/shields/blob/master/services/licenses.js) - [text-formatters.js](https://github.com/badges/shields/blob/master/services/text-formatters.js) - [version.js](https://github.com/badges/shields/blob/master/services/version.js) -3. Our badge will query a JSON API so we will extend `BaseJsonService` instead of `BaseService`. This contains some helpers to reduce the need for boilerplate when calling a JSON API. -4. We perform input validation by defining a schema which we expect the JSON we receive to conform to. This is done using [Joi](https://github.com/hapijs/joi). Defining a schema means we can ensure the JSON we receive meets our expectations and throw an error if we receive unexpected input without having to explicitly code validation checks. The schema also acts as a filter on the JSON object. Any properties we're going to reference need to be validated, otherwise they will be filtered out. In this case our schema declares that we expect to receive an object which must have a property called 'version', which is a string. -5. Our module exports a class which extends `BaseJsonService` -6. Returns the name of the category to sort this badge into (eg. "build"). Used to sort the examples on the main [shields.io](https://shields.io) website. [Here](https://github.com/badges/shields/blob/master/services/categories.js) is the list of the valid categories. See [section 4.4](#44-adding-an-example-to-the-front-page) for more details on examples. -7. As with our previous badge, we need to declare a route. This time we will capture a variable called `gem`. -8. 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. -9. 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. -10. 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. - - - `_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`. - - A more complex call to `_requestJson()` might look like this: - ```js - return this._requestJson({ - schema: mySchema, - url, - options: { qs: { branch: 'master' } }, - errorMessages: { - 401: 'private application not supported', - 404: 'application not found', - }, - }) - ``` - -11. 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. +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-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. +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 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 [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: { 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. This code allows us to call this URL to generate this badge: ![](https://img.shields.io/gem/v/formatador) @@ -279,83 +221,112 @@ 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 -const { NotFound } = require('..') +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 -module.exports = class GemVersion extends BaseJsonService { - // ... +// (1) +import { pathParams } from '../index.js' - static get category() { - // (1) - return 'version' - } +export default class GemVersion extends BaseJsonService { + // ... - static get examples() { - // (2) - return [ - { - // (3) - title: 'Gem', - namedParams: { gem: 'formatador' }, - staticPreview: this.render({ version: '2.1.0' }), - keywords: ['ruby'], + // (2) + static category = 'version' + + 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 +### (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 @@ -365,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 6907ae0348255..ce955edb26ac2 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,141 +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]. If the badge result - is found in a relatively small in-memory cache, the response is sent - immediately. Otherwise a timeout is set to handle unresponsive service - code and the next callback is invoked: the legacy handler function. -3. The legacy handler function receives - `( queryParams, match, sendBadge, request )`. Its job is to extract data - from the regex `match` and `queryParams`, invoke `request` to fetch - 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 and caches the result. 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/deprecating-badges.md b/doc/deprecating-badges.md deleted file mode 100644 index 353b56de6b5a6..0000000000000 --- a/doc/deprecating-badges.md +++ /dev/null @@ -1,94 +0,0 @@ -# Deprecating Badges - -When a service that Shields integrates with shuts down, those badges will no longer work and need to be deprecated within Shields. - -Deprecating 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 - -## 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: - -```js -'use strict' - -const { deprecatedService } = require('..') - -module.exports = deprecatedService({ - category: 'size', - route: { - base: 'imagelayers', - format: '(?:.+?)', - }, - label: 'imagelayers', - dateAdded: new Date('2019-xx-xx'), // Be sure to update this with today's date! -}) -``` - -## Update Service Tests - -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: - -```js -const { ServiceTester } = require('../tester') - -const t = (module.exports = new ServiceTester({ - id: 'imagelayers', - title: 'ImageLayers', -})) -``` - -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: - -```js -t.create('no longer available (previously image size)') - .get('/image-size/_/ubuntu/latest.json') - .expectBadge({ - label: 'imagelayers', - message: 'no longer available', - }) -``` - -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. - -Here is an example of what the final result would look like for a test file: - -```js -'use strict' - -const { ServiceTester } = require('../tester') - -const t = (module.exports = new ServiceTester({ - id: 'imagelayers', - title: 'ImageLayers', -})) - -t.create('no longer available (previously image size)') - .get('/image-size/_/ubuntu/latest.json') - .expectBadge({ - label: 'imagelayers', - message: 'no longer available', - }) - -t.create('no longer available (previously number of layers)') - .get('/layers/_/ubuntu/latest.json') - .expectBadge({ - label: 'imagelayers', - message: 'no longer available', - }) -``` - -## Additional Information - -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) diff --git a/doc/input-validation.md b/doc/input-validation.md new file mode 100644 index 0000000000000..b3bcc5c836afc --- /dev/null +++ b/doc/input-validation.md @@ -0,0 +1,51 @@ +# Input Data Validation + +When we receive input data from an upstream API, we perform input validation to: + +- Ensure we won't throw a runtime error trying to render a badge +- Ensure we won't render badges with spurious or unexpected output e.g: ![](https://img.shields.io/badge/version-null-blue) ![](https://img.shields.io/badge/coverage-NaN%25-red) ![](https://img.shields.io/badge/build-undefined-red) ![](https://img.shields.io/badge/coverage---10%25-critical) etc +- Express and document our understanding of the input data + +## Writing schemas and validation + +- The default validation mechanism should be to use [Joi](https://github.com/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. + - If we're going to address `foo[0]`, `foo` must be an array. + - If we're going to sort a version on the assumption it is a semver, check it's a semver + +- We don't need to validate characteristics we don't rely on. For example, if we're just going to render a version on a badge with the same exact value from the API response and do not need to sort or transform the value, then it doesn't matter what format the version number is in. We can use a very relaxed schema to validate in this case, e.g. `Joi.string().required()` + +- 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. + +- It is fine to define a single schema which is applied to multiple badges. For example, we could define a schema that says: + + ```js + const schema = Joi.object({ + license: Joi.string().required(), + version: Joi.string().required(), + }).required() + ``` + + and have both the license and version badges validate the response against that schema. + +- For build status badges there is a shared [isBuildStatus](https://github.com/badges/shields/blob/master/services/build-status.js) validator. In most cases build status badges should use `isBuildStatus` or input validation and `renderBuildStatusBadge` should be used for rendering. Any additional status values can be added to the relevant color arrays. + +## Identifying problems + +- If we know of a real-world example of a package/repo/etc that causes us to render an invalid value on a badge (e.g: ![](https://img.shields.io/badge/version-null-blue) ![](https://img.shields.io/badge/coverage-NaN%25-red) ![](https://img.shields.io/badge/build-undefined-red) ) our input validation is broken and we should fix it. + +- If we know of a real-world example of a package/repo/etc that causes us to throw an unhandled runtime exception, our input validation is broken and we should fix it. + +- We should not fail to render a badge because of a validation failure on a field that isn't necessary to render the badge. In the above example of a shared license/version schema: If we become aware of a real-world example of a package/repo/etc that has a `version` key but not a `license` key then we should split the schema (or make `version` optional and handle the error in code). 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 8fcbffbfeafc4..0000000000000 --- a/doc/logos.md +++ /dev/null @@ -1,61 +0,0 @@ -# Logos - -## Using Logos - -### SimpleIcons - -We support a wide range of logos via [SimpleIcons][]. They can be referenced by name e.g: - -![](https://img.shields.io/npm/v/npm.svg?logo=javascript) - https://img.shields.io/npm/v/npm.svg?logo=javascript - -### 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 df702a28eec54..f5507887a75a7 100644 --- a/doc/production-hosting.md +++ b/doc/production-hosting.md @@ -1,5 +1,12 @@ # Production hosting +Production hosting is managed by the Shields ops team: + +- [calebcartwright](https://github.com/calebcartwright) +- [jNullj](https://github.com/jnullj) +- [paulmelnikow](https://github.com/paulmelnikow) +- [PyvesB](https://github.com/PyvesB) + [![operations issues](https://img.shields.io/github/issues/badges/shields/operations.svg?label=open%20operations%20issues)][operations issues] [#ops chat room][ops discord] @@ -7,204 +14,71 @@ [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 | -| ----------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------ | -| Badge servers | Account owner | @espadrine | -| Badge servers | ssh, logs | @espadrine | -| Badge servers | Deployment | @espadrine, @paulmelnikow | -| Badge servers | Admin endpoints | @espadrine, @paulmelnikow | -| Compose.io Redis | Account owner | @paulmelnikow | -| Compose.io Redis | Account access | @paulmelnikow | -| Compose.io Redis | Database connection credentials | @espadrine, @paulmelnikow | -| 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 | Account owner | @espadrine | -| Cloudflare | Admin access | @espadrine, @paulmelnikow | -| GitHub | OAuth app | @espadrine ([could be transferred to the badges org][oauth transfer]) | -| Twitch | OAuth app | @PyvesB | -| OpenStreetMap (for Wheelmap) | Account owner | @paulmelnikow | -| DNS | Account owner | @olivierlacan | -| DNS | Read-only account access | @espadrine, @paulmelnikow, @chris48s | -| Sentry | Error reports | @espadrine, @paulmelnikow | -| Frontend | Deployment | Technically anyone with push access but in practice must be deployed with the badge server | -| Metrics server | Owner | @platan | -| UptimeRobot | Account owner | @paulmelnikow | -| More metrics | Owner | @RedSparr0w | -| Netlify (documentation site) | Owner | @chris48s | - -There are [too many bottlenecks][issue 2577]! - -## Badge servers - -There are three public badge servers on OVH VPS’s. - -| Cname | Hostname | Type | IP | Location | -| --------------------------- | -------------------- | ---- | -------------- | ------------------ | -| [s0.servers.shields.io][s0] | vps71670.vps.ovh.ca | VPS | 192.99.59.72 | Quebec, Canada | -| [s1.servers.shields.io][s1] | vps244529.ovh.net | VPS | 51.254.114.150 | Gravelines, France | -| [s2.servers.shields.io][s2] | vps117870.vps.ovh.ca | VPS | 149.56.96.133 | Quebec, Canada | - -- These are single-core virtual hosts with 2 GB RAM [VPS SSD 1][]. -- The Node version (v9.4.0 at time of writing) and dependency versions on the - servers can be inspected in Sentry, but only when an error occurs. -- The servers use self-signed SSL certificates. ([#1460][issue 1460]) -- After accepting the certificate, you can debug an individual server using - the links above. -- The scripts that start the server live in the [ServerScript][] repo. However - updates must be pulled manually. They are not updated as part of the deploy process. -- The server runs SSH. -- Deploys are made using a git post-receive hook. -- The server uses systemd to automatically restart the server when it crashes. -- Provisioning additional servers is a manual process which is yet to been - documented. -- The public servers _do not_ use docker. The `Dockerfile` is included for - self-hosting (including on a Docker-capable PaaS). - -[s0]: https://s0.servers.shields.io/index.html -[s1]: https://s1.servers.shields.io/index.html -[s2]: https://s2.servers.shields.io/index.html -[vps ssd 1]: https://www.ovh.com/world/vps/vps-ssd.xml -[issue 1460]: https://github.com/badges/shields/issues/1460 -[serverscript]: https://github.com/badges/ServerScript +| 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 a few caches in memory. These are neither persisted nor - inspectable. - - The [request cache][] - - The [regular-update cache][] +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 -[request cache]: https://github.com/badges/shields/blob/master/core/base-service/legacy-request-handler.js#L29-L30 -[regular-update cache]: https://github.com/badges/shields/blob/master/core/legacy/regular-update.js -[oauth transfer]: https://developer.github.com/apps/managing-oauth-apps/transferring-ownership-of-an-oauth-app/ +[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 [default.yml]: ../config/default.yml -The project ships with `dotenv`, however there is no `.env` in production. - ## Badge CDN -Sitting in front of the three servers is a Cloudflare Free account which -provides several services: +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` +- Global CDN, caching, and SSL gateway for `img.shields.io` and `shields.io` - Analytics through the Cloudflare dashboard -- DNS hosting for `shields.io` +- DNS resolution for `shields.io` (and subdomains) Cloudflare is configured to respect the servers' cache headers. -## Frontend - -The frontend is served by [GitHub Pages][] via the [gh-pages branch][gh-pages]. SSL is enforced. - -`shields.io` resolves to the GitHub Pages hosts. It is not proxied through -Cloudflare. - -Technically any maintainer can push to `gh-pages`, but in practice the frontend must be deployed -with the badge server via the deployment process described below. - -[github pages]: https://pages.github.com/ -[gh-pages]: https://github.com/badges/shields/tree/gh-pages - -## Raster server - -The raster server `raster.shields.io` (a.k.a. the rasterizing proxy) is -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 - -## Deployment - -To set things up for deployment: - -1. Get your SSH key added to the server. -2. Clone a fresh copy of the repository, dedicated for deployment. - (Not required, but recommended; and lets you use `npm ci` below.) -3. Add remotes: - -```sh -git remote add s0 root@s0.servers.shields.io:/home/m/shields.git -git remote add s1 root@s1.servers.shields.io:/home/m/shields.git -git remote add s2 root@s2.servers.shields.io:/home/m/shields.git -``` - -`origin` should point to GitHub as usual. +### Fly.io Deployment -4. Since the deploy uses `git worktree`, make sure you have git 2.5 or later. +Both the badge server and frontend are served from Fly.io. Deployments are triggered using GitHub actions in a private repo. -To deploy: - -1. Use `git fetch` to obtain a current copy of - `local-shields-io-production.yml` from the server (or obtain the current - version of that file some other way). Save it in `config/`. -2. Check out the commit you want to deploy. -3. Run `npm ci`. **This is super important for the frontend build!** -4. Run `make deploy-s0` to make a canary deploy. -5. Check the canary deploy: - - [Visit the server][s0]. Don't forget that most of the preview badges - are static! - - Look for errors in [Sentry][]. - - Keep an eye on the [status page][status]. -6. After a little while (usually 10–60 minutes), finish the deploy: - `make push-s1 push-s2 deploy-gh-pages`. - -To roll back, check out the commit you want to roll back to and repeat those -steps. - -To see which commit is deployed to a server run `git ls-remote` and then -`git log` on the `HEAD` ref. There will be two deploy commits preceded by the -commit which was deployed. - -Be careful not to push the deploy commits to GitHub. - -`make deploy-s0` does the following: - -1. Creates a working tree in `/tmp`. -2. In that tree, runs `features` and `examples` to generate data files - needed for the frontend. -3. Builds and checks in the built frontend. -4. Checks in `local-shields-io-production.yml`. -5. Pushes to s0, which updates dependencies and then restarts itself. - -`make push-s1 push-s2 deploy-gh-pages` does the following: - -1. Pushes the same working tree to s1 and s2. -2. Creates a new working tree for the frontend. -3. Adds a commit cleaning out the index. -4. Adds another commit with the build frontend. -5. Pushes to `gh-pages`. +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 @@ -212,47 +86,32 @@ DNS is registered with [DNSimple][]. [dnsimple]: https://dnsimple.com/ -## Logs - -Logs are available on the individual servers via SSH. - ## 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 +## URLs + +The canonical and only recommended domain for badge URLs is `img.shields.io`. Currently it is possible to request badges on both `img.shields.io` and `shields.io` i.e: https://img.shields.io/badge/build-passing-brightgreen and https://shields.io/badge/build-passing-brightgreen will both work. However: + +- We never show or generate the `img.`-less URL format on https://shields.io/ +- We make no guarantees about the `img.`-less URL format. At some future point we may remove the ability to serve badges on `shields.io` (without `img.`) without any warning. `img.shields.io` should always be used for badge urls. + ## Monitoring -Overall server performance and requests by service are monitored using -[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://status.shields.io/ +[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 - -## Known limitations - -1. The only way to inspect the commit on the server is with `git ls-remote`. -2. The production deploy installs `devDependencies`. It does not honor - `package-lock.json`. ([#1988][issue 1988]) - -[issue 2577]: https://github.com/badges/shields/issues/2577 -[issue 1988]: https://github.com/badges/shields/issues/1988 diff --git a/doc/releases.md b/doc/releases.md new file mode 100644 index 0000000000000..c9f51640d5f93 --- /dev/null +++ b/doc/releases.md @@ -0,0 +1,57 @@ +# Releases + +Shields is a community project that is stewarded by a handful of core maintainers who contribute on a volunteer basis. We do our best to maintain the availability and reliability of the service, and enhance and improve the project overall. However, if you've spotted something wrong or would like to see a specific feature implemented, please consider helping us resolve it by submitting a pull request. All community contributions, even documentation improvements, are welcome! + +https://github.com/badges/shields is a monorepo and hosts the Shields frontend and server code as well as the [badge-maker][npm package] NPM library (and the [badge design specification](https://github.com/badges/shields/tree/master/spec)). The packaging and release processes for these items are described in the respective sections below. + +## badge-maker package + +We follow [Semantic Versioning](https://semver.org/) for the badge-maker package, and publish git Tags using the form `X.Y.Z`, and all such tags of this form are badge-maker releases (e.g. [3.3.0](https://github.com/badges/shields/releases/tag/3.3.0)) + +The [badge-maker][npm package] is also published to the npm.js registry. + +Releases of badge-maker are done as and when needed, and not on any predetermined interval. + +## Shields.io service + +The [Shields.io Service][shields.io] consists of the frontend [https://shields.io][shields.io] website which allows users to browse and discover available badges, as well as the badge server backend that serves up requested badges (e.g. https://img.shields.io/badge/badges-rock-blue). + +This is the core, free, anonymous service available for anyone to use and which serves up hundreds of millions of badges every month! + +We do not have a fixed schedule for deploying updates to the Shields.io production environment, but we typically deploy the latest version at least once per week. + +We do not have any guaranteed SLA for the Shields.io service, but we do have monitoring and observability capabilities in place and our [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] + +## Shields server + +Some users may wish to host their own instance of Shields so we also make it possible for users to do so. This is particularly useful if you want to serve badges for resources that require authentication or are not exposed to the internet (e.g: inside a corporate network). A variety of options are available either by installing from source or using a docker image. + +### Important information + +This Shields server is the exact same codebase that powers the main Shields.io service; we do not have a separate self-hosted version of Shields. This means that the server codebase is geared towards the development, maintenance, and operation of the Shields.io service so there are a few things to note: + +- We often have to reject or de-prioritize feature requests that are only applicable for self-hosting and/or which may be problematic for the core Shields.io offering (e.g. requiring authentication on the badge server) +- We do not accept new badges that cannot be utilized with Shields.io (e.g. those that would always require user-specific authorization) +- We do not do any additional testing nor validation of self-hosted instances (e.g. we test Shields.io with Jira Cloud badges, but we don't test a self-hosted Shields server against a self-hosted Jira server instance with auth) +- We're happy to try to offer some tips and guidance to self-hosters when and where we can, but we don't have the ability to troubleshoot/debug/support/etc. self-hosted Shields servers + - Note that Paul, a core team member, offers some paid support options for those interested in self-hosting support. Contact him at ![](https://img.shields.io/badge/paul-%40m6ize.com-blue) for more information. + +We are happy to document and collate any self-hosting patterns/approaches that others have found to be helpful to be able to share with the broader community. If you have any self-hosting material you'd like to share, please feel free to get in contact with us (or even open a PR) and we'll work with you to get that incorporated for the benefit of other self-hosters! + +### Server Releases + +We try to make it as easy as possible for users to self-host a Shields server so we publish a few releases of the server. Please be sure to refer to the [self hosting guide][self hosting] for a detailed walk through on how to spin up a server. + +- The server uses [Calendar Versioning](https://calver.org/). Tags of the form `server-YYYY-MM-DD` are server releases (these are the tags that are relevant to self-hosting users, e.g. [server-2021-02-01](https://github.com/badges/shields/releases/tag/server-2021-02-01)). +- As well as [tags on GitHub](https://github.com/badges/shields/tags), server releases are also pushed to [DockerHub](https://registry.hub.docker.com/r/shieldsio/shields/tags) 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/GHCR. + +[shields.io]: https://shields.io +[npm package]: https://www.npmjs.com/package/badge-maker +[production hosting]: https://github.com/badges/shields/blob/master/doc/production-hosting.md +[self hosting]: https://github.com/badges/shields/blob/master/doc/self-hosting.md diff --git a/doc/retiring-badges.md b/doc/retiring-badges.md new file mode 100644 index 0000000000000..76b52910e06c2 --- /dev/null +++ b/doc/retiring-badges.md @@ -0,0 +1,149 @@ +# Retiring Badges + +When a service that Shields integrates with shuts down, those badges will no longer work and need to be retired (deprecated) within Shields. + +Retiring a badge involves two steps: + +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 `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 { retiredService } from '../index.js' + +export default retiredService({ + category: 'size', + route: { + base: 'imagelayers', + pattern: ':various+', + }, + label: 'imagelayers', + dateAdded: new Date('2019-xx-xx'), // Be sure to update this with today's date! +}) +``` + +## Update Service Tests + +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 `RetiredService` classes we cannot use `createServiceTester()` so you will need to create the `ServiceTester` class directly. For example: + +```js +import { ServiceTester } from '../tester.js' + +export const t = new ServiceTester({ + id: 'imagelayers', + title: 'ImageLayers', +}) +``` + +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('retired badge (previously image size)') + .get('/image-size/_/ubuntu/latest.json') + .expectBadge({ + label: 'imagelayers', + 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 `retired badge` message. + +Here is an example of what the final result would look like for a test file: + +```js +import { ServiceTester } from '../tester.js' + +export const t = new ServiceTester({ + id: 'imagelayers', + title: 'ImageLayers', +}) + +t.create('retired badge (previously image size)') + .get('/image-size/_/ubuntu/latest.json') + .expectBadge({ + label: 'imagelayers', + message: 'retired badge', + }) + +t.create('retired badge (previously number of layers)') + .get('/layers/_/ubuntu/latest.json') + .expectBadge({ + label: 'imagelayers', + message: 'retired badge', + }) +``` + +## What Happens Next? + +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) + +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 + +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 retired badges like [#10371](https://github.com/badges/shields/pull/10371) and [#11075](https://github.com/badges/shields/pull/11075) diff --git a/doc/rewriting-services.md b/doc/rewriting-services.md deleted file mode 100644 index 217af55515ff7..0000000000000 --- a/doc/rewriting-services.md +++ /dev/null @@ -1,290 +0,0 @@ -**_WARNING: all legacy services have been rewritten, this document may contain outdated information._** - -# Tips for rewriting legacy services - -## Background - -The services are in the process of being rewritten to use our new service -framework ([#1358](https://github.com/badges/shields/issues/1358)). -Meanwhile, the legacy services extend from an abstract -adapter called [LegacyService][] which provides a place to put the -`camp.route()` invocation. The wrapper extends from [BaseService][], so it -supports badge examples via `category`, `examples`, and `route`. Setting `route` -also enables `createServiceTester()` to infer a service's base path, reducing -boilerplate for [creating the tester][creating a tester]. - -Legacy services look like: - -```js -module.exports = class ExampleService extends LegacyService { - static get category() { - return 'build' - } - - static registerLegacyRouteHandler({ camp, cache }) { - camp.route( - /^\/example\/([^\/]+)\/([^\/]+)\.(svg|png|gif|jpg|json)$/, - cache(function(data, match, sendBadge, request) { - var first = match[1] - var second = match[2] - var format = match[3] - var badgeData = getBadgeData('X' + first + 'X', data) - badgeData.text[1] = second - badgeData.colorscheme = 'blue' - badgeData.colorB = '#008bb8' - sendBadge(format, badgeData) - }) - ) - } -} -``` - -References: - -- Current documentation - - [Defining a route][] - - [Defining examples][] - - [Creating a tester][] -- [BaseService, the new service framework][baseservice] -- [LegacyService, the adapter][legacyservice] -- [Obsolete tutorial on legacy services][old tutorial], possibly useful as a reference - -[old tutorial]: https://github.com/badges/shields/blob/e25e748a03d4cbb50c60b69d2b2404fc08e7cead/doc/TUTORIAL.md -[defining a route]: https://github.com/badges/shields/blob/master/doc/TUTORIAL.md#42-our-first-badge -[defining examples]: https://github.com/badges/shields/blob/master/doc/TUTORIAL.md#44-adding-an-example-to-the-front-page -[creating a tester]: https://github.com/badges/shields/blob/master/doc/service-tests.md#1-boilerplate -[baseservice]: https://github.com/badges/shields/blob/master/services/base.js -[legacyservice]: https://github.com/badges/shields/blob/master/services/legacy-service.js - -## First, write some tests - -If service tests don’t exist for the legacy service, stop and write them first. -It’s recommended to PR these separately. If there’s some test coverage, it’s -probably fine to move right ahead and add more in the process. Make sure the -tests are passing, though. - -## Organization - -1. When there’s a single legacy service that handles lots of different things - (e.g. version, license, and downloads), it should be split into three separate - service classes and placed in three separate files, e.g.: - -- `example-version.service.js` -- `example-license.service.js` -- `example-downloads.service.js` - -2. When a badge offers different variants of basically the same thing, it’s okay - to put them in the same service class. For example, daily/weekly/monthly/total - downloads can go in one badge, and star rating vs point rating vs rating count - can go in one badge, and same with various kinds of detail about a pull request. - The hard limit (as of now anyway) is _one category per service class_. - -3. If the tests haven’t been split up, split them up too and make sure they - still pass. - -## Get the route working - -1. Disable the legacy service by adding a `return` at the top of - `registerLegacyRouteHandler()`. - -2. Set up the route for one of the badges. First determine if you can express - the route using a `pattern`. A `pattern` (e.g. `pattern: ':param1/:param2'`) is - the simplest way to declare the route, also the most readable, and will be - useful for displaying a badge builder with fields in the front end and - generating badge URLs programmatically. - -3. When creating the initial route, you can stub out the service. A minimal - service extends BaseJsonService (or BaseService, or one of the others), and - defines `route()` and `handle()`. `defaultBadgeData` is optional but suggested: - -```js -const BaseJsonService = require('../base-json') - -class ExampleDownloads extends BaseJsonService { - static get route() { - return { - base: 'example/d', - pattern: ':param1/:param2', - } - } - - static defaultBadgeData() { - return { label: 'downloads' } // or whatever - } - - async handle({ param1, param2 }) { - return { message: 'hello' } - } -} -``` - -4. We don’t have really good tools for debugging matches, so the best you can do - is run a subset of your tests. To run a single service test, add `.only()` - somewhere in the chain, and run `npm run test:services:trace -- --only=example`. - -```js -t.create('build status') - .get('/pip.json') - .only() // Prevent this ServiceTester from running its other tests. - .expectBadge( - label: 'docs', - message: Joi.alternatives().try(isBuildStatus, Joi.equal('unknown')), - ) -``` - -5. Presumably the test will fail, though by examining the copious output, you - can confirm the route was matched and the named parameters mapped successfully. - Since you'll have just run the tests on the old code (right?) you'll know you - haven't inadvertently changed the route (an easy mistake to make). - -6. If the legacy service had a base URL and you've changed it, you’ll need to - update the tests _and_ the examples. Take care to do that. - -## Implement `render()` and `handle()` - -Once the route is working, fill out `render()` and `handle()`. - -1. If there’s a single service, you can implement fetch as a method or a - function at the top of the file. If there are more than one service which share - fetching code, you can put the fetch function in `example-common.js` like this - one for github: - -
- -```js -const Joi = require('@hapi/joi') -const { errorMessagesFor } = require('./github-helpers') - -const issueSchema = Joi.object({ - head: Joi.object({ - sha: Joi.string().required(), - }).required(), -}).required() - -async function fetchIssue(serviceInstance, { user, repo, number }) { - return serviceInstance._requestJson({ - schema: issueSchema, - url: `/repos/${user}/${repo}/pulls/${number}`, - errorMessages: errorMessagesFor('pull request or repo not found'), - }) -} - -module.exports = { - fetchIssue, -} -``` - -
- -or create an abstract superclass like **PypiBase**: - -
- -```js -const Joi = require('@hapi/joi') -const BaseJsonService = require('../base-json') - -const schema = Joi.object({ - info: Joi.object({ - ... - }).required() -}).required() - -module.exports = class PypiBase extends BaseJsonService { - static buildRoute(base) { - return { - base, - pattern: ':egg*', - } - } - - async fetch({ egg }) { - return this._requestJson({ - schema, - url: `https://pypi.org/pypi/${egg}/json`, - errorMessages: { 404: 'package or version not found' }, - }) - } -} -``` - -
- -2. Validation should be handled using Joi. Save this for last. While you're - getting things working, you can use `const schema = Joi.any()`, which matches - anything. - -3. Substitution of default values should also be handled by Joi, using - `.default()`. - -4. To keep with the design pattern of `render()`, formatting concerns, including - concatenation and color computation, should be dealt with inside `render()`. - This helps avoid static examples falling out of sync with the implementation. - -## Error handling - -BaseService includes built-in runtime error handling. Error classes are defined -in `services/errors.js`. Request code and validation code will throw a runtime -error, which will then bubble up to BaseService, which then renders an error -badge. The cases covered by built-in error handling need not be tested in each -service, and existing tests should be removed. - -1. If an external server can't be reached or returns a 5xx status code, - `_requestJson()` along with code in `lib/error-helper.js` will bubble up an - **Inaccessible** error. - -2. If a response does not match the schema, `validate()` will bubble up an - **InvalidResponse** error which will display **invalid response data**. - -Error handling can also be customized by the service. Alternate messages -corresponding to HTTP status codes can be specified in the `errorMessages` -parameter to `_requestJson()` etc. - -For the not found case, a service test should establish that the API is doing -what we expect. If the API returns a 404 error, code in `lib/error-helper.js` -will automatically throw a **NotFound** error. The error message can, and -generally should be customized to display something more specific like -**package not found** or **room not found**. - -Not all services return a 404 response in the not found case. Sometimes a -different status code is returned. - -Sometimes a 200 response must be examined to distinguish the not found case from a success case. This can be handled in either of two ways: - -- Write a schema which accommodates both the success and error cases. -- Write the schema for the success case. Pass `schema: Joi.any()` to - `_requestJson()`. Manually check for the error case, then invoke - `_validate()` with the success-case schema. - -In either case, the service should throw e.g -`new NotFound({ prettyMessage: 'package not found' })`. - -## Convert the examples - -1. Convert all the examples to `pattern`, `namedParams`, and `staticExample`. In some cases you can use the `pattern` inherited from `route`, though in other cases you may need to specify a pattern in the example. For example, when showing download badges for several periods, you may want to render the example with an explicit `dt` instead of `:which`. You will also need to specify a pattern for badges that use a `format` regex in the route. - -2. Open the frontend and check that the static preview badges look good. - Remember, none of them are live. - -3. Open up the prepared example URLs in their own tabs, and make sure they work correctly. - -## Validation - -When it's time to add the schema, refer to the Joi API docs: -https://github.com/hapijs/joi/blob/master/API.md - -## Housekeeping - -Switch to `createServiceTester`: - -```js -const t = (module.exports = require('../tester').createServiceTester()) -``` - -This may require updating the URLs, which will be relative to the service's base -URL. When using `createServiceTester`, services need to be specified using -the non-case-sensitive service class name, or a leading substring (e.g. -`AppveyorTests` or `appveyor`). - -Do this last. Since it involves changing test URLs, and you don't want to -accidentally change them. diff --git a/doc/self-hosting.md b/doc/self-hosting.md index e45516b415ad6..cbd615f885da8 100644 --- a/doc/self-hosting.md +++ b/doc/self-hosting.md @@ -1,31 +1,33 @@ # Hosting your own Shields server -## Installation +This document describes how to host your own shields server either from source or using a docker image. See the docs on [releases](https://github.com/badges/shields/blob/master/doc/releases.md#shields-server) for info on how we version the server and how to choose a release. -You will need Node 10 or later, which you can install using a -[package manager][]. +## Installing from Source + +You will need Node 24, which you can install using a [package manager][]. On Ubuntu / Debian: ```sh -curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash -; sudo apt-get install -y nodejs +curl -sL https://deb.nodesource.com/setup_24.x | sudo -E bash -; sudo apt-get install -y nodejs ``` ```sh git clone https://github.com/badges/shields.git cd shields +git checkout $(git tag | grep server | tail -n 1) # checkout the latest tag npm ci # You may need sudo for this. ``` [package manager]: https://nodejs.org/en/download/package-manager/ -## Build the frontend +### Build the frontend ```sh npm run build ``` -## Start the server +### Start the server ```sh sudo node server @@ -44,7 +46,7 @@ The root gets redirected to https://shields.io. For testing purposes, you can go to `http://localhost/`. -## Heroku +### Deploying to Heroku Once you have installed the [Heroku CLI][] @@ -57,76 +59,82 @@ heroku open [heroku cli]: https://devcenter.heroku.com/articles/heroku-cli -## Docker +### Deploying to Zeit Vercel -You can build and run the server locally using Docker. First build an image: +To deploy using Zeit Vercel: ```console -$ docker build -t shields . -Sending build context to Docker daemon 3.923 MB -… -Successfully built 4471b442c220 +npm run build # Not sure why, but this needs to be run before deploying. +vercel ``` -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. +## Docker -Then run the container: +### Public Images -```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 +We publish images to: -> badge-maker@3.0.0 start /usr/src/app -> node server.js +- DockerHub at https://registry.hub.docker.com/r/shieldsio/shields and +- GitHub Container Registry at https://github.com/badges/shields/pkgs/container/shields -http://[::1]/ -``` +The `next` tag is the latest build from `master`. These are only available for linux/amd64 -Assuming Docker is running locally, you should be able to get to the -application at http://localhost:8080/. +```sh +# DockerHub +$ docker pull shieldsio/shields:next +$ docker run shieldsio/shields:next +``` -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. +```sh +# GHCR +$ docker pull ghcr.io/badges/shields:next +$ docker pull ghcr.io/badges/shields:next +``` -[shields.example.env]: ../shields.example.env +Tagged snapshot releases are also available: -## Raster server +- https://registry.hub.docker.com/r/shieldsio/shields/tags +- https://github.com/badges/shields/pkgs/container/shields/versions?filters%5Bversion_type%5D=tagged -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. +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. -- 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. +### Building Docker Image Locally -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. +Alternatively, you can build and run the server locally using Docker. First build an image: -[raster server]: https://github.com/badges/svg-to-image-proxy -[micro]: https://github.com/zeit/micro +```console +$ docker build -t shields . +Sending build context to Docker daemon 3.923 MB +… +Successfully built 4471b442c220 +``` -## Zeit Now +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`). -To deploy using Zeit Now: +Then run the container, and be sure to specify the same mapped port as the one Shields is listening on : ```console -npm run build # Not sure why, but this needs to be run before deploying. -now +$ docker run --rm -p 8080:8080 --env PORT=8080 --name shields shieldsio/shields:next + +Configuration: +... +0916211515 Server is starting up: http://0.0.0.0:8080/ ``` -## Persistence +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. + +## 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 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. + +If anyone has set this up, more documentation on how to do this would be welcome! -To enable Redis-backed GitHub token persistence, point `REDIS_URL` to your -Redis installation. +[raster server]: https://github.com/badges/squint ## Server secrets @@ -136,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/ @@ -184,19 +181,27 @@ Start the server using the Sentry DSN. You can set it: sudo SENTRY_DSN=https://xxx:yyy@sentry.io/zzz node server ``` -- or by `sentry_dsn` secret property defined in `private/secret.json` +Or via config as you would do with [server secrets](server-secrets.md): +```yml +private: + sentry_dsn: ... ``` + +```sh sudo node server ``` -### Prometheus +## Prometheus -Shields uses [prom-client](https://github.com/siimon/prom-client) to provide [default metrics](https://prometheus.io/docs/instrumenting/writing_clientlibs/#standard-and-runtime-collectors). These metrics are disabled by default. -You can enable them by `METRICS_PROMETHEUS_ENABLED` environment variable. +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 npm start +METRICS_PROMETHEUS_ENABLED=true METRICS_PROMETHEUS_ENDPOINT_ENABLED=true npm start ``` Metrics are available at `/metrics` resource. + +## Cloudflare + +Shields.io uses Cloudflare as a downstream CDN. If your installation does the same, you can configure your server to only accept requests coming from Cloudflare's IPs. Set `public.requireCloudflare: true`. diff --git a/doc/server-secrets.md b/doc/server-secrets.md index 1f75a12d027de..9b3ddf4f670b5 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 @@ -80,21 +61,12 @@ An Azure DevOps Token (PAT) is required for accessing [private Azure DevOps proj [ado personal access tokens]: https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=vsts#create-personal-access-tokens-to-authenticate-access [ado token scopes]: https://docs.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/oauth?view=vsts#scopes -### Bintray - -- `BINTRAY_USER` (yml: `private.bintray_user`) -- `BINTRAY_API_KEY` (yml: `private.bintray_apikey`) - -The bintray API [requires authentication](https://bintray.com/docs/api/#_authentication) -Create an account and obtain a token from the user profile page. - ### Bitbucket (Cloud) - `BITBUCKET_USER` (yml: `private.bitbucket_username`) - `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 @@ -102,16 +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 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. + +### DockerHub + +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/ @@ -120,31 +117,42 @@ 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 + +- `GITLAB_ORIGINS` (yml: `public.services.gitlab.authorizedOrigins`) +- `GITLAB_TOKEN` (yml: `private.gitlab_token`) + +A GitLab [Personal Access Token][gitlab-pat] is required for accessing private content. If you need a GitLab token for your self-hosted Shields server then we recommend limiting the scopes to the minimal set necessary for the badges you are using. + +[gitlab-pat]: https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html ### Jenkins CI @@ -152,8 +160,7 @@ but will not be necessary for most self-hosted installations. See - `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 @@ -161,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 @@ -170,36 +194,82 @@ 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 + +- `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). + +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`. + +### Read the Docs + +- `READTHEDOCS_TOKEN` (yml: `private.readthedocs_token`) + +Authentication is optional, but a [Read the Docs API token][readthedocs api authentication] increases the API rate limit for Read the Docs badges. + +Use a token from a dedicated account that has access only to public projects. Badge endpoints are public, so a token whose account can access private projects could allow badge requests to reveal those projects' build status. + +[readthedocs api authentication]: https://docs.readthedocs.com/platform/stable/api/v3.html#authentication-and-authorization + +### 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 @@ -207,33 +277,40 @@ 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. + +### Weblate + +- `WEBLATE_ORIGINS` (yml: `public.services.weblate.authorizedOrigins`) +- `WEBLATE_API_KEY` (yml: `private.weblate_api_key`) + +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]. -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 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 +### YouTube -- `WHEELMAP_TOKEN` (yml: `private.wheelmap_token`) +- `YOUTUBE_API_KEY` (yml: `private.youtube_api_key`) -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. +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. -[wheelmap token]: http://classic.wheelmap.org/en/users/sign_in +[youtube credentials]: https://console.developers.google.com/apis/credentials ## Error reporting - `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 3c13dec9f8182..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,90 +17,79 @@ 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: ```js -'use strict' +import { createServiceTester } from '../tester.js' -const t = (module.exports = require('../tester').createServiceTester()) +export const t = await createServiceTester() ``` -If our `.service.js` module exports a single class, we can -`require('../tester').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 -const { isBuildStatus } = require('../test-validators') +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 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 [icedfrisby api]: https://github.com/MarkHerhold/IcedFrisby/blob/master/API.md [joi]: https://github.com/hapijs/joi [joi api]: https://github.com/hapijs/joi/blob/master/API.md +[regexp]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp ### (3) Running the Tests 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) ``` @@ -114,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 @@ -253,37 +207,23 @@ 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 ``` ## Pull requests -The affected service ids should be included in square brackets in the pull request -title. That way, Circle CI will run those service tests. When a pull request -affects multiple services, they should be separated with spaces. The test -runner is case-insensitive, so they should be capitalized for readability. - -For example: - -- [Travis] Fix timeout issues -- [Travis Sonar] Support user token authentication -- Add tests for [CRAN] and [CPAN] +Pull requests must follow the [documented conventions][pr-conventions] in order to execute the correct set of service tests. -In the rare case when it's necessary to see the output of a full service-test -run in a PR, include `[*****]` in the title. Unless all the tests pass, the build -will fail, so likely it will be necessary to remove it and re-run the tests -before merging. +[pr-conventions]: https://github.com/badges/shields/blob/master/CONTRIBUTING.md#running-service-tests-in-pull-requests ## 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/doc/users.md b/doc/users.md deleted file mode 100644 index 99946ee75a62d..0000000000000 --- a/doc/users.md +++ /dev/null @@ -1,50 +0,0 @@ -# Notable Projects Using Shields - -- https://github.com/AFNetworking/AFNetworking -- https://github.com/angular/angular.js -- https://github.com/ansible/ansible -- https://github.com/apple/swift -- https://github.com/atom/atom -- https://github.com/babel/babel -- https://github.com/bevacqua/dragula -- https://github.com/bower/bower -- https://github.com/chartjs/Chart.js -- https://github.com/creationix/nvm -- https://github.com/discourse/discourse -- https://github.com/docker/docker -- https://github.com/electron/electron -- https://github.com/elm-lang/core -- https://github.com/emberjs/ember.js -- https://github.com/expressjs/express -- https://github.com/facebook/react -- https://github.com/FortAwesome/Font-Awesome -- https://github.com/gitlabhq/gitlabhq -- https://github.com/gulpjs/gulp -- https://github.com/h5bp/html5-boilerplate -- https://github.com/jakubroztocil/httpie -- https://github.com/jekyll/jekyll -- https://github.com/kennethreitz/requests -- https://github.com/kubernetes/kubernetes -- https://github.com/laravel/laravel -- https://github.com/less/less.js -- https://github.com/Microsoft/TypeScript -- https://github.com/Microsoft/vscode -- https://github.com/mitchellh/vagrant -- https://github.com/Modernizr/Modernizr -- https://github.com/moment/moment -- https://github.com/mrdoob/three.js -- https://github.com/necolas/normalize.css -- https://github.com/nodejs/node -- https://github.com/plataformatec/devise -- https://github.com/postcss/postcss -- https://github.com/rails/rails -- https://github.com/reactjs/redux -- https://github.com/socketio/socket.io -- https://github.com/tensorflow/tensorflow -- https://github.com/TryGhost/Ghost -- https://github.com/twbs/bootstrap -- https://github.com/videojs/video.js -- https://github.com/vuejs/vue -- https://github.com/webpack/webpack -- https://github.com/yarnpkg/yarn -- https://github.com/zurb/foundation-sites diff --git a/entrypoint.spec.js b/entrypoint.spec.js index 73b7362b73fb5..6246d70a91474 100644 --- a/entrypoint.spec.js +++ b/entrypoint.spec.js @@ -1,29 +1,25 @@ -'use strict' +import { expect } from 'chai' +import isSvg from 'is-svg' +import got from './core/got-test-client.js' -const { expect } = require('chai') -const isSvg = require('is-svg') -const got = require('./core/got-test-client') - -let server -before(function() { - this.timeout('5s') - // remove args comming from mocha +let serverModule +before(async function () { + this.timeout('30s') + // remove args coming from mocha // https://github.com/badges/shields/issues/3365 process.argv = [] - server = require('./server') + serverModule = await import('./server.js') }) -after('shut down the server', async function() { - await server.stop() +after('shut down the server', async function () { + await serverModule.server.stop() }) -it('should render a badge', 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') + 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 60e2a78db7cbd..0000000000000 --- a/frontend/components/common.tsx +++ /dev/null @@ -1,115 +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 -} - -export function Badge({ - src, - alt = '', - display = 'inline', - height = '20px', - clickable = false, - ...rest -}: BadgeProps): JSX.Element { - return ( - - {src ? {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 951f58c19f474..0000000000000 --- a/frontend/components/customizer/customizer.tsx +++ /dev/null @@ -1,158 +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' - -function getBaseUrlFromWindowLocation(): string { - // Default to the current hostname for when there is no `BASE_URL` set - // at build time (as in most PaaS deploys). - const { protocol, hostname } = window.location - return `${protocol}//${hostname}` -} - -export default function Customizer({ - baseUrl, - title, - 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< - CopiedContentIndicatorHandle - >() 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 || getBaseUrlFromWindowLocation()}${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 be406afdd1492..0000000000000 --- a/frontend/components/customizer/path-builder.tsx +++ /dev/null @@ -1,260 +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 - const path = tokens - .map(token => { - if (typeof token === 'string') { - return token.trim() - } else { - const { delimiter, name, optional } = token - const value = namedParams[name] - if (value) { - return `${delimiter}${value.trim()}` - } else if (optional) { - return '' - } else { - isComplete = false - return `${delimiter}:${name}` - } - } - }) - .join('') - 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 { delimiter, optional } = token - const name = `${token.name}` - - const exampleValue = exampleParams[name] || '(not set)' - - return ( - - {renderLiteral(delimiter, 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 cde85ae7bce98..0000000000000 --- a/frontend/components/customizer/query-string-builder.tsx +++ /dev/null @@ -1,336 +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).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