From 53c5cfa94dea053e412a1730e282f92a7b77548d Mon Sep 17 00:00:00 2001
From: chris48s <chris48s@users.noreply.github.com>
Date: Sun, 4 Dec 2022 10:53:59 +0000
Subject: [PATCH] allow passing key to [stackexchange] (#8539)

* refactoring groundwork

* add stackapps_api_key setting

* add test for stackexchange auth

* clarify docs

Co-authored-by: repo-ranger[bot] <39074581+repo-ranger[bot]@users.noreply.github.com>
---
 config/custom-environment-variables.yml       |  1 +
 core/server/server.js                         |  1 +
 doc/server-secrets.md                         | 11 ++++++
 services/stackexchange/stackexchange-base.js  | 37 ++++++++++++++++++
 .../stackexchange/stackexchange-base.spec.js  | 38 +++++++++++++++++++
 .../stackexchange/stackexchange-helpers.js    | 16 --------
 .../stackexchange-monthlyquestions.service.js | 16 +++-----
 .../stackexchange-reputation.service.js       | 12 ++----
 .../stackexchange-taginfo.service.js          | 16 +++-----
 9 files changed, 103 insertions(+), 45 deletions(-)
 create mode 100644 services/stackexchange/stackexchange-base.js
 create mode 100644 services/stackexchange/stackexchange-base.spec.js
 delete mode 100644 services/stackexchange/stackexchange-helpers.js

diff --git a/config/custom-environment-variables.yml b/config/custom-environment-variables.yml
index a78e9d3d93..d4c64c7bb4 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 28aae21c66..25cb3f9ddc 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 b37d5373ad..1270e9079e 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 0000000000..e6a93889e9
--- /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 0000000000..c61d3ccace
--- /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 e86c4064d0..0000000000
--- 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 1c24b8d233..f77545fa2b 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 adda5b0cc6..db2e3df40d 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 60a202d8fa..1aa74e50ae 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}`,
-- 
GitLab