diff --git a/config/custom-environment-variables.yml b/config/custom-environment-variables.yml index a78e9d3d93376038999eee451b49206bf7cfc273..d4c64c7bb40199b8a74ecaa2d2fcd03722654bc2 100644 --- a/config/custom-environment-variables.yml +++ b/config/custom-environment-variables.yml @@ -98,6 +98,7 @@ private: 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' diff --git a/core/server/server.js b/core/server/server.js index 28aae21c66774b71a6fa4451289754e298183381..25cb3f9ddc13ab9e845b535e0af515118e77b2a9 100644 --- a/core/server/server.js +++ b/core/server/server.js @@ -186,6 +186,7 @@ const privateConfigSchema = Joi.object({ 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(), diff --git a/doc/server-secrets.md b/doc/server-secrets.md index b37d5373ad2807bb3d223025b5fe68e6f82931c0..1270e9079eef6223513f747f6cec6104310bce1b 100644 --- a/doc/server-secrets.md +++ b/doc/server-secrets.md @@ -244,6 +244,17 @@ Create an account, sign in and obtain a uuid and token from your 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 performa full OAuth Flow to gain an access token. + ### TeamCity - `TEAMCITY_ORIGINS` (yml: `public.services.teamcity.authorizedOrigins`) diff --git a/services/stackexchange/stackexchange-base.js b/services/stackexchange/stackexchange-base.js new file mode 100644 index 0000000000000000000000000000000000000000..e6a93889e9047223a4cd9e513c79905478349122 --- /dev/null +++ b/services/stackexchange/stackexchange-base.js @@ -0,0 +1,37 @@ +import { BaseJsonService } from '../index.js' +import { metric } from '../text-formatters.js' +import { floorCount as floorCountColor } from '../color-formatters.js' + +export function renderQuestionsBadge({ + suffix, + stackexchangesite, + query, + numValue, +}) { + const label = `${stackexchangesite} ${query} questions` + return { + label, + message: `${metric(numValue)}${suffix}`, + color: floorCountColor(numValue, 1000, 10000, 20000), + } +} + +export class StackExchangeBase extends BaseJsonService { + static category = 'chat' + + static auth = { + passKey: 'stackapps_api_key', + authorizedOrigins: ['https://api.stackexchange.com'], + isRequired: false, + } + + static defaultBadgeData = { + label: 'stackoverflow', + } + + async fetch(params) { + return this._requestJson( + this.authHelper.withQueryStringAuth({ passKey: 'key' }, params) + ) + } +} diff --git a/services/stackexchange/stackexchange-base.spec.js b/services/stackexchange/stackexchange-base.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..c61d3ccace9772926c57d99344767116eed18479 --- /dev/null +++ b/services/stackexchange/stackexchange-base.spec.js @@ -0,0 +1,38 @@ +import Joi from 'joi' +import { expect } from 'chai' +import nock from 'nock' +import { cleanUpNockAfterEach, defaultContext } from '../test-helpers.js' +import { StackExchangeBase } from './stackexchange-base.js' + +class DummyStackExchangeService extends StackExchangeBase { + static route = { base: 'fake-base' } + + async handle() { + const data = await this.fetch({ + schema: Joi.any(), + url: 'https://api.stackexchange.com/2.2/tags/python/info', + }) + return { message: data.message } + } +} + +describe('StackExchangeBase', function () { + describe('auth', function () { + cleanUpNockAfterEach() + + const config = { private: { stackapps_api_key: 'fake-key' } } + + it('sends the auth information as configured', async function () { + const scope = nock('https://api.stackexchange.com') + .get('/2.2/tags/python/info') + .query({ key: 'fake-key' }) + .reply(200, { message: 'fake message' }) + + expect( + await DummyStackExchangeService.invoke(defaultContext, config, {}) + ).to.deep.equal({ message: 'fake message' }) + + scope.done() + }) + }) +}) diff --git a/services/stackexchange/stackexchange-helpers.js b/services/stackexchange/stackexchange-helpers.js deleted file mode 100644 index e86c4064d0160dc46ca835acb80a01e327bfcbff..0000000000000000000000000000000000000000 --- a/services/stackexchange/stackexchange-helpers.js +++ /dev/null @@ -1,16 +0,0 @@ -import { metric } from '../text-formatters.js' -import { floorCount as floorCountColor } from '../color-formatters.js' - -export default function renderQuestionsBadge({ - suffix, - stackexchangesite, - query, - numValue, -}) { - const label = `${stackexchangesite} ${query} questions` - return { - label, - message: `${metric(numValue)}${suffix}`, - color: floorCountColor(numValue, 1000, 10000, 20000), - } -} diff --git a/services/stackexchange/stackexchange-monthlyquestions.service.js b/services/stackexchange/stackexchange-monthlyquestions.service.js index 1c24b8d233049796488612ca31bcbd2b9a6f1d78..f77545fa2b642b30adf1441f962b0a140e2c8e7e 100644 --- a/services/stackexchange/stackexchange-monthlyquestions.service.js +++ b/services/stackexchange/stackexchange-monthlyquestions.service.js @@ -1,16 +1,16 @@ import dayjs from 'dayjs' import Joi from 'joi' import { nonNegativeInteger } from '../validators.js' -import { BaseJsonService } from '../index.js' -import renderQuestionsBadge from './stackexchange-helpers.js' +import { + renderQuestionsBadge, + StackExchangeBase, +} from './stackexchange-base.js' const tagSchema = Joi.object({ total: nonNegativeInteger, }).required() -export default class StackExchangeMonthlyQuestions extends BaseJsonService { - static category = 'chat' - +export default class StackExchangeMonthlyQuestions extends StackExchangeBase { static route = { base: 'stackexchange', pattern: ':stackexchangesite/qm/:query', @@ -29,10 +29,6 @@ export default class StackExchangeMonthlyQuestions extends BaseJsonService { }, ] - static defaultBadgeData = { - label: 'stackoverflow', - } - static render(props) { return renderQuestionsBadge({ suffix: '/month', @@ -51,7 +47,7 @@ export default class StackExchangeMonthlyQuestions extends BaseJsonService { .endOf('month') .unix() - const parsedData = await this._requestJson({ + const parsedData = await this.fetch({ schema: tagSchema, options: { decompress: true, diff --git a/services/stackexchange/stackexchange-reputation.service.js b/services/stackexchange/stackexchange-reputation.service.js index adda5b0cc632aaaf1c01feb0fdc4e8db443c7b6f..db2e3df40d6c18fd4489704addd1db9b0d0a1300 100644 --- a/services/stackexchange/stackexchange-reputation.service.js +++ b/services/stackexchange/stackexchange-reputation.service.js @@ -1,7 +1,7 @@ import Joi from 'joi' import { metric } from '../text-formatters.js' import { floorCount as floorCountColor } from '../color-formatters.js' -import { BaseJsonService } from '../index.js' +import { StackExchangeBase } from './stackexchange-base.js' const reputationSchema = Joi.object({ items: Joi.array() @@ -14,9 +14,7 @@ const reputationSchema = Joi.object({ .required(), }).required() -export default class StackExchangeReputation extends BaseJsonService { - static category = 'chat' - +export default class StackExchangeReputation extends StackExchangeBase { static route = { base: 'stackexchange', pattern: ':stackexchangesite/r/:query', @@ -34,10 +32,6 @@ export default class StackExchangeReputation extends BaseJsonService { }, ] - static defaultBadgeData = { - label: 'stackoverflow', - } - static render({ stackexchangesite, numValue }) { const label = `${stackexchangesite} reputation` @@ -51,7 +45,7 @@ export default class StackExchangeReputation extends BaseJsonService { async handle({ stackexchangesite, query }) { const path = `users/${query}` - const parsedData = await this._requestJson({ + const parsedData = await this.fetch({ schema: reputationSchema, options: { decompress: true, searchParams: { site: stackexchangesite } }, url: `https://api.stackexchange.com/2.2/${path}`, diff --git a/services/stackexchange/stackexchange-taginfo.service.js b/services/stackexchange/stackexchange-taginfo.service.js index 60a202d8fa36ce492283a93f65b11f275aa8d1dd..1aa74e50ae3573e51880cc294c88b9afb89bc223 100644 --- a/services/stackexchange/stackexchange-taginfo.service.js +++ b/services/stackexchange/stackexchange-taginfo.service.js @@ -1,6 +1,8 @@ import Joi from 'joi' -import { BaseJsonService } from '../index.js' -import renderQuestionsBadge from './stackexchange-helpers.js' +import { + renderQuestionsBadge, + StackExchangeBase, +} from './stackexchange-base.js' const tagSchema = Joi.object({ items: Joi.array() @@ -13,9 +15,7 @@ const tagSchema = Joi.object({ .required(), }).required() -export default class StackExchangeQuestions extends BaseJsonService { - static category = 'chat' - +export default class StackExchangeQuestions extends StackExchangeBase { static route = { base: 'stackexchange', pattern: ':stackexchangesite/t/:query', @@ -34,10 +34,6 @@ export default class StackExchangeQuestions extends BaseJsonService { }, ] - static defaultBadgeData = { - label: 'stackoverflow', - } - static render(props) { return renderQuestionsBadge({ suffix: '', @@ -48,7 +44,7 @@ export default class StackExchangeQuestions extends BaseJsonService { async handle({ stackexchangesite, query }) { const path = `tags/${query}/info` - const parsedData = await this._requestJson({ + const parsedData = await this.fetch({ schema: tagSchema, options: { decompress: true, searchParams: { site: stackexchangesite } }, url: `https://api.stackexchange.com/2.2/${path}`,