diff --git a/src/interface-checker.ts b/src/interface-checker.ts index f7a4490..6286a7d 100644 --- a/src/interface-checker.ts +++ b/src/interface-checker.ts @@ -3,6 +3,7 @@ import { ServerConfig_AccessOptions, ServerConfig_PutSaver, Config, + ServerConfig_Controller, } from "./server-config"; import { as } from "./server-types"; @@ -400,25 +401,32 @@ class CheckRepeat extends TypeCheck { export const checkRepeat = (cb: () => TypeCheck, expected: string) => new CheckRepeat(cb, expected); -// export function checkServerConfig(obj, checker: boolean): true | {}; -// export function checkServerConfig(obj, checker: TypeCheck): true | {}; + +const checkAccessPerms = checkObject({ + mkdir: checkBoolean, + upload: checkBoolean, + websockets: checkBoolean, + writeErrors: checkBoolean, + registerNotice: checkBoolean, + putsaver: checkBoolean, + loginlink: checkBoolean, + transfer: checkBoolean, + datafolder: checkBoolean +}); + +export const checkController = checkArray(checkObject({ + publicKey: checkString, + permissions: checkUnion(checkBooleanFalse, checkAccessPerms), + allowRestart: checkBoolean, + allowSave: checkBoolean +})); export function checkServerConfig(obj): readonly [boolean, string | {}] { // if(checker === undefined) checker = new CheckInterface(false); // else if (typeof checker === "boolean") checker = new CheckInterface(checker); // let checker = new CheckInterface(showUnionNulls); // let { checkBoolean, checkString, checkStringEnum, checkNumber, checkNumberEnum, checkBooleanFalse, checkNull } = checker; - const checkAccessPerms = checkObject({ - mkdir: checkBoolean, - upload: checkBoolean, - websockets: checkBoolean, - writeErrors: checkBoolean, - registerNotice: checkBoolean, - putsaver: checkBoolean, - loginlink: checkBoolean, - transfer: checkBoolean, - datafolder: checkBoolean - }); + const putsaverOptional = as>({ backupFolder: checkString, etag: checkStringEnum("optional", "required", "disabled"), @@ -526,6 +534,7 @@ export function checkServerConfig(obj): readonly [boolean, string | {}] { localAddressPermissions: checkRecord(checkString, checkAccessPerms), port: checkNumber, }), + controllers: checkController, directoryIndex: checkObject({ defaultType: checkStringEnum("html", "json"), icons: checkRecord(checkString, checkArray(checkString)), @@ -549,4 +558,5 @@ export function checkServerConfig(obj): readonly [boolean, string | {}] { //not conform to ServerConfig and the server is about to exit. The error data is in `res`. // console.log("Check server config result: " + JSON.stringify(res, null, 2)); return [res, errHash] as const; + } diff --git a/src/server-config.ts b/src/server-config.ts index bbff8ab..7c84e56 100644 --- a/src/server-config.ts +++ b/src/server-config.ts @@ -304,6 +304,7 @@ export function normalizeSettings(_set: ServerConfigSchema, settingsFile) { gzipBackups: set.putsaver.gzipBackups(true), }, datafolder: set.datafolder({}), + controllers: set.controllers([]), directoryIndex: { // ...{ defaultType: set.directoryIndex.defaultType("html"), @@ -434,6 +435,7 @@ export interface ServerConfigSchema { * which exports the object */ authAccounts?: { [K: string]: ServerConfig_AuthAccountsValue }; + controllers?: ServerConfig_Controller[]; // /** client-side data folder loader which loads datafolders directly into the browser */ // EXPERIMENTAL_clientside_datafolders?: Partial, /** @@ -490,6 +492,8 @@ export interface ServerConfig { * which exports the object */ authAccounts: { [K: string]: ServerConfig_AuthAccountsValue }; + /** An array of controllers which can control TiddlyServer */ + controllers: ServerConfig_Controller[]; // /** client-side data folder loader which loads datafolders directly into the browser */ // EXPERIMENTAL_clientside_datafolders: ServerConfig_ClientsideDatafolders, /** @@ -621,6 +625,19 @@ export interface ServerConfig_BindInfo { /** always bind a separate server instance to 127.0.0.1 regardless of any other settings */ _bindLocalhost: boolean; } +export interface ServerConfig_Controller { + /** The public key of this controller. */ + publicKey: string + /** Allow the browser to order restarts of the server listeners and data folders with new settings applied */ + allowRestart: boolean + /** Allow changed settings to be saved back to the loaded settings.json file */ + allowSave: boolean + /** + * Connections from this browser will use these permissions instead of the permissions from the local address. + * Permissions only apply if not logged in. Tree authList is unaffected. Set to false to not use this. + */ + permissions: ServerConfig_AccessOptions | false +} export interface ServerConfig_Logging { /** access log file */ logAccess: string | false; diff --git a/src/server.ts b/src/server.ts index ce0c454..7a2097f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -13,7 +13,7 @@ import * as WebSocket from "ws"; import { handler as morgan } from "./logger"; import { handleAuthRoute } from "./auth-route"; import { checkCookieAuth } from "./cookies"; -import { checkServerConfig } from "./interface-checker"; +import { checkServerConfig, checkController } from "./interface-checker"; import { RequestEvent } from "./request-event"; import { Observable, Subject, Subscription } from "rxjs"; import { filter } from "rxjs/operators"; @@ -26,11 +26,14 @@ import { ServerConfig, ServerEventEmitter, testAddress, + ServerConfigSchema, + ServerConfig_AccessOptions, } from "./server-types"; import { StateObject } from "./state-object"; import { handleTreeRoute } from "./tiddlyserver"; import { EventEmitter } from "./event-emitter-types"; import { Socket } from "net"; +import { ServerConfig_Controller } from "./server-config"; export { checkServerConfig, loadSettings, libsReady }; const { Server: WebSocketServer } = WebSocket; @@ -59,7 +62,7 @@ interface ListenerEvents { sender: Listener } -interface ControllerEvents { +interface MainServerEvents { close?: { force: boolean } } @@ -106,6 +109,8 @@ export async function initServer({ dryRun: boolean; }) { + Controller.handleSettings(settings); + if (checkServerConfig(settings)[0] !== true) throw "ServerConfig did not pass validator"; @@ -118,6 +123,10 @@ export async function initServer({ startup.eventer.emit("serverOpen", startup.servers, startup.hosts, !!settingshttps, dryRun); startup.printWelcomeMessage(); + startup.disposer.add(() => { + + }); + Controller.handleServer(startup); if (dryRun) console.log("DRY RUN: No further processing is likely to happen"); @@ -138,7 +147,7 @@ export class MainServer { hosts: string[] isHttps: boolean events = new Subject(); - command = new Subject(); + command = new Subject(); disposer = new Subscription(); debugOutput: Writable; @@ -293,6 +302,8 @@ export class MainServer { let { iface } = e.sender; if (e.close) { this.debug(4, "server %s closed", iface); + if (this.servers.every(e => e.closed)) + this.disposer.unsubscribe(); } else if (e.listening) { this.debug(1, "server %s listening", iface); } else if (e.request) { @@ -378,6 +389,9 @@ export class MainServer { static handleAdminRoute(state: StateObject) { switch (state.path[2]) { + case "controller": + Controller.handleController(state); + break; case "authenticate": handleAuthRoute(state); break; @@ -395,7 +409,7 @@ export class Listener { constructor( public debugOutput: Writable, public events: Subject, - public command: Observable, + public command: Observable, public iface: string, public server: https.Server | http.Server ) { @@ -436,14 +450,14 @@ export class Listener { false && this.server.on("clientError", (error, socket) => { this.events.next({ sender: this, error: { error, socket, type: "connection" } }); - }) + }); this.server.on("close", () => { this.closing = true; - this.closed = true; - this.events.next({ sender: this, close: true }); - if (this.sockets.length > 0) - console.log("BUG: Listener#sockets.length > 0 -- please report"); + process.nextTick(() => { + this.closed = true; + this.events.next({ sender: this, close: true }); + }); }); this.wss.on("connection", (client, request) => { false && client.on("error", (error) => { @@ -461,6 +475,8 @@ export class Listener { close() { this.closing = true; this.server.close(); + //this is a hack which should release the listener address + this.server.emit("close"); } sockets: Socket[] = []; /** same as close but also destroys all open sockets */ @@ -472,3 +488,63 @@ export class Listener { this.sockets = []; } } +let instance: Controller; +let timestamp: number = 0; +let expectedControllers: string = ""; +interface ControllerEvents { + restart: boolean; + getSettings: boolean; + putSettings: ServerConfigSchema; + timestamp: number; + signature: string; + publicKeyHash: string; +} +export class Controller { + static timestamp: number; + static handleSettings(settings: ServerConfig) { + let safeJSON = (key, val) => { + if (key === "__proto__" || key === "constructor") return undefined; + else return val; + } + if (!checkController.check(JSON.parse(JSON.stringify(settings.controllers, safeJSON), safeJSON))) { + Controller.handleServer = (startup) => { }; + Controller.handleController = (state) => { state.throw(500); return Promise.resolve(); } + return false; + } else { + expectedControllers = JSON.stringify(settings.controllers, safeJSON); + return true; + } + } + static handleServer(startup: MainServer) { + + } + static async handleController(state: StateObject) { + if (state.req.method !== "POST" || state.url.pathname !== "/") + return state.throw(400); + + await state.recieveBody(false, () => { }); + + let cont: ServerConfig_Controller = {} as any; + let json: ControllerEvents = {} as any; + + if (json.timestamp <= timestamp) return state.throw(403); + + timestamp = json.timestamp + + let restart = !!json.restart; + let settings = !!json.putSettings; + + let allowed = (!restart || cont.allowRestart) && (!settings || cont.allowSave); + + if (!allowed) return state.throw(403); + + + } + constructor(public main: MainServer) { + + } +} +// This isn't ready yet +Controller.handleSettings = () => true; +Controller.handleServer = () => { }; +Controller.handleController = async (state) => { state.throw(500); }; \ No newline at end of file diff --git a/src/state-object.ts b/src/state-object.ts index 1a16eb7..c909cd3 100644 --- a/src/state-object.ts +++ b/src/state-object.ts @@ -82,7 +82,7 @@ export class StateObject { startTime: [number, number]; timestamp: string; - body: string = ""; + body: Buffer[] = []; json: any | undefined; @@ -311,13 +311,13 @@ export class StateObject { let chunks: Buffer[] = []; this._req.on("data", chunk => { if (typeof chunk === "string") { - chunks.push(Buffer.from(chunk)); + if (chunk.length) chunks.push(Buffer.from(chunk)); } else { - chunks.push(chunk); + if (chunk.byteLength) chunks.push(chunk); } }); this._req.on("end", () => { - this.body = Buffer.concat(chunks).toString("utf8"); + this.body = chunks; // Buffer.concat(chunks).toString("utf8"); if (this.body.length === 0 || !parseJSON) return resolve(); @@ -332,8 +332,8 @@ export class StateObject { : errorCB; this.json = catchHandler - ? tryParseJSON(this.body, catchHandler) - : tryParseJSON(this.body); + ? tryParseJSON(Buffer.concat(this.body).toString("utf8"), catchHandler) + : tryParseJSON(Buffer.concat(this.body).toString("utf8")); resolve(); }); }); @@ -397,7 +397,6 @@ export class StateObject { "utf8" ); } - // private debugLog: typeof DebugLog = StateObject.DebugLogger("STATE "); } @@ -423,13 +422,10 @@ function getTreeOptions(event: RequestEvent, result: PathResolverResult) { indexExts: [], }, }; - // console.log(state.ancestry); ancestry.forEach(e => { - // console.log(e); e.$options && e.$options.forEach(f => { if (f.$element === "auth" || f.$element === "putsaver" || f.$element === "index") { - // console.log(f); Object.keys(f).forEach(k => { if (f[k] === undefined) return; options[f.$element][k] = f[k];