Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add [CurseForge] badges #9252

Merged
merged 10 commits into from
Aug 13, 2023
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' })
Loading