Archived
1
0

Add https server

This commit is contained in:
Asher
2019-07-11 17:12:52 -05:00
parent 81862d4fa1
commit 2b2aa9a211
20 changed files with 405 additions and 186 deletions

266
src/channel.ts Normal file
View File

@ -0,0 +1,266 @@
import * as path from "path";
import { getPathFromAmdModule } from "vs/base/common/amd";
import { VSBuffer } from "vs/base/common/buffer";
import { Emitter, Event } from "vs/base/common/event";
import { IDisposable } from "vs/base/common/lifecycle";
import { OS } from "vs/base/common/platform";
import { URI, UriComponents } from "vs/base/common/uri";
import { transformOutgoingURIs } from "vs/base/common/uriIpc";
import { IServerChannel } from "vs/base/parts/ipc/common/ipc";
import { IDiagnosticInfo } from "vs/platform/diagnostics/common/diagnosticsService";
import { IEnvironmentService } from "vs/platform/environment/common/environment";
import { IExtensionDescription, ExtensionIdentifier } from "vs/platform/extensions/common/extensions";
import { FileDeleteOptions, FileOverwriteOptions, FileType, IStat, IWatchOptions, FileOpenOptions } from "vs/platform/files/common/files";
import { ILogService } from "vs/platform/log/common/log";
import pkg from "vs/platform/product/node/package";
import product from "vs/platform/product/node/product";
import { IRemoteAgentEnvironment } from "vs/platform/remote/common/remoteAgentEnvironment";
import { ExtensionScanner, ExtensionScannerInput } from "vs/workbench/services/extensions/node/extensionPoints";
import { DiskFileSystemProvider } from "vs/workbench/services/files/node/diskFileSystemProvider";
import { getUriTransformer } from "vs/server/src/util";
/**
* Extend the file provider to allow unwatching.
*/
class Watcher extends DiskFileSystemProvider {
public readonly watches = new Map<number, IDisposable>();
public dispose(): void {
this.watches.forEach((w) => w.dispose());
this.watches.clear();
super.dispose();
}
public _watch(req: number, resource: URI, opts: IWatchOptions): void {
this.watches.set(req, this.watch(resource, opts));
}
public unwatch(req: number): void {
this.watches.get(req)!.dispose();
this.watches.delete(req);
}
}
/**
* See: src/vs/platform/remote/common/remoteAgentFileSystemChannel.ts.
*/
export class FileProviderChannel implements IServerChannel, IDisposable {
private readonly provider: DiskFileSystemProvider;
private readonly watchers = new Map<string, Watcher>();
public constructor(private readonly logService: ILogService) {
this.provider = new DiskFileSystemProvider(this.logService);
}
public listen(context: any, event: string, args?: any): Event<any> {
switch (event) {
// This is where the actual file changes are sent. The watch method just
// adds things that will fire here. That means we have to split up
// watchers based on the session otherwise sessions would get events for
// other sessions. There is also no point in having the watcher unless
// something is listening. I'm not sure there is a different way to
// dispose, anyway.
case "filechange":
const session = args[0];
const emitter = new Emitter({
onFirstListenerAdd: () => {
const provider = new Watcher(this.logService);
this.watchers.set(session, provider);
const transformer = getUriTransformer(context.remoteAuthority);
provider.onDidChangeFile((events) => {
emitter.fire(events.map((event) => ({
...event,
resource: transformer.transformOutgoing(event.resource),
})));
});
provider.onDidErrorOccur((event) => emitter.fire(event));
},
onLastListenerRemove: () => {
this.watchers.get(session)!.dispose();
this.watchers.delete(session);
},
});
return emitter.event;
}
throw new Error(`Invalid listen "${event}"`);
}
public call(_: unknown, command: string, args?: any): Promise<any> {
switch (command) {
case "stat": return this.stat(args[0]);
case "open": return this.open(args[0], args[1]);
case "close": return this.close(args[0]);
case "read": return this.read(args[0], args[1], args[2]);
case "write": return this.write(args[0], args[1], args[2], args[3], args[4]);
case "delete": return this.delete(args[0], args[1]);
case "mkdir": return this.mkdir(args[0]);
case "readdir": return this.readdir(args[0]);
case "rename": return this.rename(args[0], args[1], args[2]);
case "copy": return this.copy(args[0], args[1], args[2]);
case "watch": return this.watch(args[0], args[1], args[2], args[3]);
case "unwatch": return this.unwatch(args[0], args[1]);
}
throw new Error(`Invalid call "${command}"`);
}
public dispose(): void {
this.watchers.forEach((w) => w.dispose());
this.watchers.clear();
}
private async stat(resource: UriComponents): Promise<IStat> {
return this.provider.stat(URI.from(resource));
}
private async open(resource: UriComponents, opts: FileOpenOptions): Promise<number> {
return this.provider.open(URI.from(resource), opts);
}
private async close(fd: number): Promise<void> {
return this.provider.close(fd);
}
private async read(fd: number, pos: number, length: number): Promise<[VSBuffer, number]> {
const buffer = VSBuffer.alloc(length);
const bytesRead = await this.provider.read(fd, pos, buffer.buffer, 0, length);
return [buffer, bytesRead];
}
private write(fd: number, pos: number, buffer: VSBuffer, offset: number, length: number): Promise<number> {
return this.provider.write(fd, pos, buffer.buffer, offset, length);
}
private async delete(resource: UriComponents, opts: FileDeleteOptions): Promise<void> {
return this.provider.delete(URI.from(resource), opts);
}
private async mkdir(resource: UriComponents): Promise<void> {
return this.provider.mkdir(URI.from(resource));
}
private async readdir(resource: UriComponents): Promise<[string, FileType][]> {
return this.provider.readdir(URI.from(resource));
}
private async rename(resource: UriComponents, target: UriComponents, opts: FileOverwriteOptions): Promise<void> {
return this.provider.rename(URI.from(resource), URI.from(target), opts);
}
private copy(resource: UriComponents, target: UriComponents, opts: FileOverwriteOptions): Promise<void> {
return this.provider.copy(URI.from(resource), URI.from(target), opts);
}
private async watch(session: string, req: number, resource: UriComponents, opts: IWatchOptions): Promise<void> {
this.watchers.get(session)!._watch(req, URI.from(resource), opts);
}
private async unwatch(session: string, req: number): Promise<void> {
this.watchers.get(session)!.unwatch(req);
}
}
/**
* See: src/vs/workbench/services/remote/common/remoteAgentEnvironmentChannel.ts.
*/
export class ExtensionEnvironmentChannel implements IServerChannel {
public constructor(
private readonly environment: IEnvironmentService,
private readonly log: ILogService,
) {}
public listen(_: unknown, event: string): Event<any> {
throw new Error(`Invalid listen "${event}"`);
}
public async call(context: any, command: string, args?: any): Promise<any> {
switch (command) {
case "getEnvironmentData":
return transformOutgoingURIs(
await this.getEnvironmentData(args.language),
getUriTransformer(context.remoteAuthority),
);
case "getDiagnosticInfo": return this.getDiagnosticInfo();
case "disableTelemetry": return this.disableTelemetry();
}
throw new Error(`Invalid call "${command}"`);
}
private async getEnvironmentData(locale: string): Promise<IRemoteAgentEnvironment> {
return {
pid: process.pid,
appRoot: URI.file(this.environment.appRoot),
appSettingsHome: this.environment.appSettingsHome,
settingsPath: this.environment.machineSettingsHome,
logsPath: URI.file(this.environment.logsPath),
extensionsPath: URI.file(this.environment.extensionsPath),
extensionHostLogsPath: URI.file(path.join(this.environment.logsPath, "extension-host")),
globalStorageHome: URI.file(this.environment.globalStorageHome),
userHome: URI.file(this.environment.userHome),
extensions: await this.scanExtensions(locale),
os: OS,
};
}
private async scanExtensions(locale: string): Promise<IExtensionDescription[]> {
const root = getPathFromAmdModule(require, "");
const translations = {}; // TODO: translations
// TODO: there is also this.environment.extensionDevelopmentLocationURI to look into.
const scanBuiltin = async (): Promise<IExtensionDescription[]> => {
const input = new ExtensionScannerInput(
pkg.version, product.commit, locale, !!process.env.VSCODE_DEV,
path.resolve(root, "../extensions"),
true,
false,
translations,
);
const extensions = await ExtensionScanner.scanExtensions(input, this.log);
// TODO: there is more to do if process.env.VSCODE_DEV is true.
return extensions;
};
const scanInstalled = async (): Promise<IExtensionDescription[]> => {
const input = new ExtensionScannerInput(
pkg.version, product.commit, locale, !!process.env.VSCODE_DEV,
this.environment.extensionsPath, false, true, translations,
);
return ExtensionScanner.scanExtensions(input, this.log);
};
return Promise.all([scanBuiltin(), scanInstalled()]).then((allExtensions) => {
// It's possible to get duplicates.
const uniqueExtensions = new Map<string, IExtensionDescription>();
allExtensions.forEach((extensions) => {
extensions.forEach((extension) => {
const id = ExtensionIdentifier.toKey(extension.identifier);
if (uniqueExtensions.has(id)) {
const oldPath = uniqueExtensions.get(id)!.extensionLocation.fsPath;
const newPath = extension.extensionLocation.fsPath;
this.log.warn(
`Extension ${id} in ${oldPath} has been overridden ${newPath}`,
);
}
uniqueExtensions.set(id, extension);
});
});
const finalExtensions = <IExtensionDescription[]>[];
uniqueExtensions.forEach((e) => finalExtensions.push(e));
return finalExtensions;
});
}
private getDiagnosticInfo(): Promise<IDiagnosticInfo> {
throw new Error("not implemented");
}
private disableTelemetry(): Promise<void> {
throw new Error("not implemented");
}
}

