From 96e9e130def44e5db6ba5c99d9250bcc4d205b41 Mon Sep 17 00:00:00 2001 From: Sandro Marques Date: Sun, 13 Aug 2023 19:00:40 +0100 Subject: [PATCH] Add [CurseForge] badges (#9252) * add curseforge downloads badge * Add more [CurseForge] badges Adds the following badges: - /curseforge/dt/:projectId (downloads) - /curseforge/game-versions/:projectId (game versions) - /curseforge/v/:projectId (version) The following secret: - CURSEFORGE_API_KEY (yml: private.curseforge_api_key) * Remove default logo from badges * Linter fixes * Rename `errorMessages` to `httpErrors` * Remove namedLogo from ModrinthGameVersions badge * Remove namedLogo from ModrinthVersion badge * Remove namedLogo from ModrinthFollowers badge --------- Co-authored-by: Minecraftschurli Co-authored-by: Pierre-Yves Bigourdan <10694593+PyvesB@users.noreply.github.com> --- config/custom-environment-variables.yml | 1 + .../local-shields-io-production.template.yml | 1 + config/local.template.yml | 1 + core/base-service/auth-helper.js | 11 ++++ core/server/server.js | 1 + doc/server-secrets.md | 13 ++++ services/curseforge/curseforge-base.js | 61 +++++++++++++++++++ .../curseforge-downloads.service.js | 29 +++++++++ .../curseforge/curseforge-downloads.tester.js | 22 +++++++ .../curseforge-game-versions.service.js | 36 +++++++++++ .../curseforge-game-versions.tester.js | 22 +++++++ .../curseforge/curseforge-version.service.js | 31 ++++++++++ .../curseforge/curseforge-version.tester.js | 22 +++++++ 13 files changed, 251 insertions(+) create mode 100644 services/curseforge/curseforge-base.js create mode 100644 services/curseforge/curseforge-downloads.service.js create mode 100644 services/curseforge/curseforge-downloads.tester.js create mode 100644 services/curseforge/curseforge-game-versions.service.js create mode 100644 services/curseforge/curseforge-game-versions.tester.js create mode 100644 services/curseforge/curseforge-version.service.js create mode 100644 services/curseforge/curseforge-version.tester.js diff --git a/config/custom-environment-variables.yml b/config/custom-environment-variables.yml index da8e9ec860..0f35b0284e 100644 --- a/config/custom-environment-variables.yml +++ b/config/custom-environment-variables.yml @@ -77,6 +77,7 @@ private: bitbucket_password: 'BITBUCKET_PASS' bitbucket_server_username: 'BITBUCKET_SERVER_USER' bitbucket_server_password: 'BITBUCKET_SERVER_PASS' + curseforge_api_key: 'CURSEFORGE_API_KEY' discord_bot_token: 'DISCORD_BOT_TOKEN' drone_token: 'DRONE_TOKEN' gh_client_id: 'GH_CLIENT_ID' diff --git a/config/local-shields-io-production.template.yml b/config/local-shields-io-production.template.yml index 848039ad7a..6f2ad3b86b 100644 --- a/config/local-shields-io-production.template.yml +++ b/config/local-shields-io-production.template.yml @@ -1,5 +1,6 @@ private: # These are the keys which are set on the production servers. + curseforge_api_key: ... discord_bot_token: ... gh_client_id: ... gh_client_secret: ... diff --git a/config/local.template.yml b/config/local.template.yml index ae1c51c619..f5cc116578 100644 --- a/config/local.template.yml +++ b/config/local.template.yml @@ -4,6 +4,7 @@ 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: '...' diff --git a/core/base-service/auth-helper.js b/core/base-service/auth-helper.js index ccfe2722a0..069291b512 100644 --- a/core/base-service/auth-helper.js +++ b/core/base-service/auth-helper.js @@ -153,6 +153,11 @@ class AuthHelper { : undefined } + _apiKeyHeader(apiKeyHeader) { + const { _pass: pass } = this + return this.isConfigured ? { [apiKeyHeader]: pass } : undefined + } + static _mergeHeaders(requestParams, headers) { const { options: { headers: existingHeaders, ...restOptions } = {}, @@ -170,6 +175,12 @@ class AuthHelper { } } + withApiKeyHeader(requestParams, header = 'x-api-key') { + return this._withAnyAuth(requestParams, requestParams => + this.constructor._mergeHeaders(requestParams, this._apiKeyHeader(header)), + ) + } + withBearerAuthHeader( requestParams, bearerKey = 'Bearer', // lgtm [js/hardcoded-credentials] diff --git a/core/server/server.js b/core/server/server.js index 5bf38ec3df..81eaf8e6d8 100644 --- a/core/server/server.js +++ b/core/server/server.js @@ -162,6 +162,7 @@ const publicConfigSchema = Joi.object({ const privateConfigSchema = Joi.object({ azure_devops_token: Joi.string(), + curseforge_api_key: Joi.string(), discord_bot_token: Joi.string(), drone_token: Joi.string(), gh_client_id: Joi.string(), diff --git a/doc/server-secrets.md b/doc/server-secrets.md index ebecef355a..5e0e992792 100644 --- a/doc/server-secrets.md +++ b/doc/server-secrets.md @@ -97,6 +97,19 @@ self-hosted Shields installation access to private repositories hosted on bitbuc Bitbucket badges use basic auth. Provide a username and password to give your self-hosted Shields installation access to a private Bitbucket Server instance. +### CurseForge + +- `CURSEFORGE_API_KEY` (yml: `private.curseforge_api_key`) + +A CurseForge API key is required to use the [CurseForge API][cf api]. To obtain +an API key, [signup to CurseForge Console][cf signup] with a Google account and +create an organization, then go to the [API keys page][cf api key] and copy the +generated API key. + +[cf api]: https://docs.curseforge.com +[cf signup]: https://console.curseforge.com/#/signup +[cf api key]: https://console.curseforge.com/#/api-keys + ### Discord Using a token for Dicsord is optional but will allow higher API rates. diff --git a/services/curseforge/curseforge-base.js b/services/curseforge/curseforge-base.js new file mode 100644 index 0000000000..599afe5d77 --- /dev/null +++ b/services/curseforge/curseforge-base.js @@ -0,0 +1,61 @@ +import Joi from 'joi' +import { BaseJsonService } from '../index.js' +import { nonNegativeInteger } from '../validators.js' + +const schema = Joi.object({ + data: Joi.object({ + downloadCount: nonNegativeInteger, + latestFiles: Joi.array() + .items({ + displayName: Joi.string().required(), + gameVersions: Joi.array().items(Joi.string().required()).required(), + }) + .required(), + }).required(), +}).required() + +const documentation = ` +

+ The CurseForge badge requires the Project ID in order access the + CurseForge API. +

+

+ The Project ID is different from the URL slug and can be found in the 'About Project' section of your + CurseForge mod page. +

+The Project ID in the 'About Projection' section on CurseForge. +` + +export default class BaseCurseForgeService extends BaseJsonService { + static auth = { + passKey: 'curseforge_api_key', + authorizedOrigins: ['https://api.curseforge.com'], + isRequired: true, + } + + async fetchMod({ projectId }) { + // Documentation: https://docs.curseforge.com/#get-mod + const response = await this._requestJson( + this.authHelper.withApiKeyHeader({ + schema, + url: `https://api.curseforge.com/v1/mods/${projectId}`, + httpErrors: { + 403: 'invalid API key', + }, + }), + ) + + const latestFiles = response.data.latestFiles + const latestFile = + latestFiles.length > 0 ? latestFiles[latestFiles.length - 1] : {} + + return { + downloads: response.data.downloadCount, + version: latestFile?.displayName || 'N/A', + gameVersions: latestFile?.gameVersions || ['N/A'], + } + } +} + +export { BaseCurseForgeService, documentation } diff --git a/services/curseforge/curseforge-downloads.service.js b/services/curseforge/curseforge-downloads.service.js new file mode 100644 index 0000000000..7156214ae6 --- /dev/null +++ b/services/curseforge/curseforge-downloads.service.js @@ -0,0 +1,29 @@ +import { renderDownloadsBadge } from '../downloads.js' +import BaseCurseForgeService, { documentation } from './curseforge-base.js' + +export default class CurseForgeDownloads extends BaseCurseForgeService { + static category = 'downloads' + + static route = { + base: 'curseforge/dt', + pattern: ':projectId', + } + + static examples = [ + { + title: 'CurseForge Downloads', + namedParams: { + projectId: '238222', + }, + staticPreview: renderDownloadsBadge({ downloads: 234000000 }), + documentation, + }, + ] + + static defaultBadgeData = { label: 'downloads' } + + async handle({ projectId }) { + const { downloads } = await this.fetchMod({ projectId }) + return renderDownloadsBadge({ downloads }) + } +} diff --git a/services/curseforge/curseforge-downloads.tester.js b/services/curseforge/curseforge-downloads.tester.js new file mode 100644 index 0000000000..456c001859 --- /dev/null +++ b/services/curseforge/curseforge-downloads.tester.js @@ -0,0 +1,22 @@ +import { createServiceTester } from '../tester.js' +import { isMetric } from '../test-validators.js' +import { noToken } from '../test-helpers.js' +import CurseForgeDownloads from './curseforge-downloads.service.js' + +export const t = await createServiceTester() +const noApiKey = noToken(CurseForgeDownloads) + +t.create('Downloads') + .skipWhen(noApiKey) + .get('/238222.json') + .expectBadge({ label: 'downloads', message: isMetric }) + +t.create('Downloads (empty)') + .skipWhen(noApiKey) + .get('/872620.json') + .expectBadge({ label: 'downloads', message: '0' }) + +t.create('Downloads (not found)') + .skipWhen(noApiKey) + .get('/invalid-project-id.json') + .expectBadge({ label: 'downloads', message: 'not found', color: 'red' }) diff --git a/services/curseforge/curseforge-game-versions.service.js b/services/curseforge/curseforge-game-versions.service.js new file mode 100644 index 0000000000..47baa515f5 --- /dev/null +++ b/services/curseforge/curseforge-game-versions.service.js @@ -0,0 +1,36 @@ +import BaseCurseForgeService, { documentation } from './curseforge-base.js' + +export default class CurseForgeGameVersions extends BaseCurseForgeService { + static category = 'platform-support' + + static route = { + base: 'curseforge/game-versions', + pattern: ':projectId', + } + + static examples = [ + { + title: 'CurseForge Game Versions', + namedParams: { + projectId: '238222', + }, + staticPreview: this.render({ versions: ['1.20.0', '1.19.4'] }), + documentation, + }, + ] + + static defaultBadgeData = { label: 'game versions' } + + static render({ versions }) { + return { + message: versions.join(' | '), + color: 'blue', + } + } + + async handle({ projectId }) { + const { gameVersions } = await this.fetchMod({ projectId }) + const versions = gameVersions + return this.constructor.render({ versions }) + } +} diff --git a/services/curseforge/curseforge-game-versions.tester.js b/services/curseforge/curseforge-game-versions.tester.js new file mode 100644 index 0000000000..bfbca48b7c --- /dev/null +++ b/services/curseforge/curseforge-game-versions.tester.js @@ -0,0 +1,22 @@ +import { createServiceTester } from '../tester.js' +import { withRegex } from '../test-validators.js' +import { noToken } from '../test-helpers.js' +import CurseForgeGameVersions from './curseforge-game-versions.service.js' + +export const t = await createServiceTester() +const noApiKey = noToken(CurseForgeGameVersions) + +t.create('Game Versions') + .skipWhen(noApiKey) + .get('/238222.json') + .expectBadge({ label: 'game versions', message: withRegex(/.+( \| )?/) }) + +t.create('Game Versions (empty)') + .skipWhen(noApiKey) + .get('/872620.json') + .expectBadge({ label: 'game versions', message: 'N/A', color: 'blue' }) + +t.create('Game Versions (not found)') + .skipWhen(noApiKey) + .get('/invalid-project-id.json') + .expectBadge({ label: 'game versions', message: 'not found', color: 'red' }) diff --git a/services/curseforge/curseforge-version.service.js b/services/curseforge/curseforge-version.service.js new file mode 100644 index 0000000000..cdf032e0c0 --- /dev/null +++ b/services/curseforge/curseforge-version.service.js @@ -0,0 +1,31 @@ +import { renderVersionBadge } from '../version.js' +import BaseCurseForgeService, { documentation } from './curseforge-base.js' + +export default class CurseForgeVersion extends BaseCurseForgeService { + static category = 'version' + + static route = { + base: 'curseforge/v', + pattern: ':projectId', + } + + static examples = [ + { + title: 'CurseForge Version', + namedParams: { + projectId: '238222', + }, + staticPreview: renderVersionBadge({ + version: 'jei-1.20-forge-14.0.0.4.jar', + }), + documentation, + }, + ] + + static defaultBadgeData = { label: 'version' } + + async handle({ projectId }) { + const { version } = await this.fetchMod({ projectId }) + return renderVersionBadge({ version }) + } +} diff --git a/services/curseforge/curseforge-version.tester.js b/services/curseforge/curseforge-version.tester.js new file mode 100644 index 0000000000..9d8a99bd2f --- /dev/null +++ b/services/curseforge/curseforge-version.tester.js @@ -0,0 +1,22 @@ +import { createServiceTester } from '../tester.js' +import { withRegex } from '../test-validators.js' +import { noToken } from '../test-helpers.js' +import CurseForgeVersion from './curseforge-version.service.js' + +export const t = await createServiceTester() +const noApiKey = noToken(CurseForgeVersion) + +t.create('Version') + .skipWhen(noApiKey) + .get('/238222.json') + .expectBadge({ label: 'version', message: withRegex(/.+/) }) + +t.create('Version (empty)') + .skipWhen(noApiKey) + .get('/872620.json') + .expectBadge({ label: 'version', message: 'N/A', color: 'blue' }) + +t.create('Version (not found)') + .skipWhen(noApiKey) + .get('/invalid-project-id.json') + .expectBadge({ label: 'version', message: 'not found', color: 'red' })