2020-02-14 22:57:51 +01:00
|
|
|
import { field, logger } from "@coder/logger"
|
2020-02-14 23:41:42 +01:00
|
|
|
import zip from "adm-zip"
|
2020-02-14 22:57:51 +01: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"
|
2020-02-20 22:50:01 +01:00
|
|
|
import { settings as globalSettings, SettingsProvider, UpdateSettings } from "../settings"
|
2020-02-14 22:57:51 +01:00
|
|
|
import { tmpdir } from "../util"
|
|
|
|
import { ipcMain } from "../wrapper"
|
|
|
|
|
|
|
|
export interface Update {
|
2020-02-14 23:41:42 +01:00
|
|
|
checked: number
|
2020-02-14 22:57:51 +01:00
|
|
|
version: string
|
|
|
|
}
|
|
|
|
|
2020-02-20 22:50:01 +01:00
|
|
|
export interface LatestResponse {
|
|
|
|
name: string
|
|
|
|
}
|
|
|
|
|
2020-02-14 22:57:51 +01:00
|
|
|
/**
|
|
|
|
* Update HTTP provider.
|
|
|
|
*/
|
|
|
|
export class UpdateHttpProvider extends HttpProvider {
|
2020-02-14 23:41:42 +01:00
|
|
|
private update?: Promise<Update>
|
|
|
|
private updateInterval = 1000 * 60 * 60 * 24 // Milliseconds between update checks.
|
2020-02-14 22:57:51 +01:00
|
|
|
|
2020-02-20 22:50:01 +01: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-14 22:57:51 +01:00
|
|
|
super(options)
|
|
|
|
}
|
|
|
|
|
|
|
|
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse | undefined> {
|
|
|
|
switch (route.base) {
|
2020-02-14 23:41:42 +01:00
|
|
|
case "/check":
|
|
|
|
this.ensureMethod(request)
|
|
|
|
this.getUpdate(true)
|
|
|
|
return { redirect: "/login" }
|
2020-02-14 22:57:51 +01:00
|
|
|
case "/": {
|
|
|
|
this.ensureMethod(request, ["GET", "POST"])
|
|
|
|
if (route.requestPath !== "/index.html") {
|
|
|
|
throw new HttpError("Not found", HttpCode.NotFound)
|
|
|
|
} else if (!this.authenticated(request)) {
|
|
|
|
return { redirect: "/login" }
|
|
|
|
}
|
|
|
|
|
|
|
|
switch (request.method) {
|
|
|
|
case "GET":
|
|
|
|
return this.getRoot(route)
|
|
|
|
case "POST":
|
|
|
|
return this.tryUpdate(route)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return undefined
|
|
|
|
}
|
|
|
|
|
|
|
|
public async getRoot(route: Route, error?: Error): Promise<HttpResponse> {
|
|
|
|
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/update.html")
|
|
|
|
response.content = response.content
|
|
|
|
.replace(/{{COMMIT}}/g, this.options.commit)
|
|
|
|
.replace(/{{BASE}}/g, this.base(route))
|
|
|
|
.replace(/{{UPDATE_STATUS}}/, await this.getUpdateHtml())
|
|
|
|
.replace(/{{ERROR}}/, error ? `<div class="error">${error.message}</div>` : "")
|
|
|
|
return response
|
|
|
|
}
|
|
|
|
|
|
|
|
public async handleWebSocket(): Promise<undefined> {
|
|
|
|
return undefined
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Query for and return the latest update.
|
|
|
|
*/
|
2020-02-14 23:41:42 +01:00
|
|
|
public async getUpdate(force?: boolean): Promise<Update> {
|
2020-02-14 22:57:51 +01:00
|
|
|
if (!this.enabled) {
|
|
|
|
throw new Error("updates are not enabled")
|
|
|
|
}
|
|
|
|
|
2020-02-20 22:50:01 +01:00
|
|
|
// Don't run multiple requests at a time.
|
2020-02-14 22:57:51 +01:00
|
|
|
if (!this.update) {
|
2020-02-14 23:41:42 +01:00
|
|
|
this.update = this._getUpdate(force)
|
|
|
|
this.update.then(() => (this.update = undefined))
|
2020-02-14 22:57:51 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
return this.update
|
|
|
|
}
|
|
|
|
|
2020-02-14 23:41:42 +01:00
|
|
|
private async _getUpdate(force?: boolean): Promise<Update> {
|
|
|
|
const now = Date.now()
|
2020-02-14 22:57:51 +01:00
|
|
|
try {
|
2020-02-20 22:50:01 +01:00
|
|
|
let { update } = !force ? await this.settings.read() : { update: undefined }
|
2020-02-14 23:41:42 +01:00
|
|
|
if (!update || update.checked + this.updateInterval < now) {
|
2020-02-20 22:50:01 +01:00
|
|
|
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-02-14 23:41:42 +01:00
|
|
|
}
|
|
|
|
logger.debug("Got latest version", field("latest", update.version))
|
|
|
|
return update
|
2020-02-14 22:57:51 +01:00
|
|
|
} catch (error) {
|
|
|
|
logger.error("Failed to get latest version", field("error", error.message))
|
2020-02-14 23:41:42 +01:00
|
|
|
return {
|
|
|
|
checked: now,
|
|
|
|
version: "unknown",
|
|
|
|
}
|
2020-02-14 22:57:51 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
public get currentVersion(): string {
|
|
|
|
return require(path.resolve(__dirname, "../../../package.json")).version
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Return true if the currently installed version is the latest.
|
|
|
|
*/
|
2020-02-14 23:41:42 +01:00
|
|
|
public isLatestVersion(latest: Update): boolean {
|
2020-02-14 22:57:51 +01:00
|
|
|
const version = this.currentVersion
|
|
|
|
logger.debug("Comparing versions", field("current", version), field("latest", latest.version))
|
2020-02-14 23:41:42 +01:00
|
|
|
try {
|
|
|
|
return latest.version === version || semver.lt(latest.version, version)
|
|
|
|
} catch (error) {
|
|
|
|
return true
|
|
|
|
}
|
2020-02-14 22:57:51 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
private async getUpdateHtml(): Promise<string> {
|
|
|
|
if (!this.enabled) {
|
|
|
|
return "Updates are disabled"
|
|
|
|
}
|
|
|
|
|
|
|
|
const update = await this.getUpdate()
|
2020-02-14 23:41:42 +01:00
|
|
|
if (this.isLatestVersion(update)) {
|
|
|
|
throw new Error("No update available")
|
2020-02-14 22:57:51 +01:00
|
|
|
}
|
|
|
|
|
2020-02-26 19:02:20 +01:00
|
|
|
return `<button type="submit" class="apply -button">Update to ${update.version}</button>`
|
2020-02-14 22:57:51 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
public async tryUpdate(route: Route): Promise<HttpResponse> {
|
|
|
|
try {
|
|
|
|
const update = await this.getUpdate()
|
2020-02-14 23:41:42 +01:00
|
|
|
if (this.isLatestVersion(update)) {
|
2020-02-14 22:57:51 +01:00
|
|
|
throw new Error("no update available")
|
|
|
|
}
|
|
|
|
await this.downloadUpdate(update)
|
|
|
|
return {
|
|
|
|
redirect: (Array.isArray(route.query.to) ? route.query.to[0] : route.query.to) || "/",
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
return this.getRoot(route, error)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-02-20 22:50:01 +01:00
|
|
|
public async downloadUpdate(update: Update, targetPath?: string, target?: string): Promise<void> {
|
|
|
|
const releaseName = await this.getReleaseName(update, target)
|
|
|
|
const url = this.downloadUrl.replace("{{VERSION}}", update.version).replace("{{RELEASE_NAME}}", releaseName)
|
2020-02-14 22:57:51 +01:00
|
|
|
|
2020-02-20 22:50:01 +01:00
|
|
|
let downloadPath = path.join(tmpdir, "updates", releaseName)
|
|
|
|
fs.mkdirp(path.dirname(downloadPath))
|
2020-02-14 22:57:51 +01: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-25 23:20:47 +01: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)
|
2020-02-20 22:50:01 +01:00
|
|
|
|
|
|
|
if (!targetPath) {
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
2020-02-25 23:20:47 +01:00
|
|
|
targetPath = path.resolve(__dirname, "../../../")
|
2020-02-20 22:50:01 +01:00
|
|
|
}
|
|
|
|
|
2020-02-25 23:20:47 +01:00
|
|
|
logger.debug("Replacing files", field("target", targetPath))
|
|
|
|
await fs.move(directoryPath, targetPath, { overwrite: true })
|
2020-02-14 22:57:51 +01:00
|
|
|
|
2020-02-20 22:50:01 +01:00
|
|
|
await fs.remove(downloadPath)
|
|
|
|
|
|
|
|
if (process.send) {
|
|
|
|
ipcMain().relaunch(update.version)
|
|
|
|
}
|
2020-02-14 22:57:51 +01: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-02-20 22:50:01 +01:00
|
|
|
private async getReleaseName(update: Update, target: string = os.platform()): Promise<string> {
|
2020-02-14 22:57:51 +01: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))
|
2020-02-20 22:50:01 +01:00
|
|
|
const httpx = uri.startsWith("https") ? https : http
|
|
|
|
httpx.get(uri, { headers: { "User-Agent": "code-server" } }, (response) => {
|
2020-02-14 22:57:51 +01: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(`${response.statusCode || "500"}`))
|
|
|
|
}
|
|
|
|
|
|
|
|
resolve(response)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
request(uri)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|