157
src/cli.ts Normal file
View File

@ -0,0 +1,157 @@
import * as os from "os";
import { validatePaths } from "vs/code/node/paths";
import { parseMainProcessArgv } from "vs/platform/environment/node/argvHelper";
import { ParsedArgs } from "vs/platform/environment/common/environment";
import { buildHelpMessage, buildVersionMessage, options } from "vs/platform/environment/node/argv";
import product from "vs/platform/product/node/product";
import pkg from "vs/platform/product/node/package";
import { MainServer, WebviewServer } from "vs/server/src/server";
import "vs/server/src/tar";
import { generateCertificate } from "vs/server/src/util";
interface Args extends ParsedArgs {
"allow-http"?: boolean;
cert?: string;
"cert-key"?: string;
"extra-builtin-extensions-dir"?: string;
"extra-extensions-dir"?: string;
host?: string;
"no-auth"?: boolean;
open?: string;
port?: string;
socket?: string;
"webview-port"?: string;
"webview-socket"?: string;
}
// The last item is _ which is like -- so our options need to come before it.
const last = options.pop()!;
// Remove options that won't work or don't make sense.
let i = options.length;
while (i--) {
switch (options[i].id) {
case "add":
case "diff":
case "file-uri":
case "folder-uri":
case "goto":
case "new-window":
case "reuse-window":
case "wait":
case "disable-gpu":
// TODO: pretty sure these don't work but not 100%.
case "max-memory":
case "prof-startup":
case "inspect-extensions":
case "inspect-brk-extensions":
options.splice(i, 1);
break;
}
}
options.push({ id: "allow-http", type: "boolean", cat: "o", description: "Allow http connections." });
options.push({ id: "cert", type: "string", cat: "o", description: "Path to certificate." });
options.push({ id: "cert-key", type: "string", cat: "o", description: "Path to certificate key." });
options.push({ id: "extra-builtin-extensions-dir", type: "string", cat: "o", description: "Path to extra builtin extension directory." });
options.push({ id: "extra-extensions-dir", type: "string", cat: "o", description: "Path to extra user extension directory." });
options.push({ id: "host", type: "string", cat: "o", description: "Host for the main and webview servers." });
options.push({ id: "no-auth", type: "string", cat: "o", description: "Disable password authentication." });
options.push({ id: "open", type: "boolean", cat: "o", description: "Open in the browser on startup." });
options.push({ id: "port", type: "string", cat: "o", description: "Port for the main server." });
options.push({ id: "socket", type: "string", cat: "o", description: "Listen on a socket instead of host:port." });
options.push({ id: "webview-port", type: "string", cat: "o", description: "Port for the webview server." });
options.push({ id: "webview-socket", type: "string", cat: "o", description: "Listen on a socket instead of host:port." });
options.push(last);
interface IMainCli {
main: (argv: ParsedArgs) => Promise<void>;
}
const main = async (): Promise<void> => {
const args = validatePaths(parseMainProcessArgv(process.argv)) as Args;
if (!product.extensionsGallery) {
product.extensionsGallery = {
serviceUrl: process.env.SERVICE_URL || "https://v1.extapi.coder.com",
itemUrl: process.env.ITEM_URL || "",
controlUrl: "",
recommendationsUrl: "",
};
}
const version = `${(pkg as any).codeServerVersion || "development"}-vsc${pkg.version}`;
if (args.help) {
const executable = `${product.applicationName}${os.platform() === "win32" ? ".exe" : ""}`;
return console.log(buildHelpMessage(
product.nameLong, executable,
version,
undefined,
false,
));
}
if (args.version) {
return console.log(buildVersionMessage(version, product.commit));
}
const shouldSpawnCliProcess = (): boolean => {
return !!args["install-source"]
|| !!args["list-extensions"]
|| !!args["install-extension"]
|| !!args["uninstall-extension"]
|| !!args["locate-extension"]
|| !!args["telemetry"];
};
if (shouldSpawnCliProcess()) {
const cli = await new Promise<IMainCli>((c, e) => require(["vs/code/node/cliProcessMain"], c, e));
await cli.main(args);
// There is some WriteStream instance keeping it open so force an exit.
return process.exit(0);
}
const options = {
host: args["host"]
|| (args["no-auth"] || args["allow-http"] ? "localhost" : "0.0.0.0"),
allowHttp: args["allow-http"],
cert: args["cert"],
certKey: args["cert"],
};
if (!options.allowHttp && (!options.cert || !options.certKey)) {
const { cert, certKey } = await generateCertificate();
options.cert = cert;
options.certKey = certKey;
}
const webviewPort = typeof args["webview-port"] !== "undefined"
&& parseInt(args["webview-port"], 10) || 8444;
const webviewServer = new WebviewServer({
...options,
port: webviewPort,
socket: args["webview-socket"],
});
const port = typeof args.port !== "undefined" && parseInt(args.port, 10) || 8443;
const server = new MainServer({
...options,
port,
socket: args.socket,
}, webviewServer, args);
const [webviewAddress, serverAddress] = await Promise.all([
webviewServer.listen(),
server.listen()
]);
console.log(`Main server listening on ${serverAddress}`);
console.log(`Webview server listening on ${webviewAddress}`);
};
main().catch((error) => {
console.error(error);
process.exit(1);
});

177
src/connection.ts Normal file
View File

