diff --git a/config/custom-environment-variables.yml b/config/custom-environment-variables.yml index d84ba4f948d81..c8d370c5dc73d 100644 --- a/config/custom-environment-variables.yml +++ b/config/custom-environment-variables.yml @@ -40,6 +40,8 @@ public: debug: enabled: 'GITHUB_DEBUG_ENABLED' intervalSeconds: 'GITHUB_DEBUG_INTERVAL_SECONDS' + gitlab: + authorizedOrigins: 'GITLAB_ORIGINS' jenkins: authorizedOrigins: 'JENKINS_ORIGINS' jira: diff --git a/config/local-shields-io-production.template.yml b/config/local-shields-io-production.template.yml index f127e05fcfb0e..efb55146282ae 100644 --- a/config/local-shields-io-production.template.yml +++ b/config/local-shields-io-production.template.yml @@ -3,6 +3,7 @@ private: discord_bot_token: ... gh_client_id: ... gh_client_secret: ... + gitlab_token: ... redis_url: ... sentry_dsn: ... shields_secret: ... diff --git a/config/local.template.yml b/config/local.template.yml index 0ceadf8f53247..af36abf238756 100644 --- a/config/local.template.yml +++ b/config/local.template.yml @@ -5,6 +5,7 @@ private: # you can also set these values through environment variables, which may be # preferable for self hosting. gh_token: '...' + gitlab_token: '...' twitch_client_id: '...' twitch_client_secret: '...' weblate_api_key: '...' diff --git a/core/server/server.js b/core/server/server.js index f63408b256412..9a4cacf7b938d 100644 --- a/core/server/server.js +++ b/core/server/server.js @@ -125,6 +125,7 @@ const publicConfigSchema = Joi.object({ intervalSeconds: Joi.number().integer().min(1).required(), }, }, + gitlab: defaultService, jira: defaultService, jenkins: Joi.object({ authorizedOrigins: origins, @@ -161,6 +162,7 @@ const privateConfigSchema = Joi.object({ gh_client_id: Joi.string(), gh_client_secret: Joi.string(), gh_token: Joi.string(), + gitlab_token: Joi.string(), jenkins_user: Joi.string(), jenkins_pass: Joi.string(), jira_user: Joi.string(), diff --git a/doc/production-hosting.md b/doc/production-hosting.md index a1b2fd0399477..d7f6f9e2ad8cc 100644 --- a/doc/production-hosting.md +++ b/doc/production-hosting.md @@ -32,6 +32,8 @@ Production hosting is managed by the Shields ops team: | Twitch | OAuth app | @PyvesB | | Discord | OAuth app | @PyvesB | | YouTube | Account owner | @PyvesB | +| GitLab | Account owner | @calebcartwright | +| GitLab | Account access | @calebcartwright, @chris48s, @paulmelnikow, @PyvesB | | OpenStreetMap (for Wheelmap) | Account owner | @paulmelnikow | | DNS | Account owner | @olivierlacan | | DNS | Read-only account access | @espadrine, @paulmelnikow, @chris48s | diff --git a/doc/server-secrets.md b/doc/server-secrets.md index 9d3422fa5d2bf..9f089ef0e4714 100644 --- a/doc/server-secrets.md +++ b/doc/server-secrets.md @@ -147,6 +147,15 @@ These settings are used by shields.io for GitHub OAuth app authorization but will not be necessary for most self-hosted installations. See [production-hosting.md](./production-hosting.md). +### GitLab + +- `GITLAB_ORIGINS` (yml: `public.services.gitlab.authorizedOrigins`) +- `GITLAB_TOKEN` (yml: `private.gitlab_token`) + +A GitLab [Personal Access Token][gitlab-pat] is required for accessing private content. If you need a GitLab token for your self-hosted Shields server then we recommend limiting the scopes to the minimal set necessary for the badges you are using. + +[gitlab-pat]: https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html + ### Jenkins CI - `JENKINS_ORIGINS` (yml: `public.services.jenkins.authorizedOrigins`) diff --git a/services/gitlab/gitlab-base.js b/services/gitlab/gitlab-base.js new file mode 100644 index 0000000000000..4ddf232a2ae75 --- /dev/null +++ b/services/gitlab/gitlab-base.js @@ -0,0 +1,19 @@ +import { BaseJsonService } from '../index.js' + +export default class GitLabBase extends BaseJsonService { + static auth = { + passKey: 'gitlab_token', + serviceKey: 'gitlab', + } + + async fetch({ url, options, schema, errorMessages }) { + return this._requestJson( + this.authHelper.withBasicAuth({ + schema, + url, + options, + errorMessages, + }) + ) + } +} diff --git a/services/gitlab/gitlab-tag.service.js b/services/gitlab/gitlab-tag.service.js new file mode 100644 index 0000000000000..26e63b31f44c5 --- /dev/null +++ b/services/gitlab/gitlab-tag.service.js @@ -0,0 +1,131 @@ +import Joi from 'joi' +import { version as versionColor } from '../color-formatters.js' +import { optionalUrl } from '../validators.js' +import { latest } from '../version.js' +import { addv } from '../text-formatters.js' +import { NotFound } from '../index.js' +import GitLabBase from './gitlab-base.js' + +const schema = Joi.array().items( + Joi.object({ + name: Joi.string().required(), + }) +) + +const queryParamSchema = Joi.object({ + gitlab_url: optionalUrl, + include_prereleases: Joi.equal(''), + sort: Joi.string().valid('date', 'semver').default('date'), +}).required() + +export default class GitlabTag extends GitLabBase { + static category = 'version' + + static route = { + base: 'gitlab/v/tag', + pattern: ':user/:repo', + queryParamSchema, + } + + static examples = [ + { + title: 'GitLab tag (latest by date)', + namedParams: { + user: 'shields-ops-group', + repo: 'tag-test', + }, + queryParams: { sort: 'date' }, + staticPreview: this.render({ version: 'v2.0.0' }), + }, + { + title: 'GitLab tag (latest by SemVer)', + namedParams: { + user: 'shields-ops-group', + repo: 'tag-test', + }, + queryParams: { sort: 'semver' }, + staticPreview: this.render({ version: 'v4.0.0' }), + }, + { + title: 'GitLab tag (latest by SemVer pre-release)', + namedParams: { + user: 'shields-ops-group', + repo: 'tag-test', + }, + queryParams: { + sort: 'semver', + include_prereleases: null, + }, + staticPreview: this.render({ version: 'v5.0.0-beta.1', sort: 'semver' }), + }, + { + title: 'GitLab tag (custom instance)', + namedParams: { + user: 'GNOME', + repo: 'librsvg', + }, + queryParams: { + sort: 'semver', + include_prereleases: null, + gitlab_url: 'https://gitlab.gnome.org', + }, + staticPreview: this.render({ version: 'v2.51.4' }), + }, + ] + + static defaultBadgeData = { label: 'tag' } + + static render({ version, sort }) { + return { + message: addv(version), + color: sort === 'semver' ? versionColor(version) : 'blue', + } + } + + async fetch({ user, repo, baseUrl }) { + // https://docs.gitlab.com/ee/api/tags.html + // N.B. the documentation has contradictory information about default sort order. + // As of 2020-10-11 the default is by date, but we add the `order_by` query param + // explicitly in case that changes upstream. + return super.fetch({ + schema, + url: `${baseUrl}/api/v4/projects/${user}%2F${repo}/repository/tags`, + options: { qs: { order_by: 'updated' } }, + errorMessages: { + 404: 'repo not found', + }, + }) + } + + static transform({ tags, sort, includePrereleases }) { + if (tags.length === 0) { + throw new NotFound({ prettyMessage: 'no tags found' }) + } + + if (sort === 'date') { + return tags[0].name + } + + return latest( + tags.map(t => t.name), + { pre: includePrereleases } + ) + } + + async handle( + { user, repo }, + { + gitlab_url: baseUrl = 'https://gitlab.com', + include_prereleases: pre, + sort, + } + ) { + const tags = await this.fetch({ user, repo, baseUrl }) + const version = this.constructor.transform({ + tags, + sort, + includePrereleases: pre !== undefined, + }) + return this.constructor.render({ version, sort }) + } +} diff --git a/services/gitlab/gitlab-tag.spec.js b/services/gitlab/gitlab-tag.spec.js new file mode 100644 index 0000000000000..b539a5a0d1e5e --- /dev/null +++ b/services/gitlab/gitlab-tag.spec.js @@ -0,0 +1,47 @@ +import { expect } from 'chai' +import nock from 'nock' +import { cleanUpNockAfterEach, defaultContext } from '../test-helpers.js' +import GitLabTag from './gitlab-tag.service.js' + +describe('GitLabTag', function () { + describe('auth', function () { + cleanUpNockAfterEach() + + const fakeToken = 'abc123' + const config = { + public: { + services: { + gitlab: { + authorizedOrigins: ['https://gitlab.com'], + }, + }, + }, + private: { + gitlab_token: fakeToken, + }, + } + + it('sends the auth information as configured', async function () { + const scope = nock('https://gitlab.com/') + .get('/api/v4/projects/foo%2Fbar/repository/tags?order_by=updated') + // This ensures that the expected credentials are actually being sent with the HTTP request. + // Without this the request wouldn't match and the test would fail. + .basicAuth({ user: '', pass: fakeToken }) + .reply(200, [{ name: '1.9' }]) + + expect( + await GitLabTag.invoke( + defaultContext, + config, + { user: 'foo', repo: 'bar' }, + {} + ) + ).to.deep.equal({ + message: 'v1.9', + color: 'blue', + }) + + scope.done() + }) + }) +}) diff --git a/services/gitlab/gitlab-tag.tester.js b/services/gitlab/gitlab-tag.tester.js new file mode 100644 index 0000000000000..524d1e820bf4c --- /dev/null +++ b/services/gitlab/gitlab-tag.tester.js @@ -0,0 +1,27 @@ +import { isSemver } from '../test-validators.js' +import { createServiceTester } from '../tester.js' +export const t = await createServiceTester() + +t.create('Tag (latest by date)') + .get('/shields-ops-group/tag-test.json') + .expectBadge({ label: 'tag', message: 'v2.0.0', color: 'blue' }) + +t.create('Tag (latest by SemVer)') + .get('/shields-ops-group/tag-test.json?sort=semver') + .expectBadge({ label: 'tag', message: 'v4.0.0', color: 'blue' }) + +t.create('Tag (latest by SemVer pre-release)') + .get('/shields-ops-group/tag-test.json?sort=semver&include_prereleases') + .expectBadge({ label: 'tag', message: 'v5.0.0-beta.1', color: 'orange' }) + +t.create('Tag (custom instance') + .get('/GNOME/librsvg.json?gitlab_url=https://gitlab.gnome.org') + .expectBadge({ label: 'tag', message: isSemver, color: 'blue' }) + +t.create('Tag (repo not found)') + .get('/fdroid/nonexistant.json') + .expectBadge({ label: 'tag', message: 'repo not found' }) + +t.create('Tag (no tags)') + .get('/fdroid/fdroiddata.json') + .expectBadge({ label: 'tag', message: 'no tags found' })