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
2 changes: 2 additions & 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 All @@ -13,3 +14,4 @@ private:
weblate_api_key: '...'
wheelmap_token: '...'
youtube_api_key: '...'
curseforge_api_token: '...'
PyvesB marked this conversation as resolved.
Show resolved Hide resolved
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}`,
errorMessages: {
PyvesB marked this conversation as resolved.
Show resolved Hide resolved
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', namedLogo: 'curseforge' }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As per our guidelines, logos should not be included by default for non social style badges. Could you please remove the logo for all new badges?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There were a few leftovers, I went ahead and removed them myself to avoid another review cycle :)


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', namedLogo: 'curseforge' }

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', namedLogo: 'curseforge' }

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' })
5 changes: 4 additions & 1 deletion services/modrinth/modrinth-downloads.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ export default class ModrinthDownloads extends BaseModrinthService {
},
]

static defaultBadgeData = { label: 'downloads' }
static defaultBadgeData = {
label: 'downloads',
namedLogo: 'modrinth',
PyvesB marked this conversation as resolved.
Show resolved Hide resolved
}

async handle({ projectId }) {
const { downloads } = await this.fetchProject({ projectId })
Expand Down
5 changes: 4 additions & 1 deletion services/modrinth/modrinth-followers.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ export default class ModrinthFollowers extends BaseModrinthService {
},
]

static defaultBadgeData = { label: 'followers' }
static defaultBadgeData = {
label: 'followers',
namedLogo: 'modrinth',
}

static render({ followers }) {
return {
Expand Down
5 changes: 4 additions & 1 deletion services/modrinth/modrinth-game-versions.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ export default class ModrinthGameVersions extends BaseModrinthService {
},
]

static defaultBadgeData = { label: 'game versions' }
static defaultBadgeData = {
label: 'game versions',
namedLogo: 'modrinth',
}

static render({ versions }) {
return {
Expand Down
5 changes: 4 additions & 1 deletion services/modrinth/modrinth-version.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ export default class ModrinthVersion extends BaseModrinthService {
},
]

static defaultBadgeData = { label: 'version' }
static defaultBadgeData = {
label: 'version',
namedLogo: 'modrinth',
}

async handle({ projectId }) {
const { 0: latest } = await this.fetchVersions({ projectId })
Expand Down
Loading