1
0
silverbullet/packages/server/express_server.ts

552 lines
17 KiB
TypeScript
Raw Normal View History

import express, { Express } from "express";
2022-04-26 17:04:36 +00:00
import { Manifest, SilverBulletHooks } from "@silverbulletmd/common/manifest";
2022-04-25 08:33:38 +00:00
import { EndpointHook } from "@plugos/plugos/hooks/endpoint";
import { readdir, readFile, rm } from "fs/promises";
2022-04-25 08:33:38 +00:00
import { System } from "@plugos/plugos/system";
import { DiskSpacePrimitives } from "@silverbulletmd/common/spaces/disk_space_primitives";
import path from "path";
import bodyParser from "body-parser";
2022-04-25 08:33:38 +00:00
import { EventHook } from "@plugos/plugos/hooks/event";
import spaceSyscalls from "./syscalls/space";
2022-04-25 08:33:38 +00:00
import { eventSyscalls } from "@plugos/plugos/syscalls/event";
import { ensureTable as ensureIndexTable, pageIndexSyscalls } from "./syscalls";
import knex, { Knex } from "knex";
2022-04-25 08:33:38 +00:00
import shellSyscalls from "@plugos/plugos/syscalls/shell.node";
import { NodeCronHook } from "@plugos/plugos/hooks/node_cron";
import { markdownSyscalls } from "@silverbulletmd/common/syscalls/markdown";
import { EventedSpacePrimitives } from "@silverbulletmd/common/spaces/evented_space_primitives";
import { Space } from "@silverbulletmd/common/spaces/space";
import {
createSandbox,
nodeModulesDir,
} from "@plugos/plugos/environments/node_sandbox";
2022-04-25 08:33:38 +00:00
import { jwtSyscalls } from "@plugos/plugos/syscalls/jwt";
2022-04-29 16:54:27 +00:00
import buildMarkdown from "@silverbulletmd/common/parser";
import { loadMarkdownExtensions } from "@silverbulletmd/common/markdown_ext";
2022-04-24 16:06:34 +00:00
import http, { Server } from "http";
2022-04-25 17:46:08 +00:00
import { esbuildSyscalls } from "@plugos/plugos/syscalls/esbuild";
2022-04-26 17:04:36 +00:00
import { systemSyscalls } from "./syscalls/system";
2022-04-26 18:31:31 +00:00
import { plugPrefix } from "@silverbulletmd/common/spaces/constants";
2022-03-21 14:21:34 +00:00
2022-05-09 12:59:12 +00:00
import sandboxSyscalls from "@plugos/plugos/syscalls/sandbox";
2022-08-02 10:43:39 +00:00
// @ts-ignore
2022-08-02 11:37:18 +00:00
import settingsTemplate from "bundle-text:./SETTINGS_template.md";
2022-05-13 12:36:26 +00:00
import { safeRun } from "./util";
2022-05-16 13:09:36 +00:00
import {
ensureFTSTable,
fullTextSearchSyscalls,
} from "@plugos/plugos/syscalls/fulltext.knex_sqlite";
2022-05-17 09:53:17 +00:00
import { PlugSpacePrimitives } from "./hooks/plug_space_primitives";
import { PageNamespaceHook } from "./hooks/page_namespace";
import { readFileSync } from "fs";
import fileSystemSyscalls from "@plugos/plugos/syscalls/fs.node";
import {
ensureTable as ensureStoreTable,
2022-09-12 12:50:37 +00:00
storeSyscalls,
} from "@plugos/plugos/syscalls/store.knex_node";
2022-08-02 10:43:39 +00:00
import { parseYamlSettings } from "@silverbulletmd/common/util";
2022-09-12 12:50:37 +00:00
import { SpacePrimitives } from "@silverbulletmd/common/spaces/space_primitives";
import { version } from "./package.json";
2022-09-12 12:50:37 +00:00
const globalModules: any = JSON.parse(
readFileSync(
nodeModulesDir + "/node_modules/@silverbulletmd/web/dist/global.plug.json",
"utf-8"
)
);
2022-05-13 12:36:26 +00:00
2022-04-29 16:54:27 +00:00
const safeFilename = /^[a-zA-Z0-9_\-\.]+$/;
export type ServerOptions = {
port: number;
pagesPath: string;
distDir: string;
builtinPlugDir: string;
2022-06-28 12:14:15 +00:00
password?: string;
2022-04-29 16:54:27 +00:00
};
const storeVersionKey = "$silverBulletVersion";
const indexRequiredKey = "$spaceIndexed";
2022-03-21 14:21:34 +00:00
export class ExpressServer {
app: Express;
system: System<SilverBulletHooks>;
private space: Space;
private distDir: string;
private eventHook: EventHook;
private db: Knex<any, unknown[]>;
2022-04-24 16:06:34 +00:00
private port: number;
private server?: Server;
2022-04-26 17:04:36 +00:00
builtinPlugDir: string;
2022-06-28 12:14:15 +00:00
password?: string;
2022-08-02 10:43:39 +00:00
settings: { [key: string]: any } = {};
2022-09-12 12:50:37 +00:00
spacePrimitives: SpacePrimitives;
2022-03-21 14:21:34 +00:00
2022-04-29 16:54:27 +00:00
constructor(options: ServerOptions) {
this.port = options.port;
2022-04-24 16:06:34 +00:00
this.app = express();
2022-04-29 16:54:27 +00:00
this.builtinPlugDir = options.builtinPlugDir;
this.distDir = options.distDir;
2022-06-28 12:14:15 +00:00
this.password = options.password;
// Set up the PlugOS System
2022-04-24 16:06:34 +00:00
this.system = new System<SilverBulletHooks>("server");
2022-03-21 14:21:34 +00:00
2022-06-28 12:14:15 +00:00
// Instantiate the event bus hook
this.eventHook = new EventHook();
2022-04-24 16:06:34 +00:00
this.system.addHook(this.eventHook);
2022-06-28 12:14:15 +00:00
// And the page namespace hook
2022-05-17 09:53:17 +00:00
let namespaceHook = new PageNamespaceHook();
this.system.addHook(namespaceHook);
2022-06-28 12:14:15 +00:00
// The space
2022-09-12 12:50:37 +00:00
this.spacePrimitives = new EventedSpacePrimitives(
new PlugSpacePrimitives(
new DiskSpacePrimitives(options.pagesPath),
namespaceHook
),
2022-09-12 12:50:37 +00:00
this.eventHook
);
2022-09-12 12:50:37 +00:00
this.space = new Space(this.spacePrimitives);
2022-06-28 12:14:15 +00:00
// The database used for persistence (SQLite)
this.db = knex({
client: "better-sqlite3",
connection: {
2022-04-29 16:54:27 +00:00
filename: path.join(options.pagesPath, "data.db"),
},
useNullAsDefault: true,
});
2022-03-31 15:25:34 +00:00
2022-06-28 12:14:15 +00:00
// The cron hook
2022-04-24 16:06:34 +00:00
this.system.addHook(new NodeCronHook());
2022-03-31 15:25:34 +00:00
2022-07-11 11:50:55 +00:00
// Register syscalls available on the server side
2022-04-29 16:54:27 +00:00
this.system.registerSyscalls(
[],
pageIndexSyscalls(this.db),
storeSyscalls(this.db, "store"),
2022-05-16 13:09:36 +00:00
fullTextSearchSyscalls(this.db, "fts"),
2022-04-29 16:54:27 +00:00
spaceSyscalls(this.space),
eventSyscalls(this.eventHook),
markdownSyscalls(buildMarkdown([])),
esbuildSyscalls(),
systemSyscalls(this),
2022-05-09 12:59:12 +00:00
sandboxSyscalls(this.system),
2022-04-29 16:54:27 +00:00
jwtSyscalls()
);
2022-07-11 11:50:55 +00:00
// Danger zone
this.system.registerSyscalls(["shell"], shellSyscalls(options.pagesPath));
this.system.registerSyscalls(["fs"], fileSystemSyscalls("/"));
2022-06-28 12:14:15 +00:00
// Register the HTTP endpoint hook (with "/_/<plug-name>"" prefix, hardcoded for now)
2022-05-11 09:49:27 +00:00
this.system.addHook(new EndpointHook(this.app, "/_"));
2022-05-13 12:36:26 +00:00
this.system.on({
plugLoaded: (plug) => {
2022-06-28 12:14:15 +00:00
// Automatically inject some modules into each plug
2022-05-13 12:36:26 +00:00
safeRun(async () => {
for (let [modName, code] of Object.entries(
globalModules.dependencies
)) {
await plug.sandbox.loadDependency(modName, code as string);
2022-05-13 12:36:26 +00:00
}
});
},
});
2022-06-28 12:14:15 +00:00
// Hook into some "get-plug:" to allow loading plugs from disk (security of this TBD)
// First, for builtins (loaded from the packages/plugs/ folder)
2022-04-26 17:04:36 +00:00
this.eventHook.addLocalListener(
"get-plug:builtin",
async (plugName: string): Promise<Manifest> => {
2022-04-29 16:54:27 +00:00
if (!safeFilename.test(plugName)) {
throw new Error(`Invalid plug name: ${plugName}`);
}
2022-04-26 17:04:36 +00:00
try {
let manifestJson = await readFile(
path.join(this.builtinPlugDir, `${plugName}.plug.json`),
"utf8"
2022-04-25 09:24:13 +00:00
);
2022-04-26 17:04:36 +00:00
return JSON.parse(manifestJson);
} catch (e) {
throw new Error(`No such builtin: ${plugName}`);
}
}
);
2022-06-28 12:14:15 +00:00
// Second, for loading plug JSON files with absolute or relative (from CWD) paths
this.eventHook.addLocalListener(
"get-plug:file",
async (plugPath: string): Promise<Manifest> => {
let resolvedPath = path.resolve(plugPath);
if (!resolvedPath.startsWith(process.cwd())) {
throw new Error(
`Plugin path outside working directory, this is disallowed: ${resolvedPath}`
);
}
try {
let manifestJson = await readFile(resolvedPath, "utf8");
return JSON.parse(manifestJson);
} catch (e) {
throw new Error(
`No such file: ${resolvedPath} or could not parse as JSON`
);
}
}
);
2022-06-28 12:14:15 +00:00
// Rescan disk every 5s to detect any out-of-process file changes
setInterval(() => {
2022-04-26 17:04:36 +00:00
this.space.updatePageList().catch(console.error);
}, 5000);
}
2022-04-11 18:34:09 +00:00
rebuildMdExtensions() {
this.system.registerSyscalls(
[],
markdownSyscalls(buildMarkdown(loadMarkdownExtensions(this.system)))
);
}
2022-06-28 12:14:15 +00:00
// In case of a new space with no `PLUGS` file, generate a default one based on all built-in plugs
2022-04-26 18:31:31 +00:00
private async bootstrapBuiltinPlugs() {
let allPlugFiles = await readdir(this.builtinPlugDir);
let pluginNames = [];
for (let file of allPlugFiles) {
if (file.endsWith(".plug.json")) {
let manifestJson = await readFile(
path.join(this.builtinPlugDir, file),
"utf8"
);
let manifest: Manifest = JSON.parse(manifestJson);
pluginNames.push(manifest.name);
2022-09-12 12:50:37 +00:00
await this.spacePrimitives.writeFile(
`${plugPrefix}${file}`,
"string",
2022-04-26 18:31:31 +00:00
manifestJson
);
}
}
2022-08-10 10:03:58 +00:00
try {
await this.space.getPageMeta("PLUGS");
console.log("PLUGS file already exists, won't override it.");
return;
} catch {
console.log("Writing fresh PLUGS file.");
await this.space.writePage(
"PLUGS",
"This file lists all plugs that SilverBullet will load. Run the `Plugs: Update` command to update and reload this list of plugs.\n\n```yaml\n- " +
pluginNames.map((name) => `builtin:${name}`).join("\n- ") +
"\n```"
);
}
2022-04-26 18:31:31 +00:00
}
2022-04-26 17:04:36 +00:00
async reloadPlugs() {
// Version check
let lastRunningVersion = await this.system.localSyscall(
"core",
"store.get",
[storeVersionKey]
);
let upgrading = false;
if (lastRunningVersion !== version) {
upgrading = true;
console.log("Version change detected!");
console.log("Going to re-bootstrap with the builtin set of plugs...");
console.log("First removing existing plug files...");
const existingPlugFiles = (
await this.spacePrimitives.fetchFileList()
).filter((meta) => meta.name.startsWith(plugPrefix));
for (let plugFile of existingPlugFiles) {
await this.spacePrimitives.deleteFile(plugFile.name);
}
console.log("Now writing the default set of plugs...");
await this.bootstrapBuiltinPlugs();
await this.system.localSyscall("core", "store.set", [
storeVersionKey,
version,
]);
await this.system.localSyscall("core", "store.set", [
"$spaceIndexed",
false,
]);
}
2022-04-26 17:04:36 +00:00
await this.space.updatePageList();
2022-09-12 12:50:37 +00:00
let allPlugs = await this.space.listPlugs();
// Sanity check: are there any plugs at all? If not, let's put back the core set
2022-09-12 12:50:37 +00:00
if (allPlugs.length === 0) {
2022-04-26 18:31:31 +00:00
await this.bootstrapBuiltinPlugs();
2022-09-12 12:50:37 +00:00
allPlugs = await this.space.listPlugs();
2022-04-26 18:31:31 +00:00
}
2022-04-26 17:04:36 +00:00
await this.system.unloadAll();
console.log("Loading plugs", allPlugs);
2022-09-12 12:50:37 +00:00
for (let plugName of allPlugs) {
let { data } = await this.space.readAttachment(plugName, "string");
await this.system.load(JSON.parse(data as string), createSandbox);
2022-04-26 17:04:36 +00:00
}
this.rebuildMdExtensions();
let corePlug = this.system.loadedPlugs.get("core");
if (!corePlug) {
console.error("Something went very wrong, 'core' plug not found");
return;
}
// If we're upgrading, update plugs from PLUGS file
// This will automatically reinvoke an plugReload() call
if (upgrading) {
console.log("Now updating plugs");
await corePlug.invoke("updatePlugs", []);
}
// Do we need to reindex this space?
if (
!(await this.system.localSyscall("core", "store.get", [indexRequiredKey]))
) {
console.log("Now reindexing space...");
await corePlug.invoke("reindexSpace", []);
await this.system.localSyscall("core", "store.set", [
indexRequiredKey,
true,
]);
}
2022-04-26 17:04:36 +00:00
}
2022-04-24 16:06:34 +00:00
async start() {
2022-06-28 12:14:15 +00:00
const passwordMiddleware: (req: any, res: any, next: any) => void = this
.password
2022-04-29 16:54:27 +00:00
? (req, res, next) => {
2022-06-28 12:14:15 +00:00
if (req.headers.authorization === `Bearer ${this.password}`) {
2022-04-29 16:54:27 +00:00
next();
} else {
res.status(401).send("Unauthorized");
}
}
: (req, res, next) => {
next();
};
await ensureIndexTable(this.db);
await ensureStoreTable(this.db, "store");
2022-05-16 13:09:36 +00:00
await ensureFTSTable(this.db, "fts");
2022-08-02 10:43:39 +00:00
await this.ensureAndLoadSettings();
// Load plugs
this.reloadPlugs().catch(console.error);
2022-04-29 16:54:27 +00:00
// Serve static files (javascript, css, html)
2022-04-24 16:06:34 +00:00
this.app.use("/", express.static(this.distDir));
// Pages API
this.app.use(
2022-09-12 12:50:37 +00:00
"/fs",
passwordMiddleware,
2022-09-12 12:50:37 +00:00
buildFsRouter(this.spacePrimitives)
);
// Plug API
2022-09-12 12:50:37 +00:00
this.app.use("/plug", passwordMiddleware, this.buildPlugRouter());
// Fallback, serve index.html
2022-09-12 12:50:37 +00:00
this.app.get(/^(\/((?!fs\/).)+)$/, async (req, res) => {
res.sendFile(`${this.distDir}/index.html`, {});
});
this.server = http.createServer(this.app);
this.server.listen(this.port, () => {
console.log(
`Silver Bullet is now running: http://localhost:${this.port}`
);
console.log("--------------");
});
}
private buildPlugRouter() {
let plugRouter = express.Router();
plugRouter.post(
"/:plug/syscall/:name",
bodyParser.json(),
async (req, res) => {
const name = req.params.name;
const plugName = req.params.plug;
const args = req.body as any;
const plug = this.system.loadedPlugs.get(plugName);
if (!plug) {
res.status(404);
return res.send(`Plug ${plugName} not found`);
}
try {
const result = await this.system.syscallWithContext(
{ plug },
name,
args
);
res.status(200);
res.header("Content-Type", "application/json");
res.send(JSON.stringify(result));
} catch (e: any) {
res.status(500);
return res.send(e.message);
}
}
);
plugRouter.post(
"/:plug/function/:name",
bodyParser.json(),
async (req, res) => {
const name = req.params.name;
const plugName = req.params.plug;
const args = req.body as any[];
const plug = this.system.loadedPlugs.get(plugName);
if (!plug) {
res.status(404);
return res.send(`Plug ${plugName} not found`);
}
try {
const result = await plug.invoke(name, args);
res.status(200);
res.header("Content-Type", "application/json");
res.send(JSON.stringify(result));
} catch (e: any) {
res.status(500);
// console.log("Error invoking function", e);
return res.send(e.message);
}
}
);
return plugRouter;
}
2022-08-02 10:43:39 +00:00
async ensureAndLoadSettings() {
try {
await this.space.getPageMeta("SETTINGS");
} catch (e) {
await this.space.writePage("SETTINGS", settingsTemplate, true);
}
let { text: settingsText } = await this.space.readPage("SETTINGS");
this.settings = parseYamlSettings(settingsText);
if (!this.settings.indexPage) {
this.settings.indexPage = "index";
}
2022-06-28 12:14:15 +00:00
try {
2022-08-02 10:43:39 +00:00
await this.space.getPageMeta(this.settings.indexPage);
2022-06-28 12:14:15 +00:00
} catch (e) {
2022-08-02 10:43:39 +00:00
await this.space.writePage(
this.settings.indexPage,
`Welcome to your new space!`
);
2022-06-28 12:14:15 +00:00
}
}
2022-04-24 16:06:34 +00:00
async stop() {
if (this.server) {
console.log("Stopping");
await this.system.unloadAll();
console.log("Stopped plugs");
return new Promise<void>((resolve, reject) => {
this.server!.close((err) => {
this.server = undefined;
console.log("stopped server");
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
2022-03-21 14:21:34 +00:00
}
}
2022-09-12 12:50:37 +00:00
function buildFsRouter(spacePrimitives: SpacePrimitives) {
let fsRouter = express.Router();
// File list
fsRouter.route("/").get(async (req, res, next) => {
res.json(await spacePrimitives.fetchFileList());
});
fsRouter
.route(/\/(.+)/)
.get(async (req, res, next) => {
let name = req.params[0];
2022-09-13 06:41:01 +00:00
console.log("Loading file", name);
2022-09-12 12:50:37 +00:00
try {
let attachmentData = await spacePrimitives.readFile(
name,
"arraybuffer"
);
res.status(200);
res.header("Last-Modified", "" + attachmentData.meta.lastModified);
res.header("X-Permission", attachmentData.meta.perm);
res.header("Content-Type", attachmentData.meta.contentType);
res.send(Buffer.from(attachmentData.data as ArrayBuffer));
} catch (e) {
next();
}
})
.put(bodyParser.raw({ type: "*/*", limit: "100mb" }), async (req, res) => {
let name = req.params[0];
console.log("Saving file", name);
try {
let meta = await spacePrimitives.writeFile(
name,
"arraybuffer",
req.body,
false
);
res.status(200);
res.header("Last-Modified", "" + meta.lastModified);
res.header("Content-Type", meta.contentType);
res.header("Content-Length", "" + meta.size);
res.header("X-Permission", meta.perm);
res.send("OK");
} catch (err) {
res.status(500);
res.send("Write failed");
console.error("Pipeline failed", err);
}
})
.options(async (req, res, next) => {
let name = req.params[0];
try {
const meta = await spacePrimitives.getFileMeta(name);
res.status(200);
res.header("Last-Modified", "" + meta.lastModified);
res.header("X-Permission", meta.perm);
res.header("Content-Length", "" + meta.size);
res.header("Content-Type", meta.contentType);
res.send("");
} catch (e) {
next();
}
})
.delete(async (req, res) => {
let name = req.params[0];
try {
await spacePrimitives.deleteFile(name);
res.status(200);
res.send("OK");
} catch (e) {
console.error("Error deleting attachment", e);
res.status(500);
res.send("OK");
}
});
return fsRouter;
}