@ -0,0 +1,177 @@
import * as cp from "child_process";
import { getPathFromAmdModule } from "vs/base/common/amd";
import { VSBuffer } from "vs/base/common/buffer";
import { Emitter } from "vs/base/common/event";
import { ISocket } from "vs/base/parts/ipc/common/ipc.net";
import { NodeSocket, WebSocketNodeSocket } from "vs/base/parts/ipc/node/ipc.net";
import { ILogService } from "vs/platform/log/common/log";
import { IExtHostReadyMessage, IExtHostSocketMessage } from "vs/workbench/services/extensions/common/extensionHostProtocol";
import { Protocol } from "vs/server/src/protocol";
import { uriTransformerPath } from "vs/server/src/util";
export abstract class Connection {
private readonly _onClose = new Emitter<void>();
public readonly onClose = this._onClose.event;
private timeout: NodeJS.Timeout | undefined;
private readonly wait = 1000 * 60;
private closed: boolean = false;
public constructor(protected protocol: Protocol) {
// onClose seems to mean we want to disconnect, so close immediately.
protocol.onClose(() => this.close());
// If the socket closes, we want to wait before closing so we can
// reconnect in the meantime.
protocol.onSocketClose(() => {
this.timeout = setTimeout(() => {
this.close();
}, this.wait);
});
}
/**
* Set up the connection on a new socket.
*/
public reconnect(protocol: Protocol, buffer: VSBuffer): void {
if (this.closed) {
throw new Error("Cannot reconnect to closed connection");
}
clearTimeout(this.timeout as any); // Not sure why the type doesn't work.
this.protocol = protocol;
this.connect(protocol.getSocket(), buffer);
}
/**
* Close and clean up connection. This will also kill the socket the
* connection is on. Probably not safe to reconnect once this has happened.
*/
protected close(): void {
if (!this.closed) {
this.closed = true;
this.protocol.sendDisconnect();
this.dispose();
this.protocol.dispose();
this._onClose.fire();
}
}
/**
* Clean up the connection.
*/
protected abstract dispose(): void;
/**
* Connect to a new socket.
*/
protected abstract connect(socket: ISocket, buffer: VSBuffer): void;
}
/**
* Used for all the IPC channels.
*/
export class ManagementConnection extends Connection {
protected dispose(): void {
// Nothing extra to do here.
}
protected connect(socket: ISocket, buffer: VSBuffer): void {
this.protocol.beginAcceptReconnection(socket, buffer);
this.protocol.endAcceptReconnection();
}
}
/**
* Manage the extension host process.
*/
export class ExtensionHostConnection extends Connection {
private process: cp.ChildProcess;
public constructor(protocol: Protocol, private readonly log: ILogService) {
super(protocol);
const socket = this.protocol.getSocket();
const buffer = this.protocol.readEntireBuffer();
this.process = this.spawn(socket, buffer);
}
protected dispose(): void {
this.process.kill();
}
protected connect(socket: ISocket, buffer: VSBuffer): void {
this.sendInitMessage(socket, buffer);
}
private sendInitMessage(nodeSocket: ISocket, buffer: VSBuffer): void {
const socket = nodeSocket instanceof NodeSocket
? nodeSocket.socket
: (nodeSocket as WebSocketNodeSocket).socket.socket;
socket.pause();
const initMessage: IExtHostSocketMessage = {
type: "VSCODE_EXTHOST_IPC_SOCKET",
initialDataChunk: (buffer.buffer as Buffer).toString("base64"),
skipWebSocketFrames: nodeSocket instanceof NodeSocket,
};
this.process.send(initMessage, socket);
}
private spawn(socket: ISocket, buffer: VSBuffer): cp.ChildProcess {
const proc = cp.fork(
getPathFromAmdModule(require, "bootstrap-fork"),
[
"--type=extensionHost",
`--uriTransformerPath=${uriTransformerPath()}`
],
{
env: {
...process.env,
AMD_ENTRYPOINT: "vs/workbench/services/extensions/node/extensionHostProcess",
PIPE_LOGGING: "true",
VERBOSE_LOGGING: "true",
VSCODE_EXTHOST_WILL_SEND_SOCKET: "true",
VSCODE_HANDLES_UNCAUGHT_ERRORS: "true",
VSCODE_LOG_STACK: "false",
},
silent: true,
},
);
proc.on("error", (error) => {
console.error(error);
this.close();
});
proc.on("exit", (code, signal) => {
console.error("Extension host exited", { code, signal });
this.close();
});
proc.stdout.setEncoding("utf8");
proc.stderr.setEncoding("utf8");
proc.stdout.on("data", (data) => this.log.info("Extension host stdout", data));
proc.stderr.on("data", (data) => this.log.error("Extension host stderr", data));
proc.on("message", (event) => {
if (event && event.type === "__$console") {
const severity = this.log[event.severity] ? event.severity : "info";
this.log[severity]("Extension host", event.arguments);
}
});
const listen = (message: IExtHostReadyMessage) => {
if (message.type === "VSCODE_EXTHOST_IPC_READY") {
proc.removeListener("message", listen);
this.sendInitMessage(socket, buffer);
}
};
proc.on("message", listen);
return proc;
}
}

170
src/insights.ts Normal file
View File

