@@ -0,0 +1,4 @@
|
||||
This page contains settings for configuring SilverBullet and its plugs. Any changes outside of the yaml block will be overwritten.
|
||||
```yaml
|
||||
indexPage: index
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "../common/deps.ts";
|
||||
export { Database as SQLite } from "https://deno.land/x/sqlite3@0.6.1/mod.ts";
|
||||
export { Application, Router } from "https://deno.land/x/oak@v11.1.0/mod.ts";
|
||||
|
||||
export { mime } from "https://deno.land/x/mimetypes@v1.0.0/mod.ts";
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Plug } from "../../plugos/plug.ts";
|
||||
import { System } from "../../plugos/system.ts";
|
||||
import { Hook, Manifest } from "../../plugos/types.ts";
|
||||
|
||||
export type NamespaceOperation =
|
||||
| "readFile"
|
||||
| "writeFile"
|
||||
| "listFiles"
|
||||
| "getFileMeta"
|
||||
| "deleteFile";
|
||||
|
||||
export type PageNamespaceDef = {
|
||||
pattern: string;
|
||||
operation: NamespaceOperation;
|
||||
};
|
||||
|
||||
export type PageNamespaceHookT = {
|
||||
pageNamespace?: PageNamespaceDef;
|
||||
};
|
||||
|
||||
type SpaceFunction = {
|
||||
operation: NamespaceOperation;
|
||||
pattern: RegExp;
|
||||
plug: Plug<PageNamespaceHookT>;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export class PageNamespaceHook implements Hook<PageNamespaceHookT> {
|
||||
spaceFunctions: SpaceFunction[] = [];
|
||||
constructor() {}
|
||||
|
||||
apply(system: System<PageNamespaceHookT>): void {
|
||||
system.on({
|
||||
plugLoaded: () => {
|
||||
this.updateCache(system);
|
||||
},
|
||||
plugUnloaded: () => {
|
||||
this.updateCache(system);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
updateCache(system: System<PageNamespaceHookT>) {
|
||||
this.spaceFunctions = [];
|
||||
for (let plug of system.loadedPlugs.values()) {
|
||||
if (plug.manifest?.functions) {
|
||||
for (
|
||||
let [funcName, funcDef] of Object.entries(
|
||||
plug.manifest.functions,
|
||||
)
|
||||
) {
|
||||
if (funcDef.pageNamespace) {
|
||||
this.spaceFunctions.push({
|
||||
operation: funcDef.pageNamespace.operation,
|
||||
pattern: new RegExp(funcDef.pageNamespace.pattern),
|
||||
plug,
|
||||
name: funcName,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validateManifest(manifest: Manifest<PageNamespaceHookT>): string[] {
|
||||
let errors: string[] = [];
|
||||
if (!manifest.functions) {
|
||||
return [];
|
||||
}
|
||||
for (let [funcName, funcDef] of Object.entries(manifest.functions)) {
|
||||
if (funcDef.pageNamespace) {
|
||||
if (!funcDef.pageNamespace.pattern) {
|
||||
errors.push(`Function ${funcName} has a namespace but no pattern`);
|
||||
}
|
||||
if (!funcDef.pageNamespace.operation) {
|
||||
errors.push(`Function ${funcName} has a namespace but no operation`);
|
||||
}
|
||||
if (
|
||||
![
|
||||
"readFile",
|
||||
"writeFile",
|
||||
"getFileMeta",
|
||||
"listFiles",
|
||||
"deleteFile",
|
||||
].includes(funcDef.pageNamespace.operation)
|
||||
) {
|
||||
errors.push(
|
||||
`Function ${funcName} has an invalid operation ${funcDef.pageNamespace.operation}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Plug } from "../../plugos/plug.ts";
|
||||
import {
|
||||
FileData,
|
||||
FileEncoding,
|
||||
SpacePrimitives,
|
||||
} from "../../common/spaces/space_primitives.ts";
|
||||
import { AttachmentMeta, FileMeta, PageMeta } from "../../common/types.ts";
|
||||
import { NamespaceOperation, PageNamespaceHook } from "./page_namespace.ts";
|
||||
|
||||
export class PlugSpacePrimitives implements SpacePrimitives {
|
||||
constructor(
|
||||
private wrapped: SpacePrimitives,
|
||||
private hook: PageNamespaceHook,
|
||||
) {}
|
||||
|
||||
performOperation(
|
||||
type: NamespaceOperation,
|
||||
pageName: string,
|
||||
...args: any[]
|
||||
): Promise<any> | false {
|
||||
for (let { operation, pattern, plug, name } of this.hook.spaceFunctions) {
|
||||
if (operation === type && pageName.match(pattern)) {
|
||||
return plug.invoke(name, [pageName, ...args]);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async fetchFileList(): Promise<FileMeta[]> {
|
||||
let allFiles: FileMeta[] = [];
|
||||
for (let { plug, name, operation } of this.hook.spaceFunctions) {
|
||||
if (operation === "listFiles") {
|
||||
try {
|
||||
for (let pm of await plug.invoke(name, [])) {
|
||||
allFiles.push(pm);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error listing files", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
let result = await this.wrapped.fetchFileList();
|
||||
for (let pm of result) {
|
||||
allFiles.push(pm);
|
||||
}
|
||||
return allFiles;
|
||||
}
|
||||
|
||||
readFile(
|
||||
name: string,
|
||||
encoding: FileEncoding,
|
||||
): Promise<{ data: FileData; meta: FileMeta }> {
|
||||
let result = this.performOperation("readFile", name);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
return this.wrapped.readFile(name, encoding);
|
||||
}
|
||||
|
||||
getFileMeta(name: string): Promise<FileMeta> {
|
||||
let result = this.performOperation("getFileMeta", name);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
return this.wrapped.getFileMeta(name);
|
||||
}
|
||||
|
||||
writeFile(
|
||||
name: string,
|
||||
encoding: FileEncoding,
|
||||
data: FileData,
|
||||
selfUpdate?: boolean,
|
||||
): Promise<FileMeta> {
|
||||
let result = this.performOperation(
|
||||
"writeFile",
|
||||
name,
|
||||
encoding,
|
||||
data,
|
||||
selfUpdate,
|
||||
);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return this.wrapped.writeFile(name, encoding, data, selfUpdate);
|
||||
}
|
||||
|
||||
deleteFile(name: string): Promise<void> {
|
||||
let result = this.performOperation("deleteFile", name);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
return this.wrapped.deleteFile(name);
|
||||
}
|
||||
|
||||
proxySyscall(plug: Plug<any>, name: string, args: any[]): Promise<any> {
|
||||
return this.wrapped.proxySyscall(plug, name, args);
|
||||
}
|
||||
|
||||
invokeFunction(
|
||||
plug: Plug<any>,
|
||||
env: string,
|
||||
name: string,
|
||||
args: any[],
|
||||
): Promise<any> {
|
||||
return this.wrapped.invokeFunction(plug, env, name, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
import { Application, path, Router, SQLite } from "./deps.ts";
|
||||
import {
|
||||
AssetBundle,
|
||||
assetReadFileSync,
|
||||
assetReadTextFileSync,
|
||||
assetStatSync,
|
||||
} from "../plugos/asset_bundle_reader.ts";
|
||||
import { Manifest, SilverBulletHooks } from "../common/manifest.ts";
|
||||
import { loadMarkdownExtensions } from "../common/markdown_ext.ts";
|
||||
import buildMarkdown from "../common/parser.ts";
|
||||
import { DiskSpacePrimitives } from "../common/spaces/disk_space_primitives.ts";
|
||||
import { EventedSpacePrimitives } from "../common/spaces/evented_space_primitives.ts";
|
||||
import { Space } from "../common/spaces/space.ts";
|
||||
import { SpacePrimitives } from "../common/spaces/space_primitives.ts";
|
||||
import { markdownSyscalls } from "../common/syscalls/markdown.ts";
|
||||
import { parseYamlSettings } from "../common/util.ts";
|
||||
import { createSandbox } from "../plugos/environments/deno_sandbox.ts";
|
||||
import { EndpointHook } from "../plugos/hooks/endpoint.ts";
|
||||
import { EventHook } from "../plugos/hooks/event.ts";
|
||||
import { DenoCronHook } from "../plugos/hooks/cron.deno.ts";
|
||||
import { esbuildSyscalls } from "../plugos/syscalls/esbuild.ts";
|
||||
import { eventSyscalls } from "../plugos/syscalls/event.ts";
|
||||
import fileSystemSyscalls from "../plugos/syscalls/fs.deno.ts";
|
||||
import { fullTextSearchSyscalls } from "../plugos/syscalls/fulltext.knex_sqlite.ts";
|
||||
import sandboxSyscalls from "../plugos/syscalls/sandbox.ts";
|
||||
import shellSyscalls from "../plugos/syscalls/shell.node.ts";
|
||||
import {
|
||||
ensureTable as ensureStoreTable,
|
||||
storeSyscalls,
|
||||
} from "../plugos/syscalls/store.deno.ts";
|
||||
import { System } from "../plugos/system.ts";
|
||||
import { PageNamespaceHook } from "./hooks/page_namespace.ts";
|
||||
import { PlugSpacePrimitives } from "./hooks/plug_space_primitives.ts";
|
||||
import {
|
||||
ensureTable as ensureIndexTable,
|
||||
pageIndexSyscalls,
|
||||
} from "./syscalls/index.ts";
|
||||
import spaceSyscalls from "./syscalls/space.ts";
|
||||
import { systemSyscalls } from "./syscalls/system.ts";
|
||||
import { AssetBundlePlugSpacePrimitives } from "../common/spaces/asset_bundle_space_primitives.ts";
|
||||
import assetSyscalls from "../plugos/syscalls/asset.ts";
|
||||
|
||||
export type ServerOptions = {
|
||||
port: number;
|
||||
pagesPath: string;
|
||||
assetBundle: AssetBundle;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
const indexRequiredKey = "$spaceIndexed";
|
||||
|
||||
export class HttpServer {
|
||||
app: Application;
|
||||
system: System<SilverBulletHooks>;
|
||||
private space: Space;
|
||||
private eventHook: EventHook;
|
||||
private db: SQLite;
|
||||
private port: number;
|
||||
password?: string;
|
||||
settings: { [key: string]: any } = {};
|
||||
spacePrimitives: SpacePrimitives;
|
||||
abortController?: AbortController;
|
||||
globalModules: Manifest;
|
||||
assetBundle: AssetBundle;
|
||||
|
||||
constructor(options: ServerOptions) {
|
||||
this.port = options.port;
|
||||
this.app = new Application();
|
||||
this.assetBundle = options.assetBundle;
|
||||
this.password = options.password;
|
||||
|
||||
this.globalModules = JSON.parse(
|
||||
assetReadTextFileSync(this.assetBundle, `web/global.plug.json`),
|
||||
);
|
||||
|
||||
// Set up the PlugOS System
|
||||
this.system = new System<SilverBulletHooks>("server");
|
||||
|
||||
// Instantiate the event bus hook
|
||||
this.eventHook = new EventHook();
|
||||
this.system.addHook(this.eventHook);
|
||||
|
||||
// And the page namespace hook
|
||||
const namespaceHook = new PageNamespaceHook();
|
||||
this.system.addHook(namespaceHook);
|
||||
|
||||
// The space
|
||||
this.spacePrimitives = new AssetBundlePlugSpacePrimitives(
|
||||
new EventedSpacePrimitives(
|
||||
new PlugSpacePrimitives(
|
||||
new DiskSpacePrimitives(options.pagesPath),
|
||||
namespaceHook,
|
||||
),
|
||||
this.eventHook,
|
||||
),
|
||||
this.assetBundle,
|
||||
);
|
||||
this.space = new Space(this.spacePrimitives);
|
||||
|
||||
// The database used for persistence (SQLite)
|
||||
this.db = new SQLite(path.join(options.pagesPath, "data.db"));
|
||||
|
||||
// The cron hook
|
||||
this.system.addHook(new DenoCronHook());
|
||||
|
||||
// Register syscalls available on the server side
|
||||
this.system.registerSyscalls(
|
||||
[],
|
||||
pageIndexSyscalls(this.db),
|
||||
storeSyscalls(this.db, "store"),
|
||||
fullTextSearchSyscalls(this.db, "fts"),
|
||||
spaceSyscalls(this.space),
|
||||
eventSyscalls(this.eventHook),
|
||||
markdownSyscalls(buildMarkdown([])),
|
||||
esbuildSyscalls(),
|
||||
systemSyscalls(this),
|
||||
sandboxSyscalls(this.system),
|
||||
assetSyscalls(this.system),
|
||||
// jwtSyscalls(),
|
||||
);
|
||||
// Danger zone
|
||||
this.system.registerSyscalls(["shell"], shellSyscalls(options.pagesPath));
|
||||
this.system.registerSyscalls(["fs"], fileSystemSyscalls("/"));
|
||||
|
||||
// Register the HTTP endpoint hook (with "/_/<plug-name>"" prefix, hardcoded for now)
|
||||
this.system.addHook(new EndpointHook(this.app, "/_"));
|
||||
|
||||
this.system.on({
|
||||
plugLoaded: async (plug) => {
|
||||
for (
|
||||
const [modName, code] of Object.entries(
|
||||
this.globalModules.dependencies!,
|
||||
)
|
||||
) {
|
||||
await plug.sandbox.loadDependency(modName, code as string);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Second, for loading plug JSON files with absolute or relative (from CWD) paths
|
||||
this.eventHook.addLocalListener(
|
||||
"get-plug:file",
|
||||
async (plugPath: string): Promise<Manifest> => {
|
||||
const resolvedPath = path.resolve(plugPath);
|
||||
if (!resolvedPath.startsWith(Deno.cwd())) {
|
||||
throw new Error(
|
||||
`Plugin path outside working directory, this is disallowed: ${resolvedPath}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const manifestJson = await Deno.readTextFile(resolvedPath);
|
||||
return JSON.parse(manifestJson);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`No such file: ${resolvedPath} or could not parse as JSON`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Rescan disk every 5s to detect any out-of-process file changes
|
||||
setInterval(() => {
|
||||
this.space.updatePageList().catch(console.error);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
rebuildMdExtensions() {
|
||||
this.system.registerSyscalls(
|
||||
[],
|
||||
markdownSyscalls(buildMarkdown(loadMarkdownExtensions(this.system))),
|
||||
);
|
||||
}
|
||||
|
||||
async reloadPlugs() {
|
||||
await this.space.updatePageList();
|
||||
|
||||
const allPlugs = await this.space.listPlugs();
|
||||
|
||||
console.log("Loading plugs", allPlugs);
|
||||
for (const plugName of allPlugs) {
|
||||
const { data } = await this.space.readAttachment(plugName, "string");
|
||||
await this.system.load(JSON.parse(data as string), createSandbox);
|
||||
}
|
||||
this.rebuildMdExtensions();
|
||||
|
||||
const corePlug = this.system.loadedPlugs.get("core");
|
||||
if (!corePlug) {
|
||||
console.error("Something went very wrong, 'core' plug not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async start() {
|
||||
await ensureIndexTable(this.db);
|
||||
await ensureStoreTable(this.db, "store");
|
||||
// await ensureFTSTable(this.db, "fts");
|
||||
await this.ensureAndLoadSettings();
|
||||
|
||||
// Load plugs
|
||||
this.reloadPlugs().catch(console.error);
|
||||
|
||||
// Serve static files (javascript, css, html)
|
||||
this.app.use(async ({ request, response }, next) => {
|
||||
if (request.url.pathname === "/") {
|
||||
response.headers.set("Content-type", "text/html");
|
||||
response.body = assetReadTextFileSync(
|
||||
this.assetBundle,
|
||||
"web/index.html",
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const assetName = `web${request.url.pathname}`;
|
||||
const meta = assetStatSync(this.assetBundle, assetName);
|
||||
response.status = 200;
|
||||
response.headers.set(
|
||||
"Content-type",
|
||||
meta.contentType,
|
||||
);
|
||||
response.headers.set("Content-length", "" + meta.size);
|
||||
response.headers.set(
|
||||
"Last-Modified",
|
||||
new Date(meta.lastModified).toUTCString(),
|
||||
);
|
||||
if (request.method === "GET") {
|
||||
response.body = assetReadFileSync(
|
||||
this.assetBundle,
|
||||
assetName,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
await next();
|
||||
}
|
||||
});
|
||||
|
||||
// Simple password authentication
|
||||
if (this.password) {
|
||||
this.app.use(({ request, response }, next) => {
|
||||
if (
|
||||
request.headers.get("Authorization") === `Bearer ${this.password}`
|
||||
) {
|
||||
return next();
|
||||
} else {
|
||||
response.status = 401;
|
||||
response.body = "Unauthorized";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Pages API
|
||||
const fsRouter = buildFsRouter(this.spacePrimitives);
|
||||
this.app.use(fsRouter.routes());
|
||||
this.app.use(fsRouter.allowedMethods());
|
||||
|
||||
// Plug API
|
||||
const plugRouter = this.buildPlugRouter();
|
||||
this.app.use(plugRouter.routes());
|
||||
this.app.use(plugRouter.allowedMethods());
|
||||
|
||||
// Fallback, serve index.html
|
||||
this.app.use((ctx) => {
|
||||
ctx.response.headers.set("Content-type", "text/html");
|
||||
ctx.response.body = assetReadTextFileSync(
|
||||
this.assetBundle,
|
||||
"web/index.html",
|
||||
);
|
||||
});
|
||||
|
||||
this.abortController = new AbortController();
|
||||
this.app.listen({ port: this.port, signal: this.abortController.signal })
|
||||
.catch(console.error);
|
||||
console.log(
|
||||
`Silver Bullet is now running: http://localhost:${this.port}`,
|
||||
);
|
||||
console.log("--------------");
|
||||
}
|
||||
|
||||
private buildPlugRouter(): Router {
|
||||
const plugRouter = new Router();
|
||||
|
||||
plugRouter.post(
|
||||
"/:plug/syscall/:name",
|
||||
async (ctx) => {
|
||||
const name = ctx.params.name;
|
||||
const plugName = ctx.params.plug;
|
||||
const args = await ctx.request.body().value;
|
||||
const plug = this.system.loadedPlugs.get(plugName);
|
||||
if (!plug) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = `Plug ${plugName} not found`;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await this.system.syscallWithContext(
|
||||
{ plug },
|
||||
name,
|
||||
args,
|
||||
);
|
||||
ctx.response.headers.set("Content-Type", "application/json");
|
||||
ctx.response.body = JSON.stringify(result);
|
||||
} catch (e: any) {
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = e.message;
|
||||
return;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
plugRouter.post(
|
||||
"/:plug/function/:name",
|
||||
async (ctx) => {
|
||||
const name = ctx.params.name;
|
||||
const plugName = ctx.params.plug;
|
||||
const args = await ctx.request.body().value;
|
||||
const plug = this.system.loadedPlugs.get(plugName);
|
||||
if (!plug) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = `Plug ${plugName} not found`;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await plug.invoke(name, args);
|
||||
ctx.response.headers.set("Content-Type", "application/json");
|
||||
ctx.response.body = JSON.stringify(result);
|
||||
} catch (e: any) {
|
||||
ctx.response.status = 500;
|
||||
// console.log("Error invoking function", e);
|
||||
ctx.response.body = e.message;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return new Router().use("/plug", plugRouter.routes());
|
||||
}
|
||||
|
||||
async ensureAndLoadSettings() {
|
||||
try {
|
||||
await this.space.getPageMeta("SETTINGS");
|
||||
} catch {
|
||||
await this.space.writePage(
|
||||
"SETTINGS",
|
||||
await Deno.readTextFile(
|
||||
new URL("SETTINGS_template.md", import.meta.url).pathname,
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
const { text: settingsText } = await this.space.readPage("SETTINGS");
|
||||
this.settings = parseYamlSettings(settingsText);
|
||||
if (!this.settings.indexPage) {
|
||||
this.settings.indexPage = "index";
|
||||
}
|
||||
|
||||
try {
|
||||
await this.space.getPageMeta(this.settings.indexPage);
|
||||
} catch {
|
||||
await this.space.writePage(
|
||||
this.settings.indexPage,
|
||||
`Welcome to your new space!`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (this.abortController) {
|
||||
console.log("Stopping");
|
||||
await this.system.unloadAll();
|
||||
console.log("Stopped plugs");
|
||||
this.abortController.abort();
|
||||
console.log("stopped server");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildFsRouter(spacePrimitives: SpacePrimitives): Router {
|
||||
const fsRouter = new Router();
|
||||
|
||||
// File list
|
||||
fsRouter.get("/", async ({ response }) => {
|
||||
const list = await spacePrimitives.fetchFileList();
|
||||
// console.log("List", list);
|
||||
response.headers.set("Content-type", "application/json");
|
||||
response.body = JSON.stringify(list);
|
||||
});
|
||||
|
||||
fsRouter
|
||||
.get("\/(.+)", async ({ params, response }) => {
|
||||
const name = params[0];
|
||||
console.log("Loading file", name);
|
||||
try {
|
||||
const attachmentData = await spacePrimitives.readFile(
|
||||
name,
|
||||
"arraybuffer",
|
||||
);
|
||||
response.status = 200;
|
||||
response.headers.set(
|
||||
"Last-Modified",
|
||||
"" + attachmentData.meta.lastModified,
|
||||
);
|
||||
response.headers.set("X-Permission", attachmentData.meta.perm);
|
||||
response.headers.set("Content-Type", attachmentData.meta.contentType);
|
||||
response.body = attachmentData.data as ArrayBuffer;
|
||||
} catch {
|
||||
// console.error("Error in main router", e);
|
||||
response.status = 404;
|
||||
response.body = "";
|
||||
}
|
||||
})
|
||||
.put("\/(.+)", async ({ request, response, params }) => {
|
||||
const name = params[0];
|
||||
console.log("Saving file", name);
|
||||
|
||||
try {
|
||||
const meta = await spacePrimitives.writeFile(
|
||||
name,
|
||||
"arraybuffer",
|
||||
await request.body().value,
|
||||
false,
|
||||
);
|
||||
response.status = 200;
|
||||
response.headers.set("Last-Modified", "" + meta.lastModified);
|
||||
response.headers.set("Content-Type", meta.contentType);
|
||||
response.headers.set("Content-Length", "" + meta.size);
|
||||
response.headers.set("X-Permission", meta.perm);
|
||||
response.body = "OK";
|
||||
} catch (err) {
|
||||
response.status = 500;
|
||||
response.body = "Write failed";
|
||||
console.error("Pipeline failed", err);
|
||||
}
|
||||
})
|
||||
.options("\/(.+)", async ({ response, params }, next) => {
|
||||
const name = params[0];
|
||||
try {
|
||||
const meta = await spacePrimitives.getFileMeta(name);
|
||||
response.status = 200;
|
||||
response.headers.set("Last-Modified", "" + meta.lastModified);
|
||||
response.headers.set("Content-Type", meta.contentType);
|
||||
response.headers.set("Content-Length", "" + meta.size);
|
||||
response.headers.set("X-Permission", meta.perm);
|
||||
} catch {
|
||||
next();
|
||||
}
|
||||
})
|
||||
.delete("\/(.+)", async ({ response, params }) => {
|
||||
const name = params[0];
|
||||
try {
|
||||
await spacePrimitives.deleteFile(name);
|
||||
response.status = 200;
|
||||
response.body = "OK";
|
||||
} catch (e: any) {
|
||||
console.error("Error deleting attachment", e);
|
||||
response.status = 200;
|
||||
response.body = e.message;
|
||||
}
|
||||
});
|
||||
return new Router().use("/fs", fsRouter.routes());
|
||||
}
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env -S node --enable-source-maps
|
||||
import * as flags from "https://deno.land/std@0.158.0/flags/mod.ts";
|
||||
import * as path from "https://deno.land/std@0.158.0/path/mod.ts";
|
||||
import { HttpServer } from "./http_server.ts";
|
||||
|
||||
const args = flags.parse(Deno.args, {
|
||||
string: ["port", "password", "builtins"],
|
||||
alias: { p: "port" },
|
||||
default: {
|
||||
port: "3000",
|
||||
},
|
||||
});
|
||||
|
||||
if (!args._.length) {
|
||||
console.error(
|
||||
"Usage: silverbullet [--port 3000] [--password mysecretpassword] <path-to-pages>",
|
||||
);
|
||||
Deno.exit(1);
|
||||
}
|
||||
|
||||
const pagesPath = path.resolve(Deno.cwd(), args._[0] as string);
|
||||
const port = +args.port;
|
||||
|
||||
import assetBundle from "../dist/asset_bundle.json" assert { type: "json" };
|
||||
import { AssetBundle } from "../plugos/asset_bundle_reader.ts";
|
||||
|
||||
console.log("Pages dir", pagesPath);
|
||||
|
||||
const expressServer = new HttpServer({
|
||||
port: port,
|
||||
pagesPath: pagesPath,
|
||||
assetBundle: assetBundle as AssetBundle,
|
||||
password: args.password,
|
||||
});
|
||||
expressServer.start().catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
// import { Knex } from "knex";
|
||||
import { SysCallMapping } from "../../plugos/system.ts";
|
||||
import {
|
||||
asyncExecute,
|
||||
asyncQuery,
|
||||
Query,
|
||||
queryToSql,
|
||||
} from "../../plugos/syscalls/store.deno.ts";
|
||||
import { SQLite } from "../deps.ts";
|
||||
|
||||
type Item = {
|
||||
page: string;
|
||||
key: string;
|
||||
value: any;
|
||||
};
|
||||
|
||||
export type KV = {
|
||||
key: string;
|
||||
value: any;
|
||||
};
|
||||
|
||||
const tableName = "page_index";
|
||||
|
||||
export function ensureTable(db: SQLite): Promise<void> {
|
||||
const stmt = db.prepare(
|
||||
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
|
||||
);
|
||||
const result = stmt.all(tableName);
|
||||
if (result.length === 0) {
|
||||
db.exec(
|
||||
`CREATE TABLE ${tableName} (key STRING, page STRING, value TEXT, PRIMARY KEY (page, key));`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX ${tableName}_idx ON ${tableName}(key);`,
|
||||
);
|
||||
console.log(`Created table ${tableName}`);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
export function pageIndexSyscalls(db: SQLite): SysCallMapping {
|
||||
const apiObj: SysCallMapping = {
|
||||
"index.set": async (ctx, page: string, key: string, value: any) => {
|
||||
await asyncExecute(
|
||||
db,
|
||||
`UPDATE ${tableName} SET value = ? WHERE key = ? AND page = ?`,
|
||||
JSON.stringify(value),
|
||||
key,
|
||||
page,
|
||||
);
|
||||
if (db.changes === 0) {
|
||||
await asyncExecute(
|
||||
db,
|
||||
`INSERT INTO ${tableName} (key, page, value) VALUES (?, ?, ?)`,
|
||||
key,
|
||||
page,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
}
|
||||
},
|
||||
"index.batchSet": async (ctx, page: string, kvs: KV[]) => {
|
||||
for (let { key, value } of kvs) {
|
||||
await apiObj["index.set"](ctx, page, key, value);
|
||||
}
|
||||
},
|
||||
"index.delete": async (ctx, page: string, key: string) => {
|
||||
await asyncExecute(
|
||||
db,
|
||||
`DELETE FROM ${tableName} WHERE key = ? AND page = ?`,
|
||||
key,
|
||||
page,
|
||||
);
|
||||
},
|
||||
"index.get": async (ctx, page: string, key: string) => {
|
||||
const result = await asyncQuery<Item>(
|
||||
db,
|
||||
`SELECT value FROM ${tableName} WHERE key = ? AND page = ?`,
|
||||
key,
|
||||
page,
|
||||
);
|
||||
if (result.length) {
|
||||
return JSON.parse(result[0].value);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
"index.queryPrefix": async (ctx, prefix: string) => {
|
||||
return (
|
||||
await asyncQuery<Item>(
|
||||
db,
|
||||
`SELECT key, page, value FROM ${tableName} WHERE key LIKE "${prefix}%"`,
|
||||
)
|
||||
).map(({ key, value, page }) => ({
|
||||
key,
|
||||
page,
|
||||
value: JSON.parse(value),
|
||||
}));
|
||||
},
|
||||
"index.query": async (ctx, query: Query) => {
|
||||
const { sql, params } = queryToSql(query);
|
||||
return (
|
||||
await asyncQuery<Item>(
|
||||
db,
|
||||
`SELECT key, value FROM ${tableName} ${sql}`,
|
||||
...params,
|
||||
)
|
||||
).map(({ key, value, page }: any) => ({
|
||||
key,
|
||||
page,
|
||||
value: JSON.parse(value),
|
||||
}));
|
||||
},
|
||||
"index.clearPageIndexForPage": async (ctx, page: string) => {
|
||||
await apiObj["index.deletePrefixForPage"](ctx, page, "");
|
||||
},
|
||||
"index.deletePrefixForPage": async (ctx, page: string, prefix: string) => {
|
||||
await asyncExecute(
|
||||
db,
|
||||
`DELETE FROM ${tableName} WHERE key LIKE "${prefix}%" AND page = ?`,
|
||||
page,
|
||||
);
|
||||
},
|
||||
"index.clearPageIndex": async (ctx) => {
|
||||
await asyncExecute(
|
||||
db,
|
||||
`DELETE FROM ${tableName}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
return apiObj;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { AttachmentMeta, PageMeta } from "../../common/types.ts";
|
||||
import { SysCallMapping } from "../../plugos/system.ts";
|
||||
import { Space } from "../../common/spaces/space.ts";
|
||||
import {
|
||||
FileData,
|
||||
FileEncoding,
|
||||
} from "../../common/spaces/space_primitives.ts";
|
||||
|
||||
export default (space: Space): SysCallMapping => {
|
||||
return {
|
||||
"space.listPages": async (): Promise<PageMeta[]> => {
|
||||
return [...space.listPages()];
|
||||
},
|
||||
"space.readPage": async (
|
||||
ctx,
|
||||
name: string,
|
||||
): Promise<{ text: string; meta: PageMeta }> => {
|
||||
return space.readPage(name);
|
||||
},
|
||||
"space.getPageMeta": async (ctx, name: string): Promise<PageMeta> => {
|
||||
return space.getPageMeta(name);
|
||||
},
|
||||
"space.writePage": async (
|
||||
ctx,
|
||||
name: string,
|
||||
text: string,
|
||||
): Promise<PageMeta> => {
|
||||
return space.writePage(name, text);
|
||||
},
|
||||
"space.deletePage": async (ctx, name: string) => {
|
||||
return space.deletePage(name);
|
||||
},
|
||||
"space.listPlugs": async (): Promise<string[]> => {
|
||||
return await space.listPlugs();
|
||||
},
|
||||
"space.listAttachments": async (ctx): Promise<AttachmentMeta[]> => {
|
||||
return await space.fetchAttachmentList();
|
||||
},
|
||||
"space.readAttachment": async (
|
||||
ctx,
|
||||
name: string,
|
||||
): Promise<{ data: FileData; meta: AttachmentMeta }> => {
|
||||
return await space.readAttachment(name, "dataurl");
|
||||
},
|
||||
"space.getAttachmentMeta": async (
|
||||
ctx,
|
||||
name: string,
|
||||
): Promise<AttachmentMeta> => {
|
||||
return await space.getAttachmentMeta(name);
|
||||
},
|
||||
"space.writeAttachment": async (
|
||||
ctx,
|
||||
name: string,
|
||||
encoding: FileEncoding,
|
||||
data: string,
|
||||
): Promise<AttachmentMeta> => {
|
||||
return await space.writeAttachment(name, encoding, data);
|
||||
},
|
||||
"space.deleteAttachment": async (ctx, name: string) => {
|
||||
await space.deleteAttachment(name);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { SysCallMapping } from "../../plugos/system.ts";
|
||||
import type { HttpServer } from "../http_server.ts";
|
||||
|
||||
export function systemSyscalls(httpServer: HttpServer): SysCallMapping {
|
||||
return {
|
||||
"system.invokeFunction": (
|
||||
ctx,
|
||||
env: string,
|
||||
name: string,
|
||||
...args: any[]
|
||||
) => {
|
||||
if (!ctx.plug) {
|
||||
throw Error("No plug associated with context");
|
||||
}
|
||||
return ctx.plug.invoke(name, args);
|
||||
},
|
||||
"system.reloadPlugs": () => {
|
||||
return httpServer.reloadPlugs();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export function safeRun(fn: () => Promise<void>) {
|
||||
fn().catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user