diff --git a/services/scoop/scoop-base.js b/services/scoop/scoop-base.js new file mode 100644 index 0000000000..6af71f83cf --- /dev/null +++ b/services/scoop/scoop-base.js @@ -0,0 +1,81 @@ +import Joi from 'joi' +import { ConditionalGithubAuthV3Service } from '../github/github-auth-service.js' +import { fetchJsonFromRepo } from '../github/github-common-fetch.js' +import { NotFound } from '../index.js' + +const gitHubRepoRegExp = + /https:\/\/github.com\/(?.*?)\/(?.*?)(\/|$)/ + +const bucketsSchema = Joi.object() + .pattern(/.+/, Joi.string().pattern(gitHubRepoRegExp).required()) + .required() + +export const queryParamSchema = Joi.object({ + bucket: Joi.string(), +}) + +export class ScoopBase extends ConditionalGithubAuthV3Service { + // The buckets file (https://github.com/lukesampson/scoop/blob/master/buckets.json) changes very rarely. + // Cache it for the lifetime of the current Node.js process. + buckets = null + + async fetch({ app, schema }, queryParams) { + if (!this.buckets) { + this.buckets = await fetchJsonFromRepo(this, { + schema: bucketsSchema, + user: 'ScoopInstaller', + repo: 'Scoop', + branch: 'master', + filename: 'buckets.json', + }) + } + const bucket = queryParams.bucket || 'main' + let bucketUrl = this.buckets[bucket] + if (!bucketUrl) { + // Parsing URL here will throw an error if the url is invalid + try { + const url = new URL(decodeURIComponent(bucket)) + + // Throw errors to go to jump to catch statement + // The error messages here are purely for code readability, and will never reach the user. + if (url.hostname !== 'github.com') { + throw new Error('Not a GitHub URL') + } + const path = url.pathname.split('/').filter(value => value !== '') + + if (path.length !== 2) { + throw new Error('Not a valid GitHub Repo') + } + + const [user, repo] = path + + // Reconstructing the url here ensures that the url will match the regex + bucketUrl = `https://github.com/${user}/${repo}` + } catch (e) { + throw new NotFound({ prettyMessage: `bucket "${bucket}" not found` }) + } + } + const { + groups: { user, repo }, + } = gitHubRepoRegExp.exec(bucketUrl) + try { + return await fetchJsonFromRepo(this, { + schema, + user, + repo, + branch: 'master', + filename: `bucket/${app}.json`, + }) + } catch (error) { + if (error instanceof NotFound) { + throw new NotFound({ + prettyMessage: `${app} not found in bucket "${bucket}"`, + }) + } + throw error + } + } +} + +export const description = + '[Scoop](https://scoop.sh/) is a command-line installer for Windows' diff --git a/services/scoop/scoop-license.service.js b/services/scoop/scoop-license.service.js new file mode 100644 index 0000000000..be7be0b473 --- /dev/null +++ b/services/scoop/scoop-license.service.js @@ -0,0 +1,63 @@ +import Joi from 'joi' +import { pathParam, queryParam } from '../index.js' +import { renderLicenseBadge } from '../licenses.js' +import toArray from '../../core/base-service/to-array.js' +import { queryParamSchema, description, ScoopBase } from './scoop-base.js' + +const scoopLicenseSchema = Joi.object({ + license: Joi.alternatives() + .try( + Joi.string().required(), + Joi.object({ + identifier: Joi.string().required(), + }), + ) + .required(), +}).required() + +export default class ScoopLicense extends ScoopBase { + static category = 'license' + + static route = { + base: 'scoop/l', + pattern: ':app', + queryParamSchema, + } + + static openApi = { + '/scoop/l/{app}': { + get: { + summary: 'Scoop License', + description, + parameters: [ + pathParam({ name: 'app', example: 'ngrok' }), + queryParam({ + name: 'bucket', + description: + "App's containing bucket. Can either be a name (e.g `extras`) or a URL to a GitHub Repo (e.g `https://github.com/jewlexx/personal-scoop`)", + example: 'extras', + }), + ], + }, + }, + } + + static defaultBadgeData = { label: 'license' } + + static render({ licenses }) { + return renderLicenseBadge({ licenses }) + } + + async handle({ app }, queryParams) { + const { license } = await this.fetch( + { app, schema: scoopLicenseSchema }, + queryParams, + ) + + const licenses = toArray(license).map(license => + typeof license === 'string' ? license : license.identifier, + ) + + return this.constructor.render({ licenses }) + } +} diff --git a/services/scoop/scoop-license.tester.js b/services/scoop/scoop-license.tester.js new file mode 100644 index 0000000000..b989fad4e1 --- /dev/null +++ b/services/scoop/scoop-license.tester.js @@ -0,0 +1,94 @@ +import { createServiceTester } from '../tester.js' + +export const t = await createServiceTester() + +t.create('License (valid) - with nested response') + .get('/ngrok.json') + .expectBadge({ + label: 'license', + message: 'Shareware', + }) + +t.create('License (valid) - with string response') + .get('/nvs.json') + .expectBadge({ + label: 'license', + message: 'MIT', + }) + +t.create('License (invalid)').get('/not-a-real-app.json').expectBadge({ + label: 'license', + message: 'not-a-real-app not found in bucket "main"', +}) + +t.create('License (valid custom bucket)') + .get('/atom.json?bucket=extras') + .expectBadge({ + label: 'license', + message: 'MIT', + }) + +t.create('license (not found in custom bucket)') + .get('/not-a-real-app.json?bucket=extras') + .expectBadge({ + label: 'license', + message: 'not-a-real-app not found in bucket "extras"', + }) + +t.create('license (wrong bucket)') + .get('/not-a-real-app.json?bucket=not-a-real-bucket') + .expectBadge({ + label: 'license', + message: 'bucket "not-a-real-bucket" not found', + }) + +// version (bucket url) +const validBucketUrl = encodeURIComponent( + 'https://github.com/jewlexx/personal-scoop', +) + +t.create('license (valid bucket url)') + .get(`/sfsu.json?bucket=${validBucketUrl}`) + .expectBadge({ + label: 'license', + message: 'Apache-2.0', + }) + +const validBucketUrlTrailingSlash = encodeURIComponent( + 'https://github.com/jewlexx/personal-scoop/', +) + +t.create('license (valid bucket url)') + .get(`/sfsu.json?bucket=${validBucketUrlTrailingSlash}`) + .expectBadge({ + label: 'license', + message: 'Apache-2.0', + }) + +t.create('license (not found in custom bucket)') + .get(`/not-a-real-app.json?bucket=${validBucketUrl}`) + .expectBadge({ + label: 'license', + message: `not-a-real-app not found in bucket "${decodeURIComponent(validBucketUrl)}"`, + }) + +const nonGithubUrl = encodeURIComponent('https://example.com/') + +t.create('license (non-github url)') + .get(`/not-a-real-app.json?bucket=${nonGithubUrl}`) + .expectBadge({ + label: 'license', + message: `bucket "${decodeURIComponent(nonGithubUrl)}" not found`, + }) + +const nonBucketRepo = encodeURIComponent('https://github.com/jewlexx/sfsu') + +t.create('version (non-bucket repo)') + .get(`/sfsu.json?bucket=${nonBucketRepo}`) + .expectBadge({ + label: 'license', + // !!! Important note here + // It is hard to tell if a repo is actually a scoop bucket, without getting the contents + // As such, a helpful error message here, which would require testing if the url is a valid scoop bucket, is difficult. + message: `sfsu not found in bucket "${decodeURIComponent(nonBucketRepo)}"`, + }) diff --git a/services/scoop/scoop-version.service.js b/services/scoop/scoop-version.service.js index 7b4d806750..1676959b57 100644 --- a/services/scoop/scoop-version.service.js +++ b/services/scoop/scoop-version.service.js @@ -1,27 +1,13 @@ -import { URL } from 'url' import Joi from 'joi' -import { NotFound, pathParam, queryParam } from '../index.js' -import { ConditionalGithubAuthV3Service } from '../github/github-auth-service.js' -import { fetchJsonFromRepo } from '../github/github-common-fetch.js' +import { pathParam, queryParam } from '../index.js' import { renderVersionBadge } from '../version.js' +import { queryParamSchema, description, ScoopBase } from './scoop-base.js' -const gitHubRepoRegExp = - /https:\/\/github.com\/(?.*?)\/(?.*?)(\/|$)/ -const bucketsSchema = Joi.object() - .pattern(/.+/, Joi.string().pattern(gitHubRepoRegExp).required()) - .required() const scoopSchema = Joi.object({ version: Joi.string().required(), }).required() -const queryParamSchema = Joi.object({ - bucket: Joi.string(), -}) - -export default class ScoopVersion extends ConditionalGithubAuthV3Service { - // The buckets file (https://github.com/lukesampson/scoop/blob/master/buckets.json) changes very rarely. - // Cache it for the lifetime of the current Node.js process. - buckets = null +export default class ScoopVersion extends ScoopBase { static category = 'version' static route = { @@ -34,8 +20,7 @@ export default class ScoopVersion extends ConditionalGithubAuthV3Service { '/scoop/v/{app}': { get: { summary: 'Scoop Version', - description: - '[Scoop](https://scoop.sh/) is a command-line installer for Windows', + description, parameters: [ pathParam({ name: 'app', example: 'ngrok' }), queryParam({ @@ -56,60 +41,11 @@ export default class ScoopVersion extends ConditionalGithubAuthV3Service { } async handle({ app }, queryParams) { - if (!this.buckets) { - this.buckets = await fetchJsonFromRepo(this, { - schema: bucketsSchema, - user: 'ScoopInstaller', - repo: 'Scoop', - branch: 'master', - filename: 'buckets.json', - }) - } - const bucket = queryParams.bucket || 'main' - let bucketUrl = this.buckets[bucket] - if (!bucketUrl) { - // Parsing URL here will throw an error if the url is invalid - try { - const url = new URL(decodeURIComponent(bucket)) + const { version } = await this.fetch( + { app, schema: scoopSchema }, + queryParams, + ) - // Throw errors to go to jump to catch statement - // The error messages here are purely for code readability, and will never reach the user. - if (url.hostname !== 'github.com') { - throw new Error('Not a GitHub URL') - } - const path = url.pathname.split('/').filter(value => value !== '') - - if (path.length !== 2) { - throw new Error('Not a valid GitHub Repo') - } - - const [user, repo] = path - - // Reconstructing the url here ensures that the url will match the regex - bucketUrl = `https://github.com/${user}/${repo}` - } catch (e) { - throw new NotFound({ prettyMessage: `bucket "${bucket}" not found` }) - } - } - const { - groups: { user, repo }, - } = gitHubRepoRegExp.exec(bucketUrl) - try { - const { version } = await fetchJsonFromRepo(this, { - schema: scoopSchema, - user, - repo, - branch: 'master', - filename: `bucket/${app}.json`, - }) - return this.constructor.render({ version }) - } catch (error) { - if (error instanceof NotFound) { - throw new NotFound({ - prettyMessage: `${app} not found in bucket "${bucket}"`, - }) - } - throw error - } + return this.constructor.render({ version }) } }