Skip to content

Commit

Permalink
feat(electron-updater): add requestHeaders option
Browse files Browse the repository at this point in the history
Close #1175
  • Loading branch information
kevinbuhmann authored and develar committed Jan 28, 2017
1 parent e7e2a82 commit dd1320d
Show file tree
Hide file tree
Showing 16 changed files with 235 additions and 203 deletions.
9 changes: 5 additions & 4 deletions docs/Auto Update.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,11 @@ autoUpdater.logger.transports.file.level = "info"
## Options
Name | Default | Description
--------------------|-------------------------|------------
autoDownload | `true` | Automatically download an update when it is found.
logger | `console` | The logger. You can pass [electron-log](https://github.com/megahertz/electron-log), [winston](https://github.com/winstonjs/winston) or another logger with the following interface: `{ info(), warn(), error() }`. Set it to `null` if you would like to disable a logging feature.
Name | Default | Description
--------------------|-------------------|------------
`autoDownload` | `true` | Automatically download an update when it is found.
`logger` | `console` | The logger. You can pass [electron-log](https://github.com/megahertz/electron-log), [winston](https://github.com/winstonjs/winston) or another logger with the following interface: `{ info(), warn(), error() }`. Set it to `null` if you would like to disable a logging feature.
`requestHeaders` | `null` | The request headers.
## Events
Expand Down
4 changes: 2 additions & 2 deletions packages/electron-builder-http/src/bintray.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { BintrayOptions } from "./publishOptions"
import { request } from "./httpExecutor"
import { request, configureRequestOptions } from "./httpExecutor"

export function bintrayRequest<T>(path: string, auth: string | null, data: {[name: string]: any; } | null = null, method?: string): Promise<T> {
return request<T>({hostname: "api.bintray.com", path: path}, auth, data, null, method)
return request<T>(configureRequestOptions({hostname: "api.bintray.com", path: path}, auth, method), data)
}

export interface Version {
Expand Down
145 changes: 105 additions & 40 deletions packages/electron-builder-http/src/httpExecutor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { Url } from "url"
import { createHash } from "crypto"
import { Transform } from "stream"
import { createWriteStream } from "fs-extra-p"
Expand All @@ -8,11 +7,23 @@ import _debug from "debug"
import { ProgressCallbackTransform } from "./ProgressCallbackTransform"
import { safeLoad } from "js-yaml"
import { EventEmitter } from "events"
import { Socket } from "net"

export const debug = _debug("electron-builder")
export const maxRedirects = 10
export interface RequestHeaders {
[key: string]: any
}

export interface Response extends EventEmitter {
statusCode?: number
statusMessage?: string

headers: any

setEncoding(encoding: string): void
}

export interface DownloadOptions {
headers?: RequestHeaders | null
skipDirCreation?: boolean
sha2?: string
onProgress?(progress: any): void
Expand All @@ -39,41 +50,48 @@ export function download(url: string, destination: string, options?: DownloadOpt
return executorHolder.httpExecutor.download(url, destination, options)
}

export class HttpError extends Error {
constructor(public readonly response: {statusMessage?: string | undefined, statusCode?: number | undefined, headers?: { [key: string]: string[]; } | undefined}, public description: any | null = null) {
super(response.statusCode + " " + response.statusMessage + (description == null ? "" : ("\n" + JSON.stringify(description, null, " "))) + "\nHeaders: " + JSON.stringify(response.headers, null, " "))

this.name = "HttpError"
}
}

export abstract class HttpExecutor<REQUEST_OPTS, REQUEST> {
request<T>(url: Url, token?: string | null, data?: {[name: string]: any; } | null, headers?: { [key: string]: any } | null, method?: string): Promise<T> {
const defaultHeaders: any = {"User-Agent": "electron-builder"}
const options = Object.assign({
method: method || "GET",
headers: headers == null ? defaultHeaders : Object.assign(defaultHeaders, headers)
}, url)
protected readonly maxRedirects = 10
protected readonly debug = _debug("electron-builder")

request<T>(options: RequestOptions, data?: { [name: string]: any; } | null): Promise<T> {
options = Object.assign({headers: {"User-Agent": "electron-builder"}}, options)

const encodedData = data == null ? undefined : new Buffer(JSON.stringify(data))
if (encodedData != null) {
options.method = "post"
if (options.headers == null) {
options.headers = {}
}

options.headers["Content-Type"] = "application/json"
options.headers["Content-Length"] = encodedData.length
}
return this.doApiRequest<T>(<any>options, token || null, it => (<any>it).end(encodedData), 0)
return this.doApiRequest<T>(<any>options, it => (<any>it).end(encodedData), 0)
}

protected abstract doApiRequest<T>(options: REQUEST_OPTS, token: string | null, requestProcessor: (request: REQUEST, reject: (error: Error) => void) => void, redirectCount: number): Promise<T>
protected abstract doApiRequest<T>(options: REQUEST_OPTS, requestProcessor: (request: REQUEST, reject: (error: Error) => void) => void, redirectCount: number): Promise<T>

abstract download(url: string, destination: string, options?: DownloadOptions | null): Promise<string>

protected handleResponse(response: Response, options: RequestOptions, resolve: (data?: any) => void, reject: (error: Error) => void, redirectCount: number, token: string | null, requestProcessor: (request: REQUEST, reject: (error: Error) => void) => void) {
if (debug.enabled) {
const safe: any = Object.assign({}, options)
if (safe.headers != null && safe.headers.authorization != null) {
safe.headers.authorization = "<skipped>"
}
debug(`Response status: ${response.statusCode} ${response.statusMessage}, request options: ${JSON.stringify(safe, null, 2)}`)
protected handleResponse(response: Response, options: RequestOptions, resolve: (data?: any) => void, reject: (error: Error) => void, redirectCount: number, requestProcessor: (request: REQUEST, reject: (error: Error) => void) => void) {
if (this.debug.enabled) {
this.debug(`Response status: ${response.statusCode} ${response.statusMessage}, request options: ${dumpRequestOptions(options)}`)
}

// we handle any other >= 400 error on request end (read detailed message in the response body)
if (response.statusCode === 404) {
// error is clear, we don't need to read detailed error description
reject(new HttpError(response, `method: ${options.method} url: https://${options.hostname}${options.path}
Please double check that your authentication token is correct. Due to security reasons actual status maybe not reported, but 404.
`))
return
Expand All @@ -91,7 +109,7 @@ export abstract class HttpExecutor<REQUEST_OPTS, REQUEST> {
return
}

this.doApiRequest(<REQUEST_OPTS>Object.assign({}, options, parseUrl(redirectUrl)), token, requestProcessor, redirectCount)
this.doApiRequest(<REQUEST_OPTS>Object.assign({}, options, parseUrl(redirectUrl)), requestProcessor, redirectCount)
.then(resolve)
.catch(reject)

Expand Down Expand Up @@ -129,23 +147,47 @@ export abstract class HttpExecutor<REQUEST_OPTS, REQUEST> {
}
})
}
}

export class HttpError extends Error {
constructor(public readonly response: {statusMessage?: string | undefined, statusCode?: number | undefined, headers?: { [key: string]: string[]; } | undefined}, public description: any | null = null) {
super(response.statusCode + " " + response.statusMessage + (description == null ? "" : ("\n" + JSON.stringify(description, null, " "))) + "\nHeaders: " + JSON.stringify(response.headers, null, " "))
protected abstract doRequest(options: any, callback: (response: any) => void): any

this.name = "HttpError"
}
}
protected doDownload(requestOptions: any, destination: string, redirectCount: number, options: DownloadOptions, callback: (error: Error | null) => void) {
const request = this.doRequest(requestOptions, (response: Electron.IncomingMessage) => {
if (response.statusCode >= 400) {
callback(new Error(`Cannot download "${requestOptions.protocol || "https"}://${requestOptions.hostname}/${requestOptions.path}", status ${response.statusCode}: ${response.statusMessage}`))
return
}

export interface Response extends EventEmitter {
statusCode?: number
statusMessage?: string
const redirectUrl = safeGetHeader(response, "location")
if (redirectUrl != null) {
if (redirectCount < this.maxRedirects) {
const parsedUrl = parseUrl(redirectUrl)
this.doDownload(Object.assign({}, requestOptions, {
hostname: parsedUrl.hostname,
path: parsedUrl.path,
port: parsedUrl.port == null ? undefined : parsedUrl.port
}), destination, redirectCount++, options, callback)
}
else {
callback(new Error(`Too many redirects (> ${this.maxRedirects})`))
}
return
}

headers: any
configurePipes(options, response, destination, callback)
})
this.addTimeOutHandler(request, callback)
request.on("error", callback)
request.end()
}

setEncoding(encoding: string): void
protected addTimeOutHandler(request: any, callback: (error: Error) => void) {
request.on("socket", function (socket: Socket) {
socket.setTimeout(60 * 1000, () => {
callback(new Error("Request timed out"))
request.abort()
})
})
}
}

class DigestTransform extends Transform {
Expand All @@ -166,12 +208,8 @@ class DigestTransform extends Transform {
}
}

export function githubRequest<T>(path: string, token: string | null, data: {[name: string]: any; } | null = null, method?: string): Promise<T> {
return executorHolder.httpExecutor.request<T>({hostname: "api.github.com", path: path}, token, data, {Accept: "application/vnd.github.v3+json"}, method)
}

export function request<T>(url: Url, token: string | null = null, data: {[name: string]: any; } | null = null, headers?: { [key: string]: any } | null, method?: string): Promise<T> {
return executorHolder.httpExecutor.request(url, token, data, headers, method)
export function request<T>(options: RequestOptions, data?: {[name: string]: any; } | null): Promise<T> {
return executorHolder.httpExecutor.request(options, data)
}

function checkSha2(sha2Header: string | null | undefined, sha2: string | null | undefined, callback: (error: Error | null) => void): boolean {
Expand All @@ -189,7 +227,7 @@ function checkSha2(sha2Header: string | null | undefined, sha2: string | null |
return true
}

export function safeGetHeader(response: any, headerKey: string) {
function safeGetHeader(response: any, headerKey: string) {
const value = response.headers[headerKey]
if (value == null) {
return null
Expand All @@ -203,7 +241,7 @@ export function safeGetHeader(response: any, headerKey: string) {
}
}

export function configurePipes(options: DownloadOptions, response: any, destination: string, callback: (error: Error | null) => void) {
function configurePipes(options: DownloadOptions, response: any, destination: string, callback: (error: Error | null) => void) {
if (!checkSha2(safeGetHeader(response, "X-Checksum-Sha2"), options.sha2, callback)) {
return
}
Expand All @@ -230,4 +268,31 @@ export function configurePipes(options: DownloadOptions, response: any, destinat
}

fileOut.on("finish", () => (<any>fileOut.close)(callback))
}

export function configureRequestOptions(options: RequestOptions, token: string | null, method?: string): RequestOptions {
if (method != null) {
options.method = method
}

let headers = options.headers
if (headers == null) {
headers = {}
options.headers = headers
}
if (token != null) {
(<any>headers).authorization = token.startsWith("Basic") ? token : `token ${token}`
}
if (headers["User-Agent"] == null) {
headers["User-Agent"] = "electron-builder"
}
return options
}

export function dumpRequestOptions(options: RequestOptions): string {
const safe: any = Object.assign({}, options)
if (safe.headers != null && safe.headers.authorization != null) {
safe.headers.authorization = "<skipped>"
}
return JSON.stringify(safe, null, 2)
}
6 changes: 3 additions & 3 deletions packages/electron-builder/src/publish/BintrayPublisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { BintrayClient, Version } from "electron-builder-http/out/bintray"
import { BintrayOptions } from "electron-builder-http/out/publishOptions"
import { ClientRequest } from "http"
import { NodeHttpExecutor } from "../util/nodeHttpExecutor"
import { HttpError } from "electron-builder-http"
import { HttpError, configureRequestOptions } from "electron-builder-http"

export class BintrayPublisher extends Publisher {
private _versionPromise: BluebirdPromise<Version>
Expand Down Expand Up @@ -58,7 +58,7 @@ export class BintrayPublisher extends Publisher {
let badGatewayCount = 0
for (let i = 0; i < 3; i++) {
try {
return await this.httpExecutor.doApiRequest<any>({
return await this.httpExecutor.doApiRequest<any>(configureRequestOptions({
hostname: "api.bintray.com",
path: `/content/${this.client.owner}/${this.client.repo}/${this.client.packageName}/${version.name}/${fileName}`,
method: "PUT",
Expand All @@ -68,7 +68,7 @@ export class BintrayPublisher extends Publisher {
"X-Bintray-Override": "1",
"X-Bintray-Publish": "1",
}
}, this.client.auth, requestProcessor)
}, this.client.auth), requestProcessor)
}
catch (e) {
if (e instanceof HttpError && e.response.statusCode === 502 && badGatewayCount++ < 3) {
Expand Down
31 changes: 16 additions & 15 deletions packages/electron-builder/src/publish/gitHubPublisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import BluebirdPromise from "bluebird-lst-c"
import { PublishOptions, Publisher } from "./publisher"
import { GithubOptions } from "electron-builder-http/out/publishOptions"
import { ClientRequest } from "http"
import { HttpError, githubRequest } from "electron-builder-http"
import { HttpError, configureRequestOptions } from "electron-builder-http"
import { NodeHttpExecutor } from "../util/nodeHttpExecutor"

export interface Release {
Expand Down Expand Up @@ -63,7 +63,7 @@ export class GitHubPublisher extends Publisher {

private async getOrCreateRelease(): Promise<Release | null> {
// we don't use "Get a release by tag name" because "tag name" means existing git tag, but we draft release and don't create git tag
const releases = await githubRequest<Array<Release>>(`/repos/${this.info.owner}/${this.info.repo}/releases`, this.token)
const releases = await this.githubRequest<Array<Release>>(`/repos/${this.info.owner}/${this.info.repo}/releases`, this.token)
for (const release of releases) {
if (release.tag_name === this.tag || release.tag_name === this.version) {
if (release.draft || release.prerelease) {
Expand Down Expand Up @@ -102,7 +102,7 @@ export class GitHubPublisher extends Publisher {
let badGatewayCount = 0
uploadAttempt: for (let i = 0; i < 3; i++) {
try {
return await this.httpExecutor.doApiRequest<any>({
return await this.httpExecutor.doApiRequest<any>(configureRequestOptions({
hostname: parsedUrl.hostname,
path: parsedUrl.path,
method: "POST",
Expand All @@ -112,18 +112,18 @@ export class GitHubPublisher extends Publisher {
"Content-Type": mime.lookup(fileName),
"Content-Length": dataLength
}
}, this.token, requestProcessor)
}, this.token), requestProcessor)
}
catch (e) {
if (e instanceof HttpError) {
if (e.response.statusCode === 422 && e.description != null && e.description.errors != null && e.description.errors[0].code === "already_exists") {
// delete old artifact and re-upload
log(`Artifact ${fileName} already exists, overwrite one`)

const assets = await githubRequest<Array<Asset>>(`/repos/${this.info.owner}/${this.info.repo}/releases/${release.id}/assets`, this.token, null)
const assets = await this.githubRequest<Array<Asset>>(`/repos/${this.info.owner}/${this.info.repo}/releases/${release.id}/assets`, this.token, null)
for (const asset of assets) {
if (asset!.name === fileName) {
await githubRequest<void>(`/repos/${this.info.owner}/${this.info.repo}/releases/assets/${asset!.id}`, this.token, null, "DELETE")
await this.githubRequest<void>(`/repos/${this.info.owner}/${this.info.repo}/releases/assets/${asset!.id}`, this.token, null, "DELETE")
continue uploadAttempt
}
}
Expand All @@ -142,7 +142,7 @@ export class GitHubPublisher extends Publisher {
}

private createRelease() {
return githubRequest<Release>(`/repos/${this.info.owner}/${this.info.repo}/releases`, this.token, {
return this.githubRequest<Release>(`/repos/${this.info.owner}/${this.info.repo}/releases`, this.token, {
tag_name: this.tag,
name: this.version,
draft: this.options.draft == null || this.options.draft,
Expand All @@ -153,7 +153,7 @@ export class GitHubPublisher extends Publisher {
// test only
//noinspection JSUnusedGlobalSymbols
async getRelease(): Promise<any> {
return githubRequest<Release>(`/repos/${this.info.owner}/${this.info.repo}/releases/${(await this._releasePromise).id}`, this.token)
return this.githubRequest<Release>(`/repos/${this.info.owner}/${this.info.repo}/releases/${(await this._releasePromise).id}`, this.token)
}

//noinspection JSUnusedGlobalSymbols
Expand All @@ -165,7 +165,7 @@ export class GitHubPublisher extends Publisher {

for (let i = 0; i < 3; i++) {
try {
return await githubRequest(`/repos/${this.info.owner}/${this.info.repo}/releases/${release.id}`, this.token, null, "DELETE")
return await this.githubRequest(`/repos/${this.info.owner}/${this.info.repo}/releases/${release.id}`, this.token, null, "DELETE")
}
catch (e) {
if (e instanceof HttpError) {
Expand All @@ -185,10 +185,11 @@ export class GitHubPublisher extends Publisher {
warn(`Cannot delete release ${release.id}`)
}

// async deleteOldReleases() {
// const releases = await githubRequest<Array<Release>>(`/repos/${this.owner}/${this.repo}/releases`, this.token)
// for (const release of releases) {
// await githubRequest(`/repos/${this.owner}/${this.repo}/releases/${release.id}`, this.token, null, "DELETE")
// }
// }
private githubRequest<T>(path: string, token: string | null, data: {[name: string]: any; } | null = null, method?: string): Promise<T> {
return this.httpExecutor.request<T>(configureRequestOptions({
hostname: "api.github.com",
path: path,
headers: {Accept: "application/vnd.github.v3+json"}
}, token, method), data)
}
}
Loading

0 comments on commit dd1320d

Please sign in to comment.