From fb24e26afac08b1b73c342beb07a0fbe33a6f067 Mon Sep 17 00:00:00 2001 From: develar Date: Wed, 15 Mar 2017 10:01:41 +0100 Subject: [PATCH] fix(updater): github private repo Close #1370 --- .../electron-builder-http/src/httpExecutor.ts | 16 +++- .../src/publishOptions.ts | 5 ++ packages/electron-updater/src/AppUpdater.ts | 8 +- .../electron-updater/src/GitHubProvider.ts | 25 +++--- .../src/PrivateGitHubProvider.ts | 90 ++++++++----------- test/jestSetup.js | 5 +- .../out/__snapshots__/nsisUpdaterTest.js.snap | 12 +++ test/src/nsisUpdaterTest.ts | 23 +++++ 8 files changed, 116 insertions(+), 68 deletions(-) diff --git a/packages/electron-builder-http/src/httpExecutor.ts b/packages/electron-builder-http/src/httpExecutor.ts index 3b706124188..00f2d4e7d1f 100644 --- a/packages/electron-builder-http/src/httpExecutor.ts +++ b/packages/electron-builder-http/src/httpExecutor.ts @@ -108,10 +108,9 @@ export abstract class HttpExecutor { return } - this.doApiRequest(Object.assign({}, options, parseUrl(redirectUrl)), cancellationToken, requestProcessor, redirectCount) + this.doApiRequest(Object.assign({}, removeAuthHeader(options), parseUrl(redirectUrl)), cancellationToken, requestProcessor, redirectCount) .then(resolve) .catch(reject) - return } @@ -160,7 +159,7 @@ export abstract class HttpExecutor { if (redirectUrl != null) { if (redirectCount < this.maxRedirects) { const parsedUrl = parseUrl(redirectUrl) - this.doDownload(Object.assign({}, requestOptions, { + this.doDownload(Object.assign({}, removeAuthHeader(requestOptions), { hostname: parsedUrl.hostname, path: parsedUrl.path, port: parsedUrl.port == null ? undefined : parsedUrl.port @@ -305,4 +304,15 @@ export function dumpRequestOptions(options: RequestOptions): string { safe.headers.authorization = "" } return JSON.stringify(safe, null, 2) +} + +function removeAuthHeader(requestOptions: RequestOptions): RequestOptions { + const result = Object.assign({}, requestOptions) + // github redirect to amazon s3 - avoid error "Only one auth mechanism allowed" + if (result.headers != null) { + result.headers = Object.assign({}, result.headers) + delete result.headers.Authorization + delete result.headers.authorization + } + return result } \ No newline at end of file diff --git a/packages/electron-builder-http/src/publishOptions.ts b/packages/electron-builder-http/src/publishOptions.ts index d720ba9be69..7b6edf15964 100644 --- a/packages/electron-builder-http/src/publishOptions.ts +++ b/packages/electron-builder-http/src/publishOptions.ts @@ -52,6 +52,11 @@ export interface GithubOptions extends PublishConfiguration { * @default https */ readonly protocol?: "https" | "http" | null + + /** + * The access token to support auto-update from private github repositories. Never specify it in the configuration files. Only for [setFeedURL](module:electron-updater/out/AppUpdater.AppUpdater+setFeedURL). + */ + readonly token?: string | null } export function githubUrl(options: GithubOptions) { diff --git a/packages/electron-updater/src/AppUpdater.ts b/packages/electron-updater/src/AppUpdater.ts index a6088c823b3..3a7bd61cc97 100644 --- a/packages/electron-updater/src/AppUpdater.ts +++ b/packages/electron-updater/src/AppUpdater.ts @@ -276,11 +276,13 @@ function createClient(data: string | PublishConfiguration) { const provider = (data).provider switch (provider) { case "github": - if (process.env.GH_TOKEN == null) { - return new GitHubProvider(data) + const githubOptions = data + const token = process.env.GH_TOKEN || githubOptions.token + if (token == null) { + return new GitHubProvider(githubOptions) } else { - return new PrivateGitHubProvider(data) + return new PrivateGitHubProvider(githubOptions, token) } case "s3": { diff --git a/packages/electron-updater/src/GitHubProvider.ts b/packages/electron-updater/src/GitHubProvider.ts index 20a87b89def..113a94a0d17 100644 --- a/packages/electron-updater/src/GitHubProvider.ts +++ b/packages/electron-updater/src/GitHubProvider.ts @@ -1,29 +1,35 @@ import { HttpError, request } from "electron-builder-http" import { CancellationToken } from "electron-builder-http/out/CancellationToken" -import { GithubOptions, githubUrl, UpdateInfo, VersionInfo } from "electron-builder-http/out/publishOptions" +import { GithubOptions, githubUrl, UpdateInfo } from "electron-builder-http/out/publishOptions" import { RequestOptions } from "http" import * as path from "path" import { parse as parseUrl } from "url" import { FileInfo, formatUrl, getChannelFilename, getCurrentPlatform, getDefaultChannelName, Provider } from "./api" import { validateUpdateInfo } from "./GenericProvider" -export class GitHubProvider extends Provider { +export abstract class BaseGitHubProvider extends Provider { // so, we don't need to parse port (because node http doesn't support host as url does) - private readonly baseUrl: RequestOptions - - constructor(private readonly options: GithubOptions) { + protected readonly baseUrl: RequestOptions + + constructor(protected readonly options: GithubOptions, baseHost: string) { super() - const baseUrl = parseUrl(`${options.protocol || "https"}://${options.host || "github.com"}`) + const baseUrl = parseUrl(`${options.protocol || "https"}://${options.host || baseHost}`) this.baseUrl = { protocol: baseUrl.protocol, hostname: baseUrl.hostname, port: baseUrl.port, } } +} + +export class GitHubProvider extends BaseGitHubProvider { + constructor(protected readonly options: GithubOptions) { + super(options, "github.com") + } async getLatestVersion(): Promise { - const basePath = this.getBasePath() + const basePath = this.basePath const cancellationToken = new CancellationToken() const version = await this.getLatestVersionString(basePath, cancellationToken) let result: any @@ -61,7 +67,7 @@ export class GitHubProvider extends Provider { } } - private getBasePath() { + protected get basePath() { return `/${this.options.owner}/${this.options.repo}/releases` } @@ -70,12 +76,11 @@ export class GitHubProvider extends Provider { return versionInfo } - const basePath = this.getBasePath() // space is not supported on GitHub const name = versionInfo.githubArtifactName || path.posix.basename(versionInfo.path).replace(/ /g, "-") return { name: name, - url: formatUrl(Object.assign({path: `${basePath}/download/v${versionInfo.version}/${name}`}, this.baseUrl)), + url: formatUrl(Object.assign({path: `${this.basePath}/download/v${versionInfo.version}/${name}`}, this.baseUrl)), sha2: versionInfo.sha2, } } diff --git a/packages/electron-updater/src/PrivateGitHubProvider.ts b/packages/electron-updater/src/PrivateGitHubProvider.ts index 4d35b858de9..95829243e28 100644 --- a/packages/electron-updater/src/PrivateGitHubProvider.ts +++ b/packages/electron-updater/src/PrivateGitHubProvider.ts @@ -1,54 +1,34 @@ import { HttpError, request } from "electron-builder-http" import { CancellationToken } from "electron-builder-http/out/CancellationToken" -import { GithubOptions, UpdateInfo, VersionInfo } from "electron-builder-http/out/publishOptions" +import { GithubOptions, UpdateInfo } from "electron-builder-http/out/publishOptions" import { RequestOptions } from "http" -import { safeLoad } from "js-yaml" import * as path from "path" import { parse as parseUrl } from "url" -import { FileInfo, formatUrl, getChannelFilename, getCurrentPlatform, getDefaultChannelName, Provider } from "./api" +import { FileInfo, formatUrl, getChannelFilename, getCurrentPlatform, getDefaultChannelName } from "./api" import { validateUpdateInfo } from "./GenericProvider" +import { BaseGitHubProvider } from "./GitHubProvider" -export class PrivateGitHubProvider extends Provider { - // so, we don't need to parse port (because node http doesn't support host as url does) - private readonly baseUrl: RequestOptions - private apiResult: any +interface PrivateGitHubUpdateInfo extends UpdateInfo { + assets: Array +} - constructor(private readonly options: GithubOptions) { - super() - - const baseUrl = parseUrl(`${options.protocol || "https"}://${options.host || "api.github.com"}`) - this.baseUrl = { - protocol: baseUrl.protocol, - hostname: baseUrl.hostname, - port: baseUrl.port, - } +export class PrivateGitHubProvider extends BaseGitHubProvider { + constructor(options: GithubOptions, private readonly token: string) { + super(options, "api.github.com") } - - async getLatestVersion(): Promise { - const basePath = this.getBasePath() + + async getLatestVersion(): Promise { + const basePath = this.basePath const cancellationToken = new CancellationToken() - let result: any const channelFile = getChannelFilename(getDefaultChannelName()) - const versionUrl = await this.getLatestVersionUrl(basePath, cancellationToken, channelFile) - const assetPath = parseUrl(versionUrl).path + + const assets = await this.getLatestVersionInfo(basePath, cancellationToken) const requestOptions = Object.assign({ - path: `${assetPath}?access_token=${process.env.GH_TOKEN}`, - headers: Object.assign({ - Accept: "application/octet-stream", - "User-Agent": this.options.owner - }, this.requestHeaders) - }, this.baseUrl) + headers: this.configureHeaders("application/octet-stream") + }, parseUrl(assets.find(it => it.name == channelFile)!.url)) + let result: any try { result = await request(requestOptions, cancellationToken) - //Maybe better to parse in httpExecutor ? - if (typeof result === "string") { - if (getCurrentPlatform() === "darwin") { - result = JSON.parse(result) - } - else { - result = safeLoad(result) - } - } } catch (e) { if (e instanceof HttpError && e.response.statusCode === 404) { @@ -61,54 +41,62 @@ export class PrivateGitHubProvider extends Provider { if (getCurrentPlatform() === "darwin") { result.releaseJsonUrl = `${this.options.protocol || "https"}://${this.options.host || "api.github.com"}${requestOptions.path}` } + (result).assets = assets return result } - private async getLatestVersionUrl(basePath: string, cancellationToken: CancellationToken, channelFile: string): Promise { + private configureHeaders(accept: string) { + return Object.assign({ + Accept: accept, + Authorization: `token ${this.token}`, + }, this.requestHeaders) + } + + private async getLatestVersionInfo(basePath: string, cancellationToken: CancellationToken): Promise> { const requestOptions: RequestOptions = Object.assign({ - path: `${basePath}/latest?access_token=${process.env.GH_TOKEN}`, - headers: Object.assign({Accept: "application/json", "User-Agent": this.options.owner}, this.requestHeaders) + path: `${basePath}/latest`, + headers: this.configureHeaders("application/vnd.github.v3+json"), }, this.baseUrl) try { - this.apiResult = (await request(requestOptions, cancellationToken)) - return this.apiResult.assets.find((elem: any) => { - return elem.name == channelFile - }).url + return (await request(requestOptions, cancellationToken)).assets } catch (e) { throw new Error(`Unable to find latest version on GitHub (${formatUrl(requestOptions)}), please ensure a production release exists: ${e.stack || e.message}`) } } - private getBasePath() { + private get basePath() { return `/repos/${this.options.owner}/${this.options.repo}/releases` } - async getUpdateFile(versionInfo: UpdateInfo): Promise { + async getUpdateFile(versionInfo: PrivateGitHubUpdateInfo): Promise { const headers = { Accept: "application/octet-stream", - "User-Agent": this.options.owner, - Authorization: `token ${process.env.GH_TOKEN}` + Authorization: `token ${this.token}` } // space is not supported on GitHub if (getCurrentPlatform() === "darwin") { const info = versionInfo const name = info.url.split("/").pop() - const assetPath = parseUrl(this.apiResult.assets.find((it: any) => it.name == name).url).path + const assetPath = parseUrl(versionInfo.assets.find(it => it.name == name)!.url).path info.url = formatUrl(Object.assign({path: `${assetPath}`}, this.baseUrl)) info.headers = headers return info } else { const name = versionInfo.githubArtifactName || path.posix.basename(versionInfo.path).replace(/ /g, "-") - const assetPath = parseUrl(this.apiResult.assets.find((it: any) => it.name == name).url).path return { name: name, - url: formatUrl(Object.assign({path: `${assetPath}`}, this.baseUrl)), + url: versionInfo.assets.find(it => it.name == name)!.url, sha2: versionInfo.sha2, headers: headers, } } } +} + +interface Asset { + name: string + url: string } \ No newline at end of file diff --git a/test/jestSetup.js b/test/jestSetup.js index e49f5bbad76..70dd47e29da 100644 --- a/test/jestSetup.js +++ b/test/jestSetup.js @@ -52,7 +52,10 @@ test.ifLinux = process.platform === "linux" ? test : skip test.ifLinuxOrDevMac = process.platform === "linux" || (!isCi && isMac) ? test : skip delete process.env.CSC_NAME -delete process.env.GH_TOKEN +if (process.env.TEST_APP_TMP_DIR == null) { + delete process.env.GH_TOKEN +} + process.env.CSC_IDENTITY_AUTO_DISCOVERY = "false" if (!process.env.USE_HARD_LINKS) { process.env.USE_HARD_LINKS = "true" diff --git a/test/out/__snapshots__/nsisUpdaterTest.js.snap b/test/out/__snapshots__/nsisUpdaterTest.js.snap index 3b3961b862c..df97600b41d 100644 --- a/test/out/__snapshots__/nsisUpdaterTest.js.snap +++ b/test/out/__snapshots__/nsisUpdaterTest.js.snap @@ -67,6 +67,18 @@ Object { } `; +exports[`file url github private 1`] = ` +Object { + "headers": Object { + "Accept": "application/octet-stream", + "Authorization": "token fad40e29d04dc522e3ba03a5468339e191acd82d", + }, + "name": "TestApp-Setup-1.1.0.exe", + "sha2": "f2ca1bb6c7e907d06dafe4687e579fce76b37e4e93b7605022da52e6ccc26fd2", + "url": "https://api.github.com/repos/develar/__test_nsis_release_private/releases/assets/3403081", +} +`; + exports[`sha2 mismatch error event 1`] = ` Object { "name": "TestApp Setup 1.1.0.exe", diff --git a/test/src/nsisUpdaterTest.ts b/test/src/nsisUpdaterTest.ts index 03c1e8f218f..794e7c225ba 100644 --- a/test/src/nsisUpdaterTest.ts +++ b/test/src/nsisUpdaterTest.ts @@ -167,6 +167,29 @@ test("file url github", async () => { expect(actualEvents).toEqual(expectedEvents) }) +test.skip("file url github private", async () => { + const updater = new NsisUpdater() + updater.updateConfigPath = await writeUpdateConfig({ + provider: "github", + owner: "develar", + repo: "__test_nsis_release_private", + }) + + const actualEvents: Array = [] + const expectedEvents = ["checking-for-update", "update-available", "update-downloaded"] + for (const eventName of expectedEvents) { + updater.addListener(eventName, () => { + actualEvents.push(eventName) + }) + } + + const updateCheckResult = await updater.checkForUpdates() + expect(updateCheckResult.fileInfo).toMatchSnapshot() + await assertThat(path.join(await updateCheckResult.downloadPromise)).isFile() + + expect(actualEvents).toEqual(expectedEvents) +}) + test("test error", async () => { const updater: NsisUpdater = new NsisUpdater()