code-server/src/node/app/update.ts

385 lines
12 KiB
TypeScript
Raw Normal View History

2020-02-15 04:57:51 +07:00
import { field, logger } from "@coder/logger"
import zip from "adm-zip"
2020-02-15 04:57:51 +07:00
import * as cp from "child_process"
import * as fs from "fs-extra"
import * as http from "http"
import * as https from "https"
import * as os from "os"
import * as path from "path"
import * as semver from "semver"
import { Readable, Writable } from "stream"
import * as tar from "tar-fs"
import * as url from "url"
import * as util from "util"
import * as zlib from "zlib"
import { HttpCode, HttpError } from "../../common/http"
import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http"
import { settings as globalSettings, SettingsProvider, UpdateSettings } from "../settings"
2020-02-15 04:57:51 +07:00
import { tmpdir } from "../util"
import { ipcMain } from "../wrapper"
export interface Update {
checked: number
2020-02-15 04:57:51 +07:00
version: string
}
export interface LatestResponse {
name: string
}
2020-02-15 04:57:51 +07:00
/**
* Update HTTP provider.
*/
export class UpdateHttpProvider extends HttpProvider {
private update?: Promise<Update>
private updateInterval = 1000 * 60 * 60 * 24 // Milliseconds between update checks.
2020-02-15 04:57:51 +07:00
public constructor(
options: HttpProviderOptions,
public readonly enabled: boolean,
/**
* The URL for getting the latest version of code-server. Should return JSON
* that fulfills `LatestResponse`.
*/
private readonly latestUrl = "https://api.github.com/repos/cdr/code-server/releases/latest",
/**
* The URL for downloading a version of code-server. {{VERSION}} and
* {{RELEASE_NAME}} will be replaced (for example 2.1.0 and
* code-server-2.1.0-linux-x86_64.tar.gz).
*/
private readonly downloadUrl = "https://github.com/cdr/code-server/releases/download/{{VERSION}}/{{RELEASE_NAME}}",
/**
* Update information will be stored here. If not provided, the global
* settings will be used.
*/
private readonly settings: SettingsProvider<UpdateSettings> = globalSettings,
) {
2020-02-15 04:57:51 +07:00
super(options)
}
2020-03-03 01:43:02 +07:00
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
this.ensureAuthenticated(request)
this.ensureMethod(request)
if (!this.isRoot(route)) {
throw new HttpError("Not found", HttpCode.NotFound)
}
2020-03-03 01:43:02 +07:00
2020-02-15 04:57:51 +07:00
switch (route.base) {
case "/check":
this.getUpdate(true)
2020-03-03 01:43:02 +07:00
if (route.query && route.query.to) {
return {
redirect: Array.isArray(route.query.to) ? route.query.to[0] : route.query.to,
query: { to: undefined },
}
}
return this.getRoot(route, request)
case "/apply":
return this.tryUpdate(route, request)
case "/":
return this.getRoot(route, request)
2020-02-15 04:57:51 +07:00
}
2020-03-03 01:43:02 +07:00
throw new HttpError("Not found", HttpCode.NotFound)
2020-02-15 04:57:51 +07:00
}
public async getRoot(
route: Route,
request: http.IncomingMessage,
errorOrUpdate?: Update | Error,
): Promise<HttpResponse> {
if (request.headers["content-type"] === "application/json") {
if (!this.enabled) {
return {
content: {
isLatest: true,
},
}
}
const update = await this.getUpdate()
return {
content: {
...update,
isLatest: this.isLatestVersion(update),
},
}
}
2020-02-15 04:57:51 +07:00
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/update.html")
response.content = response.content
.replace(
/{{UPDATE_STATUS}}/,
errorOrUpdate && !(errorOrUpdate instanceof Error)
? `Updated to ${errorOrUpdate.version}`
: await this.getUpdateHtml(),
)
.replace(/{{ERROR}}/, errorOrUpdate instanceof Error ? `<div class="error">${errorOrUpdate.message}</div>` : "")
return this.replaceTemplates(route, response)
2020-02-15 04:57:51 +07:00
}
/**
* Query for and return the latest update.
*/
public async getUpdate(force?: boolean): Promise<Update> {
2020-02-15 04:57:51 +07:00
if (!this.enabled) {
throw new Error("updates are not enabled")
}
// Don't run multiple requests at a time.
2020-02-15 04:57:51 +07:00
if (!this.update) {
this.update = this._getUpdate(force)
this.update.then(() => (this.update = undefined))
2020-02-15 04:57:51 +07:00
}
return this.update
}
private async _getUpdate(force?: boolean): Promise<Update> {
const now = Date.now()
2020-02-15 04:57:51 +07:00
try {
let { update } = !force ? await this.settings.read() : { update: undefined }
if (!update || update.checked + this.updateInterval < now) {
const buffer = await this.request(this.latestUrl)
const data = JSON.parse(buffer.toString()) as LatestResponse
update = { checked: now, version: data.name }
await this.settings.write({ update })
}
2020-03-17 00:43:32 +07:00
logger.debug("got latest version", field("latest", update.version))
return update
2020-02-15 04:57:51 +07:00
} catch (error) {
logger.error("Failed to get latest version", field("error", error.message))
return {
checked: now,
version: "unknown",
}
2020-02-15 04:57:51 +07:00
}
}
public get currentVersion(): string {
return require(path.resolve(__dirname, "../../../package.json")).version
}
/**
* Return true if the currently installed version is the latest.
*/
public isLatestVersion(latest: Update): boolean {
2020-02-15 04:57:51 +07:00
const version = this.currentVersion
2020-03-17 00:43:32 +07:00
logger.debug("comparing versions", field("current", version), field("latest", latest.version))
try {
return latest.version === version || semver.lt(latest.version, version)
} catch (error) {
return true
}
2020-02-15 04:57:51 +07:00
}
private async getUpdateHtml(): Promise<string> {
if (!this.enabled) {
return "Updates are disabled"
}
const update = await this.getUpdate()
if (this.isLatestVersion(update)) {
return "No update available"
2020-02-15 04:57:51 +07:00
}
return `<button type="submit" class="apply -button">Update to ${update.version}</button>`
2020-02-15 04:57:51 +07:00
}
public async tryUpdate(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
2020-02-15 04:57:51 +07:00
try {
const update = await this.getUpdate()
2020-03-03 01:43:02 +07:00
if (!this.isLatestVersion(update)) {
await this.downloadAndApplyUpdate(update)
return this.getRoot(route, request, update)
2020-02-15 04:57:51 +07:00
}
return this.getRoot(route, request)
2020-02-15 04:57:51 +07:00
} catch (error) {
// For JSON requests propagate the error. Otherwise catch it so we can
// show the error inline with the update button instead of an error page.
if (request.headers["content-type"] === "application/json") {
throw error
}
return this.getRoot(route, error)
2020-02-15 04:57:51 +07:00
}
}
2020-03-04 23:31:59 +07:00
public async downloadAndApplyUpdate(update: Update, targetPath?: string): Promise<void> {
const releaseName = await this.getReleaseName(update)
const url = this.downloadUrl.replace("{{VERSION}}", update.version).replace("{{RELEASE_NAME}}", releaseName)
2020-02-15 04:57:51 +07:00
let downloadPath = path.join(tmpdir, "updates", releaseName)
fs.mkdirp(path.dirname(downloadPath))
2020-02-15 04:57:51 +07:00
const response = await this.requestResponse(url)
try {
if (downloadPath.endsWith(".tar.gz")) {
downloadPath = await this.extractTar(response, downloadPath)
} else {
downloadPath = await this.extractZip(response, downloadPath)
}
logger.debug("Downloaded update", field("path", downloadPath))
2020-02-26 05:20:47 +07:00
// The archive should have a directory inside at the top level with the
// same name as the archive.
const directoryPath = path.join(downloadPath, path.basename(downloadPath))
await fs.stat(directoryPath)
if (!targetPath) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2020-02-26 05:20:47 +07:00
targetPath = path.resolve(__dirname, "../../../")
}
// Move the old directory to prevent potential data loss.
const backupPath = path.resolve(targetPath, `../${path.basename(targetPath)}.${Date.now().toString()}`)
logger.debug("Replacing files", field("target", targetPath), field("backup", backupPath))
await fs.move(targetPath, backupPath)
// Move the new directory.
await fs.move(directoryPath, targetPath)
2020-02-15 04:57:51 +07:00
await fs.remove(downloadPath)
if (process.send) {
ipcMain().relaunch(update.version)
}
2020-02-15 04:57:51 +07:00
} catch (error) {
response.destroy(error)
throw error
}
}
private async extractTar(response: Readable, downloadPath: string): Promise<string> {
downloadPath = downloadPath.replace(/\.tar\.gz$/, "")
logger.debug("Extracting tar", field("path", downloadPath))
response.pause()
await fs.remove(downloadPath)
const decompress = zlib.createGunzip()
response.pipe(decompress as Writable)
response.on("error", (error) => decompress.destroy(error))
response.on("close", () => decompress.end())
const destination = tar.extract(downloadPath)
decompress.pipe(destination)
decompress.on("error", (error) => destination.destroy(error))
decompress.on("close", () => destination.end())
await new Promise((resolve, reject) => {
destination.on("finish", resolve)
destination.on("error", reject)
response.resume()
})
return downloadPath
}
private async extractZip(response: Readable, downloadPath: string): Promise<string> {
logger.debug("Downloading zip", field("path", downloadPath))
response.pause()
await fs.remove(downloadPath)
const write = fs.createWriteStream(downloadPath)
response.pipe(write)
response.on("error", (error) => write.destroy(error))
response.on("close", () => write.end())
await new Promise((resolve, reject) => {
write.on("error", reject)
write.on("close", resolve)
response.resume
})
const zipPath = downloadPath
downloadPath = downloadPath.replace(/\.zip$/, "")
await fs.remove(downloadPath)
logger.debug("Extracting zip", field("path", zipPath))
await new Promise((resolve, reject) => {
new zip(zipPath).extractAllToAsync(downloadPath, true, (error) => {
return error ? reject(error) : resolve()
})
})
await fs.remove(zipPath)
return downloadPath
}
/**
* Given an update return the name for the packaged archived.
*/
2020-03-04 23:31:59 +07:00
public async getReleaseName(update: Update): Promise<string> {
let target: string = os.platform()
2020-02-15 04:57:51 +07:00
if (target === "linux") {
const result = await util
.promisify(cp.exec)("ldd --version")
.catch((error) => ({
stderr: error.message,
stdout: "",
}))
if (/musl/.test(result.stderr) || /musl/.test(result.stdout)) {
target = "alpine"
}
}
let arch = os.arch()
if (arch === "x64") {
arch = "x86_64"
}
return `code-server-${update.version}-${target}-${arch}.${target === "darwin" ? "zip" : "tar.gz"}`
}
private async request(uri: string): Promise<Buffer> {
const response = await this.requestResponse(uri)
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
let bufferLength = 0
response.on("data", (chunk) => {
bufferLength += chunk.length
chunks.push(chunk)
})
response.on("error", reject)
response.on("end", () => {
resolve(Buffer.concat(chunks, bufferLength))
})
})
}
private async requestResponse(uri: string): Promise<http.IncomingMessage> {
let redirects = 0
const maxRedirects = 10
return new Promise((resolve, reject) => {
const request = (uri: string): void => {
logger.debug("Making request", field("uri", uri))
const httpx = uri.startsWith("https") ? https : http
const client = httpx.get(uri, { headers: { "User-Agent": "code-server" } }, (response) => {
2020-02-15 04:57:51 +07:00
if (
response.statusCode &&
response.statusCode >= 300 &&
response.statusCode < 400 &&
response.headers.location
) {
++redirects
if (redirects > maxRedirects) {
return reject(new Error("reached max redirects"))
}
response.destroy()
return request(url.resolve(uri, response.headers.location))
}
if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 400) {
return reject(new Error(`${uri}: ${response.statusCode || "500"}`))
2020-02-15 04:57:51 +07:00
}
resolve(response)
})
client.on("error", reject)
2020-02-15 04:57:51 +07:00
}
request(uri)
})
}
}