Skip to content

Commit

Permalink
Add [CurseForge] badges (#9252)
Browse files Browse the repository at this point in the history
* 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 <minecraftschurli@gmail.com>
Co-authored-by: Pierre-Yves Bigourdan <10694593+PyvesB@users.noreply.github.com>
  • Loading branch information
3 people authored Aug 13, 2023
1 parent c7810a7 commit 96e9e13
Show file tree
Hide file tree
Showing 13 changed files with 251 additions and 0 deletions.
1 change: 1 addition & 0 deletions config/custom-environment-variables.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions config/local-shields-io-production.template.yml
Original file line number Diff line number Diff line change
@@ -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: ...
Expand Down
1 change: 1 addition & 0 deletions config/local.template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: '...'
Expand Down
11 changes: 11 additions & 0 deletions core/base-service/auth-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } = {},
Expand All @@ -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]
Expand Down
1 change: 1 addition & 0 deletions core/server/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
13 changes: 13 additions & 0 deletions doc/server-secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
61 changes: 61 additions & 0 deletions services/curseforge/curseforge-base.js
Original file line number Diff line number Diff line change
@@ -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 = `
<p>
The CurseForge badge requires the <code>Project ID</code> in order access the
<a href="https://docs.curseforge.com/#get-mod" target="_blank">CurseForge API</a>.
</p>
<p>
The <code>Project ID</code> is different from the URL slug and can be found in the 'About Project' section of your
CurseForge mod page.
</p>
<img src="https://github.com/badges/shields/assets/1098773/0d45b5fa-2cde-415d-8152-b84c535a1535"
alt="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 }
29 changes: 29 additions & 0 deletions services/curseforge/curseforge-downloads.service.js
Original file line number Diff line number Diff line change
@@ -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 })
}
}
22 changes: 22 additions & 0 deletions services/curseforge/curseforge-downloads.tester.js
Original file line number Diff line number Diff line change
@@ -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' })
36 changes: 36 additions & 0 deletions services/curseforge/curseforge-game-versions.service.js
Original file line number Diff line number Diff line change
@@ -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 })
}
}
22 changes: 22 additions & 0 deletions services/curseforge/curseforge-game-versions.tester.js
Original file line number Diff line number Diff line change
@@ -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' })
31 changes: 31 additions & 0 deletions services/curseforge/curseforge-version.service.js
Original file line number Diff line number Diff line change
@@ -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 })
}
}
22 changes: 22 additions & 0 deletions services/curseforge/curseforge-version.tester.js
Original file line number Diff line number Diff line change
@@ -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' })

0 comments on commit 96e9e13

Please sign in to comment.