Merge pull request #1934 from cdr/plugin
Add plugin system for adding http endpoints
This commit is contained in:
@ -8,10 +8,9 @@ import { HttpProvider, HttpResponse, Route } from "../http"
|
||||
import { pathToFsPath } from "../util"
|
||||
|
||||
/**
|
||||
* Static file HTTP provider. Regular static requests (the path is the request
|
||||
* itself) do not require authentication and they only allow access to resources
|
||||
* within the application. Requests for tars (the path is in a query parameter)
|
||||
* do require permissions and can access any directory.
|
||||
* Static file HTTP provider. Static requests do not require authentication if
|
||||
* the resource is in the application's directory except requests to serve a
|
||||
* directory as a tar which always requires authentication.
|
||||
*/
|
||||
export class StaticHttpProvider extends HttpProvider {
|
||||
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
|
||||
@ -22,7 +21,7 @@ export class StaticHttpProvider extends HttpProvider {
|
||||
return this.getTarredResource(request, pathToFsPath(route.query.tar))
|
||||
}
|
||||
|
||||
const response = await this.getReplacedResource(route)
|
||||
const response = await this.getReplacedResource(request, route)
|
||||
if (!this.isDev) {
|
||||
response.cache = true
|
||||
}
|
||||
@ -32,17 +31,25 @@ export class StaticHttpProvider extends HttpProvider {
|
||||
/**
|
||||
* Return a resource with variables replaced where necessary.
|
||||
*/
|
||||
protected async getReplacedResource(route: Route): Promise<HttpResponse> {
|
||||
protected async getReplacedResource(request: http.IncomingMessage, route: Route): Promise<HttpResponse> {
|
||||
// The first part is always the commit (for caching purposes).
|
||||
const split = route.requestPath.split("/").slice(1)
|
||||
|
||||
const resourcePath = path.resolve("/", ...split)
|
||||
|
||||
// Make sure it's in code-server or a plugin.
|
||||
const validPaths = [this.rootPath, process.env.PLUGIN_DIR]
|
||||
if (!validPaths.find((p) => p && resourcePath.startsWith(p))) {
|
||||
this.ensureAuthenticated(request)
|
||||
}
|
||||
|
||||
switch (split[split.length - 1]) {
|
||||
case "manifest.json": {
|
||||
const response = await this.getUtf8Resource(this.rootPath, ...split)
|
||||
const response = await this.getUtf8Resource(resourcePath)
|
||||
return this.replaceTemplates(route, response)
|
||||
}
|
||||
}
|
||||
return this.getResource(this.rootPath, ...split)
|
||||
return this.getResource(resourcePath)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -200,8 +200,6 @@ export class VscodeHttpProvider extends HttpProvider {
|
||||
.replace(`"{{WORKBENCH_WEB_CONFIGURATION}}"`, `'${JSON.stringify(options.workbenchWebConfiguration)}'`)
|
||||
.replace(`"{{NLS_CONFIGURATION}}"`, `'${JSON.stringify(options.nlsConfiguration)}'`)
|
||||
return this.replaceTemplates<Options>(route, response, {
|
||||
base: this.base(route),
|
||||
commit: this.options.commit,
|
||||
disableTelemetry: !!this.args["disable-telemetry"],
|
||||
})
|
||||
}
|
||||
|
@ -9,7 +9,8 @@ import { UpdateHttpProvider } from "./app/update"
|
||||
import { VscodeHttpProvider } from "./app/vscode"
|
||||
import { Args, bindAddrFromAllSources, optionDescriptions, parse, readConfigFile, setDefaults } from "./cli"
|
||||
import { AuthType, HttpServer, HttpServerOptions } from "./http"
|
||||
import { generateCertificate, hash, open, humanPath } from "./util"
|
||||
import { loadPlugins } from "./plugin"
|
||||
import { generateCertificate, hash, humanPath, open } from "./util"
|
||||
import { ipcMain, wrap } from "./wrapper"
|
||||
import { plural } from "../common/util"
|
||||
|
||||
@ -78,6 +79,8 @@ const main = async (args: Args, cliArgs: Args, configArgs: Args): Promise<void>
|
||||
httpServer.registerHttpProvider("/login", LoginHttpProvider, args.config!, envPassword)
|
||||
httpServer.registerHttpProvider("/static", StaticHttpProvider)
|
||||
|
||||
await loadPlugins(httpServer, args)
|
||||
|
||||
ipcMain().onDispose(() => {
|
||||
httpServer.dispose().then((errors) => {
|
||||
errors.forEach((error) => logger.error(error.message))
|
||||
|
@ -209,11 +209,11 @@ export abstract class HttpProvider {
|
||||
/**
|
||||
* Get the base relative to the provided route. For each slash we need to go
|
||||
* up a directory. For example:
|
||||
* / => ./
|
||||
* /foo => ./
|
||||
* /foo/ => ./../
|
||||
* /foo/bar => ./../
|
||||
* /foo/bar/ => ./../../
|
||||
* / => .
|
||||
* /foo => .
|
||||
* /foo/ => ./..
|
||||
* /foo/bar => ./..
|
||||
* /foo/bar/ => ./../..
|
||||
*/
|
||||
public base(route: Route): string {
|
||||
const depth = (route.originalPath.match(/\//g) || []).length
|
||||
@ -235,30 +235,23 @@ export abstract class HttpProvider {
|
||||
/**
|
||||
* Replace common templates strings.
|
||||
*/
|
||||
protected replaceTemplates(route: Route, response: HttpStringFileResponse, sessionId?: string): HttpStringFileResponse
|
||||
protected replaceTemplates<T extends object>(
|
||||
route: Route,
|
||||
response: HttpStringFileResponse,
|
||||
options: T,
|
||||
): HttpStringFileResponse
|
||||
protected replaceTemplates(
|
||||
route: Route,
|
||||
response: HttpStringFileResponse,
|
||||
sessionIdOrOptions?: string | object,
|
||||
extraOptions?: Omit<T, "base" | "csStaticBase" | "logLevel">,
|
||||
): HttpStringFileResponse {
|
||||
if (typeof sessionIdOrOptions === "undefined" || typeof sessionIdOrOptions === "string") {
|
||||
sessionIdOrOptions = {
|
||||
base: this.base(route),
|
||||
commit: this.options.commit,
|
||||
logLevel: logger.level,
|
||||
sessionID: sessionIdOrOptions,
|
||||
} as Options
|
||||
const base = this.base(route)
|
||||
const options: Options = {
|
||||
base,
|
||||
csStaticBase: base + "/static/" + this.options.commit + this.rootPath,
|
||||
logLevel: logger.level,
|
||||
...extraOptions,
|
||||
}
|
||||
response.content = response.content
|
||||
.replace(/{{COMMIT}}/g, this.options.commit)
|
||||
.replace(/{{TO}}/g, Array.isArray(route.query.to) ? route.query.to[0] : route.query.to || "/dashboard")
|
||||
.replace(/{{BASE}}/g, this.base(route))
|
||||
.replace(/"{{OPTIONS}}"/, `'${JSON.stringify(sessionIdOrOptions)}'`)
|
||||
.replace(/{{BASE}}/g, options.base)
|
||||
.replace(/{{CS_STATIC_BASE}}/g, options.csStaticBase)
|
||||
.replace(/"{{OPTIONS}}"/, `'${JSON.stringify(options)}'`)
|
||||
return response
|
||||
}
|
||||
|
||||
@ -664,7 +657,7 @@ export class HttpServer {
|
||||
e = new HttpError("Not found", HttpCode.NotFound)
|
||||
}
|
||||
const code = typeof e.code === "number" ? e.code : HttpCode.ServerError
|
||||
logger.debug("Request error", field("url", request.url), field("code", code))
|
||||
logger.debug("Request error", field("url", request.url), field("code", code), field("error", error))
|
||||
if (code >= HttpCode.ServerError) {
|
||||
logger.error(error.stack)
|
||||
}
|
||||
|
60
src/node/plugin.ts
Normal file
60
src/node/plugin.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { field, logger } from "@coder/logger"
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import * as util from "util"
|
||||
import { Args } from "./cli"
|
||||
import { HttpServer } from "./http"
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
|
||||
export type Activate = (httpServer: HttpServer, args: Args) => void
|
||||
|
||||
export interface Plugin {
|
||||
activate: Activate
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept imports so we can inject code-server when the plugin tries to
|
||||
* import it.
|
||||
*/
|
||||
const originalLoad = require("module")._load
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
require("module")._load = function (request: string, parent: object, isMain: boolean): any {
|
||||
return originalLoad.apply(this, [request.replace(/^code-server/, path.resolve(__dirname, "../..")), parent, isMain])
|
||||
}
|
||||
|
||||
const loadPlugin = async (pluginPath: string, httpServer: HttpServer, args: Args): Promise<void> => {
|
||||
try {
|
||||
const plugin: Plugin = require(pluginPath)
|
||||
plugin.activate(httpServer, args)
|
||||
logger.debug("Loaded plugin", field("name", path.basename(pluginPath)))
|
||||
} catch (error) {
|
||||
if (error.code !== "MODULE_NOT_FOUND") {
|
||||
logger.warn(error.message)
|
||||
} else {
|
||||
logger.error(error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const _loadPlugins = async (httpServer: HttpServer, args: Args): Promise<void> => {
|
||||
const pluginPath = path.resolve(__dirname, "../../plugins")
|
||||
const files = await util.promisify(fs.readdir)(pluginPath, {
|
||||
withFileTypes: true,
|
||||
})
|
||||
await Promise.all(files.map((file) => loadPlugin(path.join(pluginPath, file.name), httpServer, args)))
|
||||
}
|
||||
|
||||
export const loadPlugins = async (httpServer: HttpServer, args: Args): Promise<void> => {
|
||||
try {
|
||||
await _loadPlugins(httpServer, args)
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") {
|
||||
logger.warn(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.PLUGIN_DIR) {
|
||||
await loadPlugin(process.env.PLUGIN_DIR, httpServer, args)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user