import { field, logger } from "@coder/logger" import zip from "adm-zip" 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" import { tmpdir } from "../util" import { ipcMain } from "../wrapper" export interface Update { checked: number version: string } export interface LatestResponse { name: string } /** * Update HTTP provider. */ export class UpdateHttpProvider extends HttpProvider { private update?: Promise private updateInterval = 1000 * 60 * 60 * 24 // Milliseconds between update checks. 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 = globalSettings, ) { super(options) } public async handleRequest(route: Route, request: http.IncomingMessage): Promise { switch (route.base) { case "/check": this.ensureMethod(request) this.getUpdate(true) return { redirect: "/login" } 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 { 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 ? `
${error.message}
` : "") return response } public async handleWebSocket(): Promise { return undefined } /** * Query for and return the latest update. */ public async getUpdate(force?: boolean): Promise { if (!this.enabled) { throw new Error("updates are not enabled") } // Don't run multiple requests at a time. if (!this.update) { this.update = this._getUpdate(force) this.update.then(() => (this.update = undefined)) } return this.update } private async _getUpdate(force?: boolean): Promise { const now = Date.now() 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 }) } logger.debug("Got latest version", field("latest", update.version)) return update } catch (error) { logger.error("Failed to get latest version", field("error", error.message)) return { checked: now, version: "unknown", } } } 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 { const version = this.currentVersion 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 } } private async getUpdateHtml(): Promise { if (!this.enabled) { return "Updates are disabled" } const update = await this.getUpdate() if (this.isLatestVersion(update)) { throw new Error("No update available") } return `
Current: ${this.currentVersion}
` } public async tryUpdate(route: Route): Promise { try { const update = await this.getUpdate() if (this.isLatestVersion(update)) { 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) } } public async downloadUpdate(update: Update, targetPath?: string, target?: string): Promise { const releaseName = await this.getReleaseName(update, target) const url = this.downloadUrl.replace("{{VERSION}}", update.version).replace("{{RELEASE_NAME}}", releaseName) let downloadPath = path.join(tmpdir, "updates", releaseName) fs.mkdirp(path.dirname(downloadPath)) 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)) // The archive should have a code-server directory at the top level. try { const stat = await fs.stat(path.join(downloadPath, "code-server")) if (!stat.isDirectory()) { throw new Error("ENOENT") } } catch (error) { throw new Error("no code-server directory found in downloaded archive") } // The archive might contain a binary or it might contain loose files. // This is probably stupid but just check if `node` exists since we // package it with the loose files. const isBinary = !(await fs.pathExists(path.join(downloadPath, "code-server/node"))) // In the binary we need to replace the binary, otherwise we can replace // the directory. if (!targetPath) { // eslint-disable-next-line @typescript-eslint/no-explicit-any targetPath = (process.versions as any).nbin ? process.argv[0] : path.resolve(__dirname, "../../../") } // If we're currently running a binary it must be unlinked to avoid // ETXTBSY. try { const stat = await fs.stat(targetPath) if (stat.isFile()) { await fs.unlink(targetPath) } } catch (error) { if (error.code !== "ENOENT") { throw error } } logger.debug("Replacing files", field("target", targetPath), field("isBinary", isBinary)) if (isBinary) { await fs.move(path.join(downloadPath, "code-server/code-server"), targetPath, { overwrite: true }) } else { await fs.move(path.join(downloadPath, "code-server"), targetPath, { overwrite: true }) } await fs.remove(downloadPath) if (process.send) { ipcMain().relaunch(update.version) } } catch (error) { response.destroy(error) throw error } } private async extractTar(response: Readable, downloadPath: string): Promise { 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 { 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. */ private async getReleaseName(update: Update, target: string = os.platform()): Promise { 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 { 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 { 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 httpx.get(uri, { headers: { "User-Agent": "code-server" } }, (response) => { 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) }) } }