@ -0,0 +1,170 @@
/**
* Used by node
*/
import * as https from "https";
import * as os from "os";
export const defaultClient = "filler";
export class TelemetryClient {
public channel = {
setUseDiskRetryCaching: (): void => undefined,
};
public constructor() {
//
}
public trackEvent(options: {
name: string;
properties: object;
measurements: object;
}): void {
if (!options.properties) {
options.properties = {};
}
if (!options.measurements) {
options.measurements = {};
}
try {
const cpus = os.cpus();
// tslint:disable-next-line:no-any
(options.measurements as any).cpu = {
model: cpus[0].model,
cores: cpus.length,
};
} catch (ex) {
// Nothin
}
try {
// tslint:disable-next-line:no-any
(options.measurements as any).memory = {
virtual_free: os.freemem(),
virtual_used: os.totalmem(),
};
} catch (ex) {
//
}
try {
// tslint:disable:no-any
(options.properties as any)["common.shell"] = os.userInfo().shell;
(options.properties as any)["common.release"] = os.release();
(options.properties as any)["common.arch"] = os.arch();
// tslint:enable:no-any
} catch (ex) {
//
}
try {
// tslint:disable-next-line:no-any
(options.properties as any)["common.machineId"] = machineIdSync();
} catch (ex) {
//
}
try {
const request = https.request({
host: "v1.telemetry.coder.com",
port: 443,
path: "/track",
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
request.on("error", () => {
// Do nothing, we don"t really care
});
request.write(JSON.stringify(options));
request.end();
} catch (ex) {
// Suppress all errs
}
}
public flush(options: {
readonly callback: () => void;
}): void {
options.callback();
}
}
// Taken from https://github.com/automation-stack/node-machine-id
import { exec, execSync } from "child_process";
import { createHash } from "crypto";
const isWindowsProcessMixedOrNativeArchitecture = (): "" | "mixed" | "native" => {
// detect if the node binary is the same arch as the Windows OS.
// or if this is 32 bit node on 64 bit windows.
if (process.platform !== "win32") {
return "";
}
if (process.arch === "ia32" && process.env.hasOwnProperty("PROCESSOR_ARCHITEW6432")) {
return "mixed";
}
return "native";
};
let { platform } = process,
win32RegBinPath = {
native: "%windir%\\System32",
mixed: "%windir%\\sysnative\\cmd.exe /c %windir%\\System32",
"": "",
},
guid = {
darwin: "ioreg -rd1 -c IOPlatformExpertDevice",
win32: `${win32RegBinPath[isWindowsProcessMixedOrNativeArchitecture()]}\\REG ` +
"QUERY HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography " +
"/v MachineGuid",
linux: "( cat /var/lib/dbus/machine-id /etc/machine-id 2> /dev/null || hostname ) | head -n 1 || :",
freebsd: "kenv -q smbios.system.uuid || sysctl -n kern.hostuuid",
// tslint:disable-next-line:no-any
} as any;
const hash = (guid: string): string => {
return createHash("sha256").update(guid).digest("hex");
};
const expose = (result: string): string => {
switch (platform) {
case "darwin":
return result
.split("IOPlatformUUID")[1]
.split("\n")[0].replace(/\=|\s+|\"/ig, "")
.toLowerCase();
case "win32":
return result
.toString()
.split("REG_SZ")[1]
.replace(/\r+|\n+|\s+/ig, "")
.toLowerCase();
case "linux":
return result
.toString()
.replace(/\r+|\n+|\s+/ig, "")
.toLowerCase();
case "freebsd":
return result
.toString()
.replace(/\r+|\n+|\s+/ig, "")
.toLowerCase();
default:
throw new Error(`Unsupported platform: ${process.platform}`);
}
};
let cachedMachineId: string | undefined;
const machineIdSync = (): string => {
if (cachedMachineId) {
return cachedMachineId;
}
let id: string = expose(execSync(guid[platform]).toString());
cachedMachineId = hash(id);
return cachedMachineId;
};

109
src/protocol.ts Normal file
View File

@ -0,0 +1,109 @@
import * as crypto from "crypto";
import * as net from "net";
import { VSBuffer } from "vs/base/common/buffer";
import { NodeSocket, WebSocketNodeSocket } from "vs/base/parts/ipc/node/ipc.net";
import { PersistentProtocol } from "vs/base/parts/ipc/common/ipc.net";
import { AuthRequest, ConnectionTypeRequest, HandshakeMessage } from "vs/platform/remote/common/remoteAgentConnection";
export interface SocketOptions {
readonly reconnectionToken: string;
readonly reconnection: boolean;
readonly skipWebSocketFrames: boolean;
}
export class Protocol extends PersistentProtocol {
private disposed: boolean = false;
public constructor(
secWebsocketKey: string,
socket: net.Socket,
public readonly options: SocketOptions,
) {
super(
options.skipWebSocketFrames
? new NodeSocket(socket)
: new WebSocketNodeSocket(new NodeSocket(socket)),
);
socket.on("error", () => this.dispose());
socket.on("end", () => this.dispose());
// This magic value is specified by the websocket spec.
const magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
const reply = crypto.createHash("sha1")
.update(secWebsocketKey + magic)
.digest("base64");
socket.write([
"HTTP/1.1 101 Switching Protocols",
"Upgrade: websocket",
"Connection: Upgrade",
`Sec-WebSocket-Accept: ${reply}`,
].join("\r\n") + "\r\n\r\n");
}
public sendDisconnect(): void {
if (!this.disposed) {
super.sendDisconnect();
}
}
public dispose(error?: Error): void {
if (!this.disposed) {
this.disposed = true;
if (error) {
this.sendMessage({ type: "error", reason: error.message });
}
super.dispose();
this.getSocket().dispose();
}
}
/**
* Perform a handshake to get a connection request.
*/
public handshake(): Promise<ConnectionTypeRequest> {
return new Promise((resolve, reject) => {
const handler = this.onControlMessage((rawMessage) => {
try {
const message = JSON.parse(rawMessage.toString());
switch (message.type) {
case "auth": return this.authenticate(message);
case "connectionType":
handler.dispose();
return resolve(message);
default: throw new Error("Unrecognized message type");
}
} catch (error) {
handler.dispose();
reject(error);
}
});
});
}
/**
* TODO: This ignores the authentication process entirely for now.
*/
private authenticate(_message: AuthRequest): void {
this.sendMessage({
type: "sign",
data: "",
});
}
/**
* TODO: implement.
*/
public tunnel(): void {
throw new Error("Tunnel is not implemented yet");
}
/**
* Send a handshake message. In the case of the extension host, it just sends
* back a debug port.
*/
public sendMessage(message: HandshakeMessage | { debugPort?: number } ): void {
this.sendControl(VSBuffer.fromString(JSON.stringify(message)));
}
}

458
src/server.ts Normal file
View File

@ -0,0 +1,458 @@
import * as fs from "fs";
import * as http from "http";
import * as https from "https";
import * as net from "net";
import * as path from "path";
import * as tls from "tls";
import * as util from "util";
import * as url from "url";
import { Emitter } from "vs/base/common/event";
import { sanitizeFilePath } from "vs/base/common/extpath";
import { getMediaMime } from "vs/base/common/mime";
import { extname } from "vs/base/common/path";
import { UriComponents, URI } from "vs/base/common/uri";
import { IPCServer, ClientConnectionEvent, StaticRouter } from "vs/base/parts/ipc/common/ipc";
import { mkdirp } from "vs/base/node/pfs";
import { LogsDataCleaner } from "vs/code/electron-browser/sharedProcess/contrib/logsDataCleaner";
import { IConfigurationService } from "vs/platform/configuration/common/configuration";
import { ConfigurationService } from "vs/platform/configuration/node/configurationService";
import { IDialogService } from "vs/platform/dialogs/common/dialogs";
import { DialogChannelClient } from "vs/platform/dialogs/node/dialogIpc";
import { IEnvironmentService, ParsedArgs } from "vs/platform/environment/common/environment";
import { EnvironmentService } from "vs/platform/environment/node/environmentService";
import { IExtensionManagementService, IExtensionGalleryService } from "vs/platform/extensionManagement/common/extensionManagement";
import { ExtensionGalleryChannel } from "vs/platform/extensionManagement/node/extensionGalleryIpc";
import { ExtensionGalleryService } from "vs/platform/extensionManagement/node/extensionGalleryService";
import { ExtensionManagementChannel } from "vs/platform/extensionManagement/node/extensionManagementIpc";
import { ExtensionManagementService } from "vs/platform/extensionManagement/node/extensionManagementService";
import { SyncDescriptor } from "vs/platform/instantiation/common/descriptors";
import { InstantiationService } from "vs/platform/instantiation/common/instantiationService";
import { ServiceCollection } from "vs/platform/instantiation/common/serviceCollection";
import { ILocalizationsService } from "vs/platform/localizations/common/localizations";
import { LocalizationsService } from "vs/platform/localizations/node/localizations";
import { getLogLevel, ILogService } from "vs/platform/log/common/log";
import { LogLevelSetterChannel } from "vs/platform/log/common/logIpc";
import { SpdLogService } from "vs/platform/log/node/spdlogService";
import { IProductConfiguration } from "vs/platform/product/common/product";
import product from "vs/platform/product/node/product";
import { ConnectionType, ConnectionTypeRequest } from "vs/platform/remote/common/remoteAgentConnection";
import { REMOTE_FILE_SYSTEM_CHANNEL_NAME } from "vs/platform/remote/common/remoteAgentFileSystemChannel";
import { IRequestService } from "vs/platform/request/node/request";
import { RequestService } from "vs/platform/request/node/requestService";
import { ITelemetryService } from "vs/platform/telemetry/common/telemetry";
import { NullTelemetryService } from "vs/platform/telemetry/common/telemetryUtils";
import { RemoteExtensionLogFileName } from "vs/workbench/services/remote/common/remoteAgentService";
// import { TelemetryService } from "vs/workbench/services/telemetry/electron-browser/telemetryService";
import { IWorkbenchConstructionOptions } from "vs/workbench/workbench.web.api";
import { Connection, ManagementConnection, ExtensionHostConnection } from "vs/server/src/connection";
import { ExtensionEnvironmentChannel, FileProviderChannel , } from "vs/server/src/channel";
import { Protocol } from "vs/server/src/protocol";
import { getUriTransformer, useHttpsTransformer } from "vs/server/src/util";
export enum HttpCode {
Ok = 200,
NotFound = 404,
BadRequest = 400,
}
export interface Options {
WORKBENCH_WEB_CONGIGURATION: IWorkbenchConstructionOptions;
REMOTE_USER_DATA_URI: UriComponents | URI;
PRODUCT_CONFIGURATION: IProductConfiguration | null;
CONNECTION_AUTH_TOKEN: string;
}
export interface Response {
content?: string | Buffer;
code?: number;
headers: http.OutgoingHttpHeaders;
}
export class HttpError extends Error {
public constructor(message: string, public readonly code: number) {
super(message);
// @ts-ignore
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
export interface ServerOptions {
readonly port: number;
readonly host: string;
readonly socket?: string;
readonly allowHttp?: boolean;
readonly cert?: string;
readonly certKey?: string;
}
export abstract class Server {
// The underlying web server.
protected readonly server: http.Server | https.Server;
protected rootPath = path.resolve(__dirname, "../../..");
private listenPromise: Promise<string> | undefined;
public constructor(private readonly options: ServerOptions) {
if (this.options.cert && this.options.certKey) {
useHttpsTransformer();
const httpolyglot = require.__$__nodeRequire(path.resolve(__dirname, "../node_modules/httpolyglot/lib/index")) as typeof import("httpolyglot");
this.server = httpolyglot.createServer({
cert: fs.readFileSync(this.options.cert),
key: fs.readFileSync(this.options.certKey),
}, this.onRequest);
} else {
this.server = http.createServer(this.onRequest);
}
}
public listen(): Promise<string> {
if (!this.listenPromise) {
this.listenPromise = new Promise((resolve, reject) => {
this.server.on("error", reject);
const onListen = () => resolve(this.address(this.server, this.options.allowHttp));
if (this.options.socket) {
this.server.listen(this.options.socket, onListen);
} else {
this.server.listen(this.options.port, this.options.host, onListen);
}
});
}
return this.listenPromise;
}
protected abstract handleRequest(
base: string,
requestPath: string,
parsedUrl: url.UrlWithParsedQuery,
request: http.IncomingMessage,
): Promise<Response>;
protected async getResource(filePath: string): Promise<Response> {
const content = await util.promisify(fs.readFile)(filePath);
return {
content,
headers: {
"Content-Type": getMediaMime(filePath) || {
".css": "text/css",
".html": "text/html",
".js": "text/javascript",
".json": "application/json",
}[extname(filePath)] || "text/plain",
},
};
}
private onRequest = async (request: http.IncomingMessage, response: http.ServerResponse): Promise<void> => {
const secure = (request.connection as tls.TLSSocket).encrypted;
if (!this.options.allowHttp && !secure) {
response.writeHead(302, {
Location: "https://" + request.headers.host + request.url,
});
return response.end();
}
try {
if (request.method !== "GET") {
throw new HttpError(
`Unsupported method ${request.method}`,
HttpCode.BadRequest,
);
}
const parsedUrl = url.parse(request.url || "", true);
const fullPath = decodeURIComponent(parsedUrl.pathname || "/");
const match = fullPath.match(/^(\/?[^/]*)(.*)$/);
const [, base, requestPath] = match
? match.map((p) => p !== "/" ? p.replace(/\/$/, "") : p)
: ["", "", ""];
const { content, headers, code } = await this.handleRequest(
base, requestPath, parsedUrl, request,
);
response.writeHead(code || HttpCode.Ok, {
"Cache-Control": "max-age=86400",
// TODO: ETag?
...headers,
});
response.end(content);
} catch (error) {
if (error.code === "ENOENT" || error.code === "EISDIR") {
error = new HttpError("Not found", HttpCode.NotFound);
}
response.writeHead(typeof error.code === "number" ? error.code : 500);
response.end(error.message);
}
}
private address(server: net.Server, http?: boolean): string {
const address = server.address();
const endpoint = typeof address !== "string"
? ((address.address === "::" ? "localhost" : address.address) + ":" + address.port)
: address;
return `${http ? "http" : "https"}://${endpoint}`;
}
}
export class MainServer extends Server {
// Used to notify the IPC server that there is a new client.
public readonly _onDidClientConnect = new Emitter<ClientConnectionEvent>();
public readonly onDidClientConnect = this._onDidClientConnect.event;
// This is separate instead of just extending this class since we can't
// use properties in the super call. This manages channels.
private readonly ipc = new IPCServer(this.onDidClientConnect);
// Persistent connections. These can reconnect within a timeout.
private readonly connections = new Map<ConnectionType, Map<string, Connection>>();
private readonly services = new ServiceCollection();
public constructor(
options: ServerOptions,
private readonly webviewServer: WebviewServer,
args: ParsedArgs,
) {
super(options);
this.server.on("upgrade", async (request, socket) => {
const protocol = this.createProtocol(request, socket);
try {
await this.connect(await protocol.handshake(), protocol);
} catch (error) {
protocol.dispose(error);
}
});
const environmentService = new EnvironmentService(args, process.execPath);
const logService = new SpdLogService(RemoteExtensionLogFileName, environmentService.logsPath, getLogLevel(environmentService));
this.ipc.registerChannel("loglevel", new LogLevelSetterChannel(logService));
const router = new StaticRouter((context: any) => {
return context.clientId === "renderer";
});
this.services.set(ILogService, logService);
this.services.set(IEnvironmentService, environmentService);
this.services.set(IConfigurationService, new SyncDescriptor(ConfigurationService, [environmentService.machineSettingsResource]));
this.services.set(IRequestService, new SyncDescriptor(RequestService));
this.services.set(IExtensionGalleryService, new SyncDescriptor(ExtensionGalleryService));
this.services.set(ITelemetryService, NullTelemetryService); // TODO: telemetry
this.services.set(IDialogService, new DialogChannelClient(this.ipc.getChannel("dialog", router)));
this.services.set(IExtensionManagementService, new SyncDescriptor(ExtensionManagementService));
const instantiationService = new InstantiationService(this.services);
this.services.set(ILocalizationsService, instantiationService.createInstance(LocalizationsService));
instantiationService.invokeFunction(() => {
instantiationService.createInstance(LogsDataCleaner);
this.ipc.registerChannel(REMOTE_FILE_SYSTEM_CHANNEL_NAME, new FileProviderChannel(logService));
this.ipc.registerChannel("remoteextensionsenvironment", new ExtensionEnvironmentChannel(environmentService, logService));
const extensionsService = this.services.get(IExtensionManagementService) as IExtensionManagementService;
const extensionsChannel = new ExtensionManagementChannel(extensionsService, (context) => getUriTransformer(context.remoteAuthority));
this.ipc.registerChannel("extensions", extensionsChannel);
const galleryService = this.services.get(IExtensionGalleryService) as IExtensionGalleryService;
const galleryChannel = new ExtensionGalleryChannel(galleryService);
this.ipc.registerChannel("gallery", galleryChannel);
});
}
public async listen(): Promise<string> {
const environment = (this.services.get(IEnvironmentService) as EnvironmentService);
const mkdirs = Promise.all([
environment.extensionsPath,
].map((p) => mkdirp(p)));
const [address] = await Promise.all([
super.listen(),
mkdirs,
]);
return address;
}
protected async handleRequest(
base: string,
requestPath: string,
parsedUrl: url.UrlWithParsedQuery,
request: http.IncomingMessage,
): Promise<Response> {
switch (base) {
case "/":
return this.getRoot(request, parsedUrl);
case "/node_modules":
case "/out":
return this.getResource(path.join(this.rootPath, base, requestPath));
// TODO: this setup means you can't request anything from the root if it
// starts with /node_modules or /out, although that's probably low risk.
// There doesn't seem to be a really good way to solve this since some
// resources are requested by the browser (like the extension icon) and
// some by the file provider (like the extension README). Maybe add a
// /resource prefix and a file provider that strips that prefix?
default:
return this.getResource(path.join(base, requestPath));
}
}
private async getRoot(request: http.IncomingMessage, parsedUrl: url.UrlWithParsedQuery): Promise<Response> {
const htmlPath = path.join(
this.rootPath,
'out/vs/code/browser/workbench/workbench.html',
);
let content = await util.promisify(fs.readFile)(htmlPath, "utf8");
const remoteAuthority = request.headers.host as string;
const transformer = getUriTransformer(remoteAuthority);
const webviewEndpoint = await this.webviewServer.listen();
const cwd = process.env.VSCODE_CWD || process.cwd();
const workspacePath = parsedUrl.query.workspace as string | undefined;
const folderPath = !workspacePath ? parsedUrl.query.folder as string | undefined || cwd: undefined;
const options: Options = {
WORKBENCH_WEB_CONGIGURATION: {
workspaceUri: workspacePath
? transformer.transformOutgoing(URI.file(sanitizeFilePath(workspacePath, cwd)))
: undefined,
folderUri: folderPath
? transformer.transformOutgoing(URI.file(sanitizeFilePath(folderPath, cwd)))
: undefined,
remoteAuthority,
webviewEndpoint,
},
REMOTE_USER_DATA_URI: transformer.transformOutgoing(
(this.services.get(IEnvironmentService) as EnvironmentService).webUserDataHome,
),
PRODUCT_CONFIGURATION: product,
CONNECTION_AUTH_TOKEN: "",
};
Object.keys(options).forEach((key) => {
content = content.replace(`"{{${key}}}"`, `'${JSON.stringify(options[key])}'`);
});
content = content.replace('{{WEBVIEW_ENDPOINT}}', webviewEndpoint);
return {
content,
headers: {
"Content-Type": "text/html",
},
};
}
private createProtocol(request: http.IncomingMessage, socket: net.Socket): Protocol {
if (request.headers.upgrade !== "websocket") {
throw new Error("HTTP/1.1 400 Bad Request");
}
const options = {
reconnectionToken: "",
reconnection: false,
skipWebSocketFrames: false,
};
if (request.url) {
const query = url.parse(request.url, true).query;
if (query.reconnectionToken) {
options.reconnectionToken = query.reconnectionToken as string;
}
if (query.reconnection === "true") {
options.reconnection = true;
}
if (query.skipWebSocketFrames === "true") {
options.skipWebSocketFrames = true;
}
}
return new Protocol(
request.headers["sec-websocket-key"] as string,
socket,
options,
);
}
private async connect(message: ConnectionTypeRequest, protocol: Protocol): Promise<void> {
switch (message.desiredConnectionType) {
case ConnectionType.ExtensionHost:
case ConnectionType.Management:
const debugPort = await this.getDebugPort();
const ok = message.desiredConnectionType === ConnectionType.ExtensionHost
? (debugPort ? { debugPort } : {})
: { type: "ok" };
if (!this.connections.has(message.desiredConnectionType)) {
this.connections.set(message.desiredConnectionType, new Map());
}
const connections = this.connections.get(message.desiredConnectionType)!;
const token = protocol.options.reconnectionToken;
if (protocol.options.reconnection && connections.has(token)) {
protocol.sendMessage(ok);
const buffer = protocol.readEntireBuffer();
protocol.dispose();
return connections.get(token)!.reconnect(protocol, buffer);
}
if (protocol.options.reconnection || connections.has(token)) {
throw new Error(protocol.options.reconnection
? "Unrecognized reconnection token"
: "Duplicate reconnection token"
);
}
protocol.sendMessage(ok);
let connection: Connection;
if (message.desiredConnectionType === ConnectionType.Management) {
connection = new ManagementConnection(protocol);
this._onDidClientConnect.fire({
protocol,
onDidClientDisconnect: connection.onClose,
});
} else {
connection = new ExtensionHostConnection(
protocol, this.services.get(ILogService) as ILogService,
);
}
connections.set(protocol.options.reconnectionToken, connection);
connection.onClose(() => {
connections.delete(protocol.options.reconnectionToken);
});
break;
case ConnectionType.Tunnel: return protocol.tunnel();
default: throw new Error("Unrecognized connection type");
}
}
/**
* TODO: implement.
*/
private async getDebugPort(): Promise<number | undefined> {
return undefined;
}
}
export class WebviewServer extends Server {
protected async handleRequest(
base: string,
requestPath: string,
): Promise<Response> {
const webviewPath = path.join(
this.rootPath,
"out/vs/workbench/contrib/webview/browser/pre",
);
if (base === "/") {
base = "/index.html";
}
return this.getResource(path.join(webviewPath, base, requestPath));
}
}

224
src/tar.ts Normal file
View File

@ -0,0 +1,224 @@
import * as fs from "fs";
import * as path from "path";
import * as tarStream from "tar-stream";
import { promisify } from "util";
import * as nls from "vs/nls";
import * as vszip from "vs/base/node/zip";
import { CancellationToken } from "vs/base/common/cancellation";
import { mkdirp } from "vs/base/node/pfs";
// We will be overriding these, so keep a reference to the original.
const vszipExtract = vszip.extract;
const vszipBuffer = vszip.buffer;
export interface IExtractOptions {
overwrite?: boolean;
/**
* Source path within the TAR/ZIP archive. Only the files
* contained in this path will be extracted.
*/
sourcePath?: string;
}
export interface IFile {
path: string;
contents?: Buffer | string;
localPath?: string;
}
/**
* Override the standard VS Code behavior for zipping extensions to use the TAR
* format instead of ZIP.
*/
export const zip = (tarPath: string, files: IFile[]): Promise<string> => {
return new Promise<string>((c, e): void => {
const pack = tarStream.pack();
const chunks: Buffer[] = [];
const ended = new Promise<Buffer>((res): void => {
pack.on("end", () => {
res(Buffer.concat(chunks));
});
});
pack.on("data", (chunk) => {
chunks.push(chunk as Buffer);
});
for (let i = 0; i < files.length; i++) {
const file = files[i];
pack.entry({
name: file.path,
}, file.contents);
}
pack.finalize();
ended.then((buffer) => {
return promisify(fs.writeFile)(tarPath, buffer);
}).then(() => {
c(tarPath);
}).catch((ex) => {
e(ex);
});
});
};
/**
* Override the standard VS Code behavior for extracting archives to first
* attempt to process the archive as a TAR and then fall back to the original
* implementation for processing ZIPs.
*/
export const extract = (archivePath: string, extractPath: string, options: IExtractOptions = {}, token: CancellationToken): Promise<void> => {
return new Promise<void>((c, e): void => {
extractTar(archivePath, extractPath, options, token).then(c).catch((ex) => {
if (!ex.toString().includes("Invalid tar header")) {
e(ex);
return;
}
vszipExtract(archivePath, extractPath, options, token).then(c).catch(e);
});
});
};
/**
* Override the standard VS Code behavior for buffering archives to first
* process the Buffer as a TAR and then fall back to the original
* implementation for processing ZIPs.
*/
export const buffer = (targetPath: string, filePath: string): Promise<Buffer> => {
return new Promise<Buffer>((c, e): void => {
let done: boolean = false;
extractAssets(targetPath, new RegExp(filePath), (assetPath: string, data: Buffer) => {
if (path.normalize(assetPath) === path.normalize(filePath)) {
done = true;
c(data);
}
}).then(() => {
if (!done) {
e("couldn't find asset " + filePath);
}
}).catch((ex) => {
if (!ex.toString().includes("Invalid tar header")) {
e(ex);
return;
}
vszipBuffer(targetPath, filePath).then(c).catch(e);
});
});
};
/**
* Override the standard VS Code behavior for extracting assets from archive
* Buffers to use the TAR format instead of ZIP.
*/
const extractAssets = (tarPath: string, match: RegExp, callback: (path: string, data: Buffer) => void): Promise<void> => {
return new Promise<void>(async (c, e): Promise<void> => {
try {
const buffer = await promisify(fs.readFile)(tarPath);
const extractor = tarStream.extract();
extractor.once("error", e);
extractor.on("entry", (header, stream, next) => {
const name = header.name;
if (match.test(name)) {
extractData(stream).then((data) => {
callback(name, data);
next();
}).catch(e);
stream.resume();
} else {
stream.on("end", () => {
next();
});
stream.resume();
}
});
extractor.on("finish", () => {
c();
});
extractor.write(buffer);
extractor.end();
} catch (ex) {
e(ex);
}
});
};
const extractData = (stream: NodeJS.ReadableStream): Promise<Buffer> => {
return new Promise<Buffer>((c, e): void => {
const fileData: Buffer[] = [];
stream.on("data", (data) => fileData.push(data));
stream.on("end", () => {
const fd = Buffer.concat(fileData);
c(fd);
});
stream.on("error", e);
});
};
const extractTar = (tarPath: string, targetPath: string, options: IExtractOptions = {}, token: CancellationToken): Promise<void> => {
return new Promise<void>(async (c, e): Promise<void> => {
try {
const sourcePathRegex = new RegExp(options.sourcePath ? `^${options.sourcePath}` : "");
const buffer = await promisify(fs.readFile)(tarPath);
const extractor = tarStream.extract();
extractor.once("error", e);
extractor.on("entry", (header, stream, next) => {
const rawName = path.normalize(header.name);
const nextEntry = (): void => {
stream.resume();
next();
};
if (token.isCancellationRequested) {
return nextEntry();
}
if (!sourcePathRegex.test(rawName)) {
return nextEntry();
}
const fileName = rawName.replace(sourcePathRegex, "");
const targetFileName = path.join(targetPath, fileName);
if (/\/$/.test(fileName)) {
stream.resume();
mkdirp(targetFileName).then(() => {
next();
}, e);
return;
}
const dirName = path.dirname(fileName);
const targetDirName = path.join(targetPath, dirName);
if (targetDirName.indexOf(targetPath) !== 0) {
e(nls.localize("invalid file", "Error extracting {0}. Invalid file.", fileName));
return nextEntry();
}
return mkdirp(targetDirName, undefined, token).then(() => {
const fstream = fs.createWriteStream(targetFileName, { mode: header.mode });
fstream.once("close", () => {
next();
});
fstream.once("error", e);
stream.pipe(fstream);
stream.resume();
});
});
extractor.once("finish", c);
extractor.write(buffer);
extractor.end();
} catch (ex) {
e(ex);
}
});
};
// Override original functionality so we can use tar instead of zip.
const target = vszip as typeof vszip;
target.zip = zip;
target.extract = extract;
target.buffer = buffer;

358
src/upload.ts Normal file
View File

@ -0,0 +1,358 @@
import { exec } from "child_process";
import { appendFile } from "fs";
import { promisify } from "util";
import { logger } from "@coder/logger";
import { escapePath } from "@coder/protocol";
import { NotificationService, INotificationService, ProgressService, IProgressService, IProgress, Severity } from "./fill/notification";
export interface IURI {
readonly path: string;
readonly fsPath: string;
readonly scheme: string;
}
/**
* Represents an uploadable directory, so we can query for existing files once.
*/
interface IUploadableDirectory {
existingFiles: string[];
filesToUpload: Map<string, File>;
preparePromise?: Promise<void>;
}
/**
* There doesn't seem to be a provided type for entries, so here is an
* incomplete version.
*/
interface IEntry {
name: string;
isFile: boolean;
file: (cb: (file: File) => void) => void;
createReader: () => ({
readEntries: (cb: (entries: Array<IEntry>) => void) => void;
});
}
/**
* Handles file uploads.
*/
export class Upload {
private readonly maxParallelUploads = 100;
private readonly readSize = 32000; // ~32kb max while reading in the file.
private readonly packetSize = 32000; // ~32kb max when writing.
private readonly logger = logger.named("Upload");
private readonly currentlyUploadingFiles = new Map<string, File>();
private readonly queueByDirectory = new Map<string, IUploadableDirectory>();
private progress: IProgress | undefined;
private uploadPromise: Promise<string[]> | undefined;
private resolveUploadPromise: (() => void) | undefined;
private finished = 0;
private uploadedFilePaths = <string[]>[];
private total = 0;
public constructor(
private _notificationService: INotificationService,
private _progressService: IProgressService,
) {}
public set notificationService(service: INotificationService) {
this._notificationService = service;
}
public get notificationService(): INotificationService {
return this._notificationService;
}
public set progressService(service: IProgressService) {
this._progressService = service;
}
public get progressService(): IProgressService {
return this._progressService;
}
/**
* Upload dropped files. This will try to upload everything it can. Errors
* will show via notifications. If an upload operation is ongoing, the files
* will be added to that operation.
*/
public async uploadDropped(event: DragEvent, uploadDir: IURI): Promise<string[]> {
this.addDirectory(uploadDir.path);
await this.queueFiles(event, uploadDir);
this.logger.debug( // -1 so we don't include the uploadDir itself.
`Uploading ${this.queueByDirectory.size - 1} directories and ${this.total} files`,
);
await this.prepareDirectories();
if (!this.uploadPromise) {
this.uploadPromise = this.progressService.start("Uploading files...", (progress) => {
return new Promise((resolve): void => {
this.progress = progress;
this.resolveUploadPromise = (): void => {
const uploaded = this.uploadedFilePaths;
this.uploadPromise = undefined;
this.resolveUploadPromise = undefined;
this.uploadedFilePaths = [];
this.finished = 0;
this.total = 0;
resolve(uploaded);
};
});
}, () => {
this.cancel();
});
}
this.uploadFiles();
return this.uploadPromise;
}
/**
* Cancel all file uploads.
*/
public async cancel(): Promise<void> {
this.currentlyUploadingFiles.clear();
this.queueByDirectory.clear();
}
/**
* Create directories and get existing files.
* On failure, show the error and remove the failed directory from the queue.
*/
private async prepareDirectories(): Promise<void> {
await Promise.all(Array.from(this.queueByDirectory).map(([path, dir]) => {
if (!dir.preparePromise) {
dir.preparePromise = this.prepareDirectory(path, dir);
}
return dir.preparePromise;
}));
}
/**
* Create a directory and get existing files.
* On failure, show the error and remove the directory from the queue.
*/
private async prepareDirectory(path: string, dir: IUploadableDirectory): Promise<void> {
await Promise.all([
promisify(exec)(`mkdir -p ${escapePath(path)}`).catch((error) => {
const message = error.message.toLowerCase();
if (message.includes("file exists")) {
throw new Error(`Unable to create directory at ${path} because a file exists there`);
}
throw new Error(error.message || `Unable to upload ${path}`);
}),
// Only get files, so we don't show an override option that will just
// fail anyway.
promisify(exec)(`find ${escapePath(path)} -maxdepth 1 -not -type d`).then((stdio) => {
dir.existingFiles = stdio.stdout.split("\n");
}),
]).catch((error) => {
this.queueByDirectory.delete(path);
this.notificationService.error(error);
});
}
/**
* Upload as many files as possible. When finished, resolve the upload promise.
*/
private uploadFiles(): void {
const finishFileUpload = (path: string): void => {
++this.finished;
this.currentlyUploadingFiles.delete(path);
this.progress!.report(Math.floor((this.finished / this.total) * 100));
this.uploadFiles();
};
while (this.queueByDirectory.size > 0 && this.currentlyUploadingFiles.size < this.maxParallelUploads) {
const [dirPath, dir] = this.queueByDirectory.entries().next().value;
if (dir.filesToUpload.size === 0) {
this.queueByDirectory.delete(dirPath);
continue;
}
const [filePath, item] = dir.filesToUpload.entries().next().value;
this.currentlyUploadingFiles.set(filePath, item);
dir.filesToUpload.delete(filePath);
this.uploadFile(filePath, item, dir.existingFiles).then(() => {
finishFileUpload(filePath);
}).catch((error) => {
this.notificationService.error(error);
finishFileUpload(filePath);
});
}
if (this.queueByDirectory.size === 0 && this.currentlyUploadingFiles.size === 0) {
this.resolveUploadPromise!();
}
}
/**
* Upload a file.
*/
private async uploadFile(path: string, file: File, existingFiles: string[]): Promise<void> {
if (existingFiles.includes(path)) {
const shouldOverwrite = await new Promise((resolve): void => {
this.notificationService.prompt(
Severity.Error,
`${path} already exists. Overwrite?`,
[{
label: "Yes",
run: (): void => resolve(true),
}, {
label: "No",
run: (): void => resolve(false),
}],
() => resolve(false),
);
});
if (!shouldOverwrite) {
return;
}
}
await new Promise(async (resolve, reject): Promise<void> => {
let readOffset = 0;
const reader = new FileReader();
const seek = (): void => {
const slice = file.slice(readOffset, readOffset + this.readSize);
readOffset += this.readSize;
reader.readAsArrayBuffer(slice);
};
const rm = async (): Promise<void> => {
await promisify(exec)(`rm -f ${escapePath(path)}`);
};
await rm();
const load = async (): Promise<void> => {
const buffer = new Uint8Array(reader.result as ArrayBuffer);
let bufferOffset = 0;
while (bufferOffset <= buffer.length) {
// Got canceled while sending data.
if (!this.currentlyUploadingFiles.has(path)) {
await rm();
return resolve();
}
const data = buffer.slice(bufferOffset, bufferOffset + this.packetSize);
try {
await promisify(appendFile)(path, data);
} catch (error) {
await rm();
const message = error.message.toLowerCase();
if (message.includes("no space")) {
return reject(new Error("You are out of disk space"));
} else if (message.includes("is a directory")) {
return reject(new Error(`Unable to upload ${path} because there is a directory there`));
}
return reject(new Error(error.message || `Unable to upload ${path}`));
}
bufferOffset += this.packetSize;
}
if (readOffset >= file.size) {
this.uploadedFilePaths.push(path);
return resolve();
}
seek();
};
reader.addEventListener("load", load);
seek();
});
}
/**
* Queue files from a drop event. We have to get the files first; we can't do
* it in tandem with uploading or the entries will disappear.
*/
private async queueFiles(event: DragEvent, uploadDir: IURI): Promise<void> {
if (!event.dataTransfer || !event.dataTransfer.items) {
return;
}
const promises: Array<Promise<void>> = [];
for (let i = 0; i < event.dataTransfer.items.length; i++) {
const item = event.dataTransfer.items[i];
if (typeof item.webkitGetAsEntry === "function") {
promises.push(this.traverseItem(item.webkitGetAsEntry(), uploadDir.fsPath).catch(this.notificationService.error));
} else {
const file = item.getAsFile();
if (file) {
this.addFile(uploadDir.fsPath, uploadDir.fsPath + "/" + file.name, file);
}
}
}
await Promise.all(promises);
}
/**
* Traverses an entry and add files to the queue.
*/
private async traverseItem(entry: IEntry, parentPath: string): Promise<void> {
if (entry.isFile) {
return new Promise<void>((resolve): void => {
entry.file((file) => {
this.addFile(
parentPath,
parentPath + "/" + file.name,
file,
);
resolve();
});
});
}
parentPath += "/" + entry.name;
this.addDirectory(parentPath);
await new Promise((resolve): void => {
const promises: Array<Promise<void>> = [];
const dirReader = entry.createReader();
// According to the spec, readEntries() must be called until it calls
// the callback with an empty array.
const readEntries = (): void => {
dirReader.readEntries((entries) => {
if (entries.length === 0) {
Promise.all(promises).then(resolve).catch((error) => {
this.notificationService.error(error);
resolve();
});
} else {
promises.push(...entries.map((child) => this.traverseItem(child, parentPath)));
readEntries();
}
});
};
readEntries();
});
}
/**
* Add a file to the queue.
*/
private addFile(parentPath: string, path: string, file: File): void {
++this.total;
this.addDirectory(parentPath);
this.queueByDirectory.get(parentPath)!.filesToUpload.set(path, file);
}
/**
* Add a directory to the queue.
*/
private addDirectory(path: string): void {
if (!this.queueByDirectory.has(path)) {
this.queueByDirectory.set(path, {
existingFiles: [],
filesToUpload: new Map(),
});
}
}
}
// Global instance.
export const upload = new Upload(new NotificationService(), new ProgressService());

28
src/uriTransformerHttp.js Normal file
View File

@ -0,0 +1,28 @@
// This file is included via a regular Node require. I'm not sure how (or if)
// we can write this in Typescript and have it compile to non-AMD syntax.
module.exports = (remoteAuthority, https) => {
return {
transformIncoming: (uri) => {
switch (uri.scheme) {
case "https": return { scheme: "file", path: uri.path };
case "http": return { scheme: "file", path: uri.path };
case "file": return { scheme: "vscode-local", path: uri.path };
default: return uri;
}
},
transformOutgoing: (uri) => {
switch (uri.scheme) {
case "vscode-local": return { scheme: "file", path: uri.path };
case "file": return { scheme: https ? "https" : "http", authority: remoteAuthority, path: uri.path };
default: return uri;
}
},
transformOutgoingScheme: (scheme) => {
switch (scheme) {
case "vscode-local": return "file";
case "file": return https ? "https" : "http";
default: return scheme;
}
},
};
};

View File

@ -0,0 +1,3 @@
module.exports = (remoteAuthority) => {
return require("./uriTransformerHttp")(remoteAuthority, true);
};

60
src/util.ts Normal file
View File

@ -0,0 +1,60 @@
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import * as util from "util";
import { getPathFromAmdModule } from "vs/base/common/amd";
import { URITransformer, IRawURITransformer } from "vs/base/common/uriIpc";
import { mkdirp } from "vs/base/node/pfs";
export const tmpdir = path.join(os.tmpdir(), "code-server");
export const generateCertificate = async (): Promise<{ cert: string, certKey: string }> => {
const paths = {
cert: path.join(tmpdir, "self-signed.cert"),
certKey: path.join(tmpdir, "self-signed.key"),
};
const exists = await Promise.all([
util.promisify(fs.exists)(paths.cert),
util.promisify(fs.exists)(paths.certKey),
]);
await mkdirp(tmpdir);
if (!exists[0] || !exists[1]) {
const pem = require.__$__nodeRequire(path.resolve(__dirname, "../node_modules/pem/lib/pem")) as typeof import("pem");
const certs = await new Promise<import("pem").CertificateCreationResult>((resolve, reject): void => {
pem.createCertificate({ selfSigned: true }, (error, result) => {
if (error) {
return reject(error);
}
resolve(result);
});
});
await Promise.all([
util.promisify(fs.writeFile)(paths.cert, certs.certificate),
util.promisify(fs.writeFile)(paths.certKey, certs.serviceKey),
]);
}
return paths;
};
let secure: boolean;
export const useHttpsTransformer = (): void => {
secure = true;
};
export const uriTransformerPath = (): string => {
return getPathFromAmdModule(
require,
"vs/server/src/uriTransformerHttp" + (secure ? "s": ""),
);
};
export const getUriTransformer = (remoteAuthority: string): URITransformer => {
const rawURITransformerFactory = <any>require.__$__nodeRequire(uriTransformerPath());
const rawURITransformer = <IRawURITransformer>rawURITransformerFactory(remoteAuthority);
return new URITransformer(rawURITransformer